From 9aa17084686fb20a8428f64012dc1578b16a2604 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 17 May 2022 07:48:24 -0700 Subject: [PATCH] Some Fixes for Scatter Charts (#2828) * Some Fixes for Scatter Charts Chart issues have been pouring in recently. This is a partial response to issue #2762. It implements "no joins" for scatter charts, as well as having the reader and writer handle "point size", "line width", and "color" for markers. A new boolean property `scatterLines`, with setter and getter, is added to DataSeriesValues to handle joins (default is true which means scatter plot points *are* joined by lines). Some, but not yet all, default font properties for the chart title are handled (color and, surprisingly, font name present challenges). With these changes, sample 32readwriteScatterChart1.xlsx now looks closer to its source. There are still some differences (x-axis changes), but I think this change is already large enough. I can work on the other problems later. The code for reading charts has not yet been converted to be namespace aware. Having a tiny island of aware code in a sea of unaware makes no sense to me, so some of the new code is likewise unaware. I hope to be able to get to it eventually, but, among other considerations, it is difficult to generate suitable test cases. * Add Formal Tests Essentially the same as the corresponding Samples, but with formal assertions. * Clean Up Some Code in Reader/Xlsx/Chart Having added code to support default font attributes as well as element-specific font attributes for chart captions, there was duplicated code between the default and specific sections. I hope that this PR makes the code easier to follow. * Add Support for Font Name and Color to XLSX Chart Titles XML layout for these in new files differs from what program was expecting. Not sure if program expectations were wrong, or if this is a change to Excel since initial development. * Minor Improvement Handle theoretical case where Chart title has text but no font information. * Support Bezier Curve and Scaling of X-Axis on Scatter Plot For Bezier, need to specify `` tag in addition to already supplied `` For X-Axis, scatter needs to supply both X and y axis as `` rather than `` for X. --- phpstan-baseline.neon | 100 +------ .../templates/32readwriteScatterChart6.xlsx | Bin 0 -> 31632 bytes src/PhpSpreadsheet/Chart/DataSeriesValues.php | 41 ++- src/PhpSpreadsheet/Reader/Xlsx/Chart.php | 227 +++++++++++---- src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 27 +- .../Writer/Xlsx/StringTable.php | 62 +++-- .../Functional/AbstractFunctional.php | 5 +- .../Writer/Xlsx/Charts32ScatterTest.php | 261 ++++++++++++++++++ .../Writer/Xlsx/Charts32XmlTest.php | 89 ++++++ 9 files changed, 615 insertions(+), 197 deletions(-) create mode 100644 samples/templates/32readwriteScatterChart6.xlsx create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32ScatterTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32XmlTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 664306fd..de63f72f 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -1215,26 +1215,6 @@ parameters: count: 2 path: src/PhpSpreadsheet/Chart/DataSeries.php - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\DataSeriesValues\\:\\:refresh\\(\\) has parameter \\$flatten with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Chart/DataSeriesValues.php - - - - message: "#^Property PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\DataSeriesValues\\:\\:\\$dataSource \\(string\\) does not accept string\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Chart/DataSeriesValues.php - - - - message: "#^Property PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\DataSeriesValues\\:\\:\\$dataTypeValues has no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Chart/DataSeriesValues.php - - - - message: "#^Property PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\DataSeriesValues\\:\\:\\$fillColor \\(array\\\\|string\\) does not accept array\\\\|string\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Chart/DataSeriesValues.php - - message: "#^Parameter \\#1 \\$angle of method PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\GridLines\\:\\:setShadowAngle\\(\\) expects int, int\\|null given\\.$#" count: 1 @@ -2560,56 +2540,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Reader/Xlsx/BaseParserClass.php - - - message: "#^Cannot call method getFont\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\RichText\\\\Run\\|null\\.$#" - count: 12 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setBold\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setColor\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setItalic\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setName\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setSize\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setStrikethrough\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setSubscript\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setSuperscript\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Cannot call method setUnderline\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 3 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:chartDataSeries\\(\\) has no return type specified\\.$#" count: 1 @@ -2635,11 +2565,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:chartDataSeriesValueSet\\(\\) has parameter \\$marker with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:chartDataSeriesValueSet\\(\\) has parameter \\$namespacesChartMeta with no type specified\\.$#" count: 1 @@ -2715,21 +2640,6 @@ parameters: count: 1 path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:readColor\\(\\) has no return type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:readColor\\(\\) has parameter \\$background with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - - - message: "#^Method PhpOffice\\\\PhpSpreadsheet\\\\Reader\\\\Xlsx\\\\Chart\\:\\:readColor\\(\\) has parameter \\$color with no type specified\\.$#" - count: 1 - path: src/PhpSpreadsheet/Reader/Xlsx/Chart.php - - message: "#^Parameter \\#1 \\$position of class PhpOffice\\\\PhpSpreadsheet\\\\Chart\\\\Legend constructor expects string, bool\\|float\\|int\\|string\\|null given\\.$#" count: 1 @@ -4997,7 +4907,7 @@ parameters: - message: "#^Cannot call method getBold\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 + count: 1 path: src/PhpSpreadsheet/Writer/Xlsx/StringTable.php - @@ -5007,12 +4917,12 @@ parameters: - message: "#^Cannot call method getItalic\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 + count: 1 path: src/PhpSpreadsheet/Writer/Xlsx/StringTable.php - message: "#^Cannot call method getName\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 + count: 1 path: src/PhpSpreadsheet/Writer/Xlsx/StringTable.php - @@ -5022,7 +4932,7 @@ parameters: - message: "#^Cannot call method getStrikethrough\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 + count: 1 path: src/PhpSpreadsheet/Writer/Xlsx/StringTable.php - @@ -5037,7 +4947,7 @@ parameters: - message: "#^Cannot call method getUnderline\\(\\) on PhpOffice\\\\PhpSpreadsheet\\\\Style\\\\Font\\|null\\.$#" - count: 2 + count: 1 path: src/PhpSpreadsheet/Writer/Xlsx/StringTable.php - diff --git a/samples/templates/32readwriteScatterChart6.xlsx b/samples/templates/32readwriteScatterChart6.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..ddfa3048317b9efbc52b3527a87512d28970141a GIT binary patch literal 31632 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 Zb^j;rm+L1ff~ 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 zckB^x+4eW!6K9O^$?setDataType($dataType); $this->dataSource = $dataSource; @@ -100,6 +104,9 @@ class DataSeriesValues $this->dataValues = $dataValues; $this->pointMarker = $marker; $this->fillColor = $fillColor; + if (is_numeric($pointSize)) { + $this->pointSize = (int) $pointSize; + } } /** @@ -126,7 +133,7 @@ class DataSeriesValues */ public function setDataType($dataType) { - if (!in_array($dataType, self::$dataTypeValues)) { + if (!in_array($dataType, self::DATA_TYPE_VALUES)) { throw new Exception('Invalid datatype for chart data series values'); } $this->dataType = $dataType; @@ -137,7 +144,7 @@ class DataSeriesValues /** * Get Series Data Source (formula). * - * @return string + * @return ?string */ public function getDataSource() { @@ -147,7 +154,7 @@ class DataSeriesValues /** * Set Series Data Source (formula). * - * @param string $dataSource + * @param ?string $dataSource * * @return $this */ @@ -239,7 +246,7 @@ class DataSeriesValues /** * Get fill color. * - * @return string|string[] HEX color or array with HEX colors + * @return null|string|string[] HEX color or array with HEX colors */ public function getFillColor() { @@ -249,7 +256,7 @@ class DataSeriesValues /** * Set fill color for series. * - * @param string|string[] $color HEX color or array with HEX colors + * @param null|string|string[] $color HEX color or array with HEX colors * * @return DataSeriesValues */ @@ -260,7 +267,7 @@ class DataSeriesValues $this->validateColor($colorValue); } } else { - $this->validateColor($color); + $this->validateColor("$color"); } $this->fillColor = $color; @@ -379,7 +386,7 @@ class DataSeriesValues return $this; } - public function refresh(Worksheet $worksheet, $flatten = true): void + public function refresh(Worksheet $worksheet, bool $flatten = true): void { if ($this->dataSource !== null) { $calcEngine = Calculation::getInstance($worksheet->getParent()); @@ -421,4 +428,16 @@ class DataSeriesValues $this->pointCount = count($this->dataValues); } } + + public function getScatterLines(): bool + { + return $this->scatterLines; + } + + public function setScatterLines(bool $scatterLines): self + { + $this->scatterLines = $scatterLines; + + return $this; + } } diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php index 680335b7..4e3cd02d 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php @@ -25,7 +25,7 @@ class Chart private static function getAttribute(SimpleXMLElement $component, $name, $format) { $attributes = $component->attributes(); - if (isset($attributes[$name])) { + if (@isset($attributes[$name])) { if ($format == 'string') { return (string) $attributes[$name]; } elseif ($format == 'integer') { @@ -42,15 +42,6 @@ class Chart return null; } - private static function readColor($color, $background = false) - { - if (isset($color['rgb'])) { - return (string) $color['rgb']; - } elseif (isset($color['indexed'])) { - return Color::indexedColor($color['indexed'] - 7, $background)->getARGB(); - } - } - /** * @param string $chartName * @@ -293,6 +284,10 @@ class Chart case 'ser': $marker = null; $seriesIndex = ''; + $srgbClr = null; + $lineWidth = null; + $pointSize = null; + $noFill = false; foreach ($seriesDetails as $seriesKey => $seriesDetail) { switch ($seriesKey) { case 'idx': @@ -307,9 +302,25 @@ class Chart case 'tx': $seriesLabel[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta); + break; + case 'spPr': + $ln = $seriesDetail->children($namespacesChartMeta['a'])->ln; + $lineWidth = self::getAttribute($ln, 'w', 'string'); + if (is_countable($ln->noFill) && count($ln->noFill) === 1) { + $noFill = true; + } + break; case 'marker': $marker = self::getAttribute($seriesDetail->symbol, 'val', 'string'); + $pointSize = self::getAttribute($seriesDetail->size, 'val', 'string'); + $pointSize = is_numeric($pointSize) ? ((int) $pointSize) : null; + if (count($seriesDetail->spPr) === 1) { + $ln = $seriesDetail->spPr->children($namespacesChartMeta['a']); + if (count($ln->solidFill) === 1) { + $srgbClr = self::getattribute($ln->solidFill->srgbClr, 'val', 'string'); + } + } break; case 'smooth': @@ -321,30 +332,52 @@ class Chart break; case 'val': - $seriesValues[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, $marker); + $seriesValues[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, "$marker", "$srgbClr", "$pointSize"); break; case 'xVal': - $seriesCategory[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, $marker); + $seriesCategory[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, "$marker", "$srgbClr", "$pointSize"); break; case 'yVal': - $seriesValues[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, $marker); + $seriesValues[$seriesIndex] = self::chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, "$marker", "$srgbClr", "$pointSize"); break; } } + if ($noFill) { + if (isset($seriesLabel[$seriesIndex])) { + $seriesLabel[$seriesIndex]->setScatterLines(false); + } + if (isset($seriesCategory[$seriesIndex])) { + $seriesCategory[$seriesIndex]->setScatterLines(false); + } + if (isset($seriesValues[$seriesIndex])) { + $seriesValues[$seriesIndex]->setScatterLines(false); + } + } + if (is_numeric($lineWidth)) { + if (isset($seriesLabel[$seriesIndex])) { + $seriesLabel[$seriesIndex]->setLineWidth((int) $lineWidth); + } + if (isset($seriesCategory[$seriesIndex])) { + $seriesCategory[$seriesIndex]->setLineWidth((int) $lineWidth); + } + if (isset($seriesValues[$seriesIndex])) { + $seriesValues[$seriesIndex]->setLineWidth((int) $lineWidth); + } + } } } return new DataSeries($plotType, $multiSeriesType, $plotOrder, $seriesLabel, $seriesCategory, $seriesValues, $smoothLine); } - private static function chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, $marker = null) + private static function chartDataSeriesValueSet($seriesDetail, $namespacesChartMeta, ?string $marker = null, ?string $srgbClr = null, ?string $pointSize = null) { if (isset($seriesDetail->strRef)) { $seriesSource = (string) $seriesDetail->strRef->f; - $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker); + $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker, $srgbClr, "$pointSize"); if (isset($seriesDetail->strRef->strCache)) { $seriesData = self::chartDataSeriesValues($seriesDetail->strRef->strCache->children($namespacesChartMeta['c']), 's'); @@ -356,7 +389,7 @@ class Chart return $seriesValues; } elseif (isset($seriesDetail->numRef)) { $seriesSource = (string) $seriesDetail->numRef->f; - $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, $seriesSource, null, null, null, $marker); + $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, $seriesSource, null, null, null, $marker, $srgbClr, "$pointSize"); if (isset($seriesDetail->numRef->numCache)) { $seriesData = self::chartDataSeriesValues($seriesDetail->numRef->numCache->children($namespacesChartMeta['c'])); $seriesValues @@ -367,7 +400,7 @@ class Chart return $seriesValues; } elseif (isset($seriesDetail->multiLvlStrRef)) { $seriesSource = (string) $seriesDetail->multiLvlStrRef->f; - $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker); + $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker, $srgbClr, "$pointSize"); if (isset($seriesDetail->multiLvlStrRef->multiLvlStrCache)) { $seriesData = self::chartDataSeriesValuesMultiLevel($seriesDetail->multiLvlStrRef->multiLvlStrCache->children($namespacesChartMeta['c']), 's'); @@ -379,7 +412,7 @@ class Chart return $seriesValues; } elseif (isset($seriesDetail->multiLvlNumRef)) { $seriesSource = (string) $seriesDetail->multiLvlNumRef->f; - $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker); + $seriesValues = new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, $seriesSource, null, null, null, $marker, $srgbClr, "$pointSize"); if (isset($seriesDetail->multiLvlNumRef->multiLvlNumCache)) { $seriesData = self::chartDataSeriesValuesMultiLevel($seriesDetail->multiLvlNumRef->multiLvlNumCache->children($namespacesChartMeta['c']), 's'); @@ -474,62 +507,138 @@ class Chart { $value = new RichText(); $objText = null; + $defaultFontSize = null; + $defaultBold = null; + $defaultItalic = null; + $defaultUnderscore = null; + $defaultStrikethrough = null; + $defaultBaseline = null; + $defaultFontName = null; + $defaultColor = null; + if (isset($titleDetailPart->pPr->defRPr)) { + /** @var ?int */ + $defaultFontSize = self::getAttribute($titleDetailPart->pPr->defRPr, 'sz', 'integer'); + /** @var ?bool */ + $defaultBold = self::getAttribute($titleDetailPart->pPr->defRPr, 'b', 'boolean'); + /** @var ?bool */ + $defaultItalic = self::getAttribute($titleDetailPart->pPr->defRPr, 'i', 'boolean'); + /** @var ?string */ + $defaultUnderscore = self::getAttribute($titleDetailPart->pPr->defRPr, 'u', 'string'); + /** @var ?string */ + $defaultStrikethrough = self::getAttribute($titleDetailPart->pPr->defRPr, 'strike', 'string'); + /** @var ?int */ + $defaultBaseline = self::getAttribute($titleDetailPart->pPr->defRPr, 'baseline', 'integer'); + if (isset($titleDetailPart->pPr->defRPr->latin)) { + /** @var ?string */ + $defaultFontName = self::getAttribute($titleDetailPart->pPr->defRPr->latin, 'typeface', 'string'); + } + if (isset($titleDetailPart->pPr->defRPr->solidFill->srgbClr)) { + /** @var ?string */ + $defaultColor = self::getAttribute($titleDetailPart->pPr->defRPr->solidFill->srgbClr, 'val', 'string'); + } + } foreach ($titleDetailPart as $titleDetailElementKey => $titleDetailElement) { if (isset($titleDetailElement->t)) { $objText = $value->createTextRun((string) $titleDetailElement->t); } + if ($objText === null || $objText->getFont() === null) { + continue; + } + $fontSize = null; + $bold = null; + $italic = null; + $underscore = null; + $strikethrough = null; + $baseline = null; + $fontName = null; + $fontColor = null; if (isset($titleDetailElement->rPr)) { + // not used now, not sure it ever was, grandfathering if (isset($titleDetailElement->rPr->rFont['val'])) { - $objText->getFont()->setName((string) $titleDetailElement->rPr->rFont['val']); - } - - $fontSize = (self::getAttribute($titleDetailElement->rPr, 'sz', 'integer')); - if (is_int($fontSize)) { - $objText->getFont()->setSize(floor($fontSize / 100)); - } - - $fontColor = (self::getAttribute($titleDetailElement->rPr, 'color', 'string')); - if ($fontColor !== null) { - $objText->getFont()->setColor(new Color(self::readColor($fontColor))); + $fontName = (string) $titleDetailElement->rPr->rFont['val']; + } + if (isset($titleDetailElement->rPr->latin)) { + /** @var ?string */ + $fontName = self::getAttribute($titleDetailElement->rPr->latin, 'typeface', 'string'); + } + /** @var ?int */ + $fontSize = self::getAttribute($titleDetailElement->rPr, 'sz', 'integer'); + + // not used now, not sure it ever was, grandfathering + /** @var ?string */ + $fontColor = self::getAttribute($titleDetailElement->rPr, 'color', 'string'); + if (isset($titleDetailElement->rPr->solidFill->srgbClr)) { + /** @var ?string */ + $fontColor = self::getAttribute($titleDetailElement->rPr->solidFill->srgbClr, 'val', 'string'); } + /** @var ?bool */ $bold = self::getAttribute($titleDetailElement->rPr, 'b', 'boolean'); - if ($bold !== null) { - $objText->getFont()->setBold($bold); - } + /** @var ?bool */ $italic = self::getAttribute($titleDetailElement->rPr, 'i', 'boolean'); - if ($italic !== null) { - $objText->getFont()->setItalic($italic); - } + /** @var ?int */ $baseline = self::getAttribute($titleDetailElement->rPr, 'baseline', 'integer'); - if ($baseline !== null) { - if ($baseline > 0) { - $objText->getFont()->setSuperscript(true); - } elseif ($baseline < 0) { - $objText->getFont()->setSubscript(true); - } - } - $underscore = (self::getAttribute($titleDetailElement->rPr, 'u', 'string')); - if ($underscore !== null) { - if ($underscore == 'sng') { - $objText->getFont()->setUnderline(Font::UNDERLINE_SINGLE); - } elseif ($underscore == 'dbl') { - $objText->getFont()->setUnderline(Font::UNDERLINE_DOUBLE); - } else { - $objText->getFont()->setUnderline(Font::UNDERLINE_NONE); - } - } + /** @var ?string */ + $underscore = self::getAttribute($titleDetailElement->rPr, 'u', 'string'); - $strikethrough = (self::getAttribute($titleDetailElement->rPr, 's', 'string')); - if ($strikethrough !== null) { - if ($strikethrough == 'noStrike') { - $objText->getFont()->setStrikethrough(false); - } else { - $objText->getFont()->setStrikethrough(true); - } + /** @var ?string */ + $strikethrough = self::getAttribute($titleDetailElement->rPr, 's', 'string'); + } + + $fontName = $fontName ?? $defaultFontName; + if ($fontName !== null) { + $objText->getFont()->setName($fontName); + } + + $fontSize = $fontSize ?? $defaultFontSize; + if (is_int($fontSize)) { + $objText->getFont()->setSize(floor($fontSize / 100)); + } + + $fontColor = $fontColor ?? $defaultColor; + if ($fontColor !== null) { + $objText->getFont()->setColor(new Color($fontColor)); + } + + $bold = $bold ?? $defaultBold; + if ($bold !== null) { + $objText->getFont()->setBold($bold); + } + + $italic = $italic ?? $defaultItalic; + if ($italic !== null) { + $objText->getFont()->setItalic($italic); + } + + $baseline = $baseline ?? $defaultBaseline; + if ($baseline !== null) { + if ($baseline > 0) { + $objText->getFont()->setSuperscript(true); + } elseif ($baseline < 0) { + $objText->getFont()->setSubscript(true); + } + } + + $underscore = $underscore ?? $defaultUnderscore; + if ($underscore !== null) { + if ($underscore == 'sng') { + $objText->getFont()->setUnderline(Font::UNDERLINE_SINGLE); + } elseif ($underscore == 'dbl') { + $objText->getFont()->setUnderline(Font::UNDERLINE_DOUBLE); + } else { + $objText->getFont()->setUnderline(Font::UNDERLINE_NONE); + } + } + + $strikethrough = $strikethrough ?? $defaultStrikethrough; + if ($strikethrough !== null) { + if ($strikethrough == 'noStrike') { + $objText->getFont()->setStrikethrough(false); + } else { + $objText->getFont()->setStrikethrough(true); } } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index ba7a6545..f18d9216 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -306,7 +306,7 @@ class Chart extends WriterPart if ($chartType === DataSeries::TYPE_BUBBLECHART) { $this->writeValueAxis($objWriter, $xAxisLabel, $chartType, $id1, $id2, $catIsMultiLevelSeries, $xAxis, $majorGridlines, $minorGridlines); } else { - $this->writeCategoryAxis($objWriter, $xAxisLabel, $id1, $id2, $catIsMultiLevelSeries, $xAxis); + $this->writeCategoryAxis($objWriter, $xAxisLabel, $id1, $id2, $catIsMultiLevelSeries, $xAxis, ($chartType === DataSeries::TYPE_SCATTERCHART) ? 'c:valAx' : 'c:catAx'); } $this->writeValueAxis($objWriter, $yAxisLabel, $chartType, $id1, $id2, $valIsMultiLevelSeries, $yAxis, $majorGridlines, $minorGridlines); @@ -367,9 +367,9 @@ class Chart extends WriterPart * @param string $id2 * @param bool $isMultiLevelSeries */ - private function writeCategoryAxis(XMLWriter $objWriter, ?Title $xAxisLabel, $id1, $id2, $isMultiLevelSeries, Axis $yAxis): void + private function writeCategoryAxis(XMLWriter $objWriter, ?Title $xAxisLabel, $id1, $id2, $isMultiLevelSeries, Axis $yAxis, string $element = 'c:catAx'): void { - $objWriter->startElement('c:catAx'); + $objWriter->startElement($element); if ($id1 > 0) { $objWriter->startElement('c:axId'); @@ -1016,7 +1016,7 @@ class Chart extends WriterPart * @param bool $valIsMultiLevelSeries Is value set a multi-series set * @param string $plotGroupingType Type of grouping for multi-series values */ - private function writePlotGroup(?DataSeries $plotGroup, $groupType, XMLWriter $objWriter, &$catIsMultiLevelSeries, &$valIsMultiLevelSeries, &$plotGroupingType): void + private function writePlotGroup(?DataSeries $plotGroup, string $groupType, XMLWriter $objWriter, &$catIsMultiLevelSeries, &$valIsMultiLevelSeries, &$plotGroupingType): void { if ($plotGroup === null) { return; @@ -1104,7 +1104,7 @@ class Chart extends WriterPart } // Formatting for the points - if (($groupType == DataSeries::TYPE_LINECHART) || ($groupType == DataSeries::TYPE_STOCKCHART)) { + if (($groupType == DataSeries::TYPE_LINECHART) || ($groupType == DataSeries::TYPE_STOCKCHART || ($groupType === DataSeries::TYPE_SCATTERCHART && $plotSeriesValues !== false && !$plotSeriesValues->getScatterLines()))) { $plotLineWidth = 12700; if ($plotSeriesValues) { $plotLineWidth = $plotSeriesValues->getLineWidth(); @@ -1113,7 +1113,7 @@ class Chart extends WriterPart $objWriter->startElement('c:spPr'); $objWriter->startElement('a:ln'); $objWriter->writeAttribute('w', $plotLineWidth); - if ($groupType == DataSeries::TYPE_STOCKCHART) { + if ($groupType == DataSeries::TYPE_STOCKCHART || $groupType === DataSeries::TYPE_SCATTERCHART) { $objWriter->startElement('a:noFill'); $objWriter->endElement(); } elseif ($plotLabel) { @@ -1142,6 +1142,16 @@ class Chart extends WriterPart $objWriter->startElement('c:size'); $objWriter->writeAttribute('val', (string) $plotSeriesValues->getPointSize()); $objWriter->endElement(); + $fillColor = $plotSeriesValues->getFillColor(); + if (is_string($fillColor) && $fillColor !== '') { + $objWriter->startElement('c:spPr'); + $objWriter->startElement('a:solidFill'); + $objWriter->startElement('a:srgbClr'); + $objWriter->writeAttribute('val', $fillColor); + $objWriter->endElement(); // srgbClr + $objWriter->endElement(); // solidFill + $objWriter->endElement(); // spPr + } } $objWriter->endElement(); @@ -1192,6 +1202,11 @@ class Chart extends WriterPart $this->writePlotSeriesValues($plotSeriesValues, $objWriter, $groupType, 'num'); $objWriter->endElement(); + if ($groupType === DataSeries::TYPE_SCATTERCHART && $plotGroup->getPlotStyle() === 'smoothMarker') { + $objWriter->startElement('c:smooth'); + $objWriter->writeAttribute('val', '1'); + $objWriter->endElement(); + } } if ($groupType === DataSeries::TYPE_BUBBLECHART) { diff --git a/src/PhpSpreadsheet/Writer/Xlsx/StringTable.php b/src/PhpSpreadsheet/Writer/Xlsx/StringTable.php index a64e0d68..6808f33e 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/StringTable.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/StringTable.php @@ -215,36 +215,48 @@ class StringTable extends WriterPart foreach ($elements as $element) { // r $objWriter->startElement($prefix . 'r'); + if ($element->getFont() !== null) { + // rPr + $objWriter->startElement($prefix . 'rPr'); + $size = $element->getFont()->getSize(); + if (is_numeric($size)) { + $objWriter->writeAttribute('sz', (string) (int) ($size * 100)); + } - // rPr - $objWriter->startElement($prefix . 'rPr'); + // Bold + $objWriter->writeAttribute('b', ($element->getFont()->getBold() ? 1 : 0)); + // Italic + $objWriter->writeAttribute('i', ($element->getFont()->getItalic() ? 1 : 0)); + // Underline + $underlineType = $element->getFont()->getUnderline(); + switch ($underlineType) { + case 'single': + $underlineType = 'sng'; - // Bold - $objWriter->writeAttribute('b', ($element->getFont()->getBold() ? 1 : 0)); - // Italic - $objWriter->writeAttribute('i', ($element->getFont()->getItalic() ? 1 : 0)); - // Underline - $underlineType = $element->getFont()->getUnderline(); - switch ($underlineType) { - case 'single': - $underlineType = 'sng'; + break; + case 'double': + $underlineType = 'dbl'; - break; - case 'double': - $underlineType = 'dbl'; + break; + } + $objWriter->writeAttribute('u', $underlineType); + // Strikethrough + $objWriter->writeAttribute('strike', ($element->getFont()->getStrikethrough() ? 'sngStrike' : 'noStrike')); - break; + // Color + $objWriter->startElement($prefix . 'solidFill'); + $objWriter->startElement($prefix . 'srgbClr'); + $objWriter->writeAttribute('val', $element->getFont()->getColor()->getRGB()); + $objWriter->endElement(); // srgbClr + $objWriter->endElement(); // solidFill + + // fontName + $objWriter->startElement($prefix . 'latin'); + $objWriter->writeAttribute('typeface', $element->getFont()->getName()); + $objWriter->endElement(); + + $objWriter->endElement(); } - $objWriter->writeAttribute('u', $underlineType); - // Strikethrough - $objWriter->writeAttribute('strike', ($element->getFont()->getStrikethrough() ? 'sngStrike' : 'noStrike')); - - // rFont - $objWriter->startElement($prefix . 'latin'); - $objWriter->writeAttribute('typeface', $element->getFont()->getName()); - $objWriter->endElement(); - - $objWriter->endElement(); // t $objWriter->startElement($prefix . 't'); diff --git a/tests/PhpSpreadsheetTests/Functional/AbstractFunctional.php b/tests/PhpSpreadsheetTests/Functional/AbstractFunctional.php index da821532..60351d71 100644 --- a/tests/PhpSpreadsheetTests/Functional/AbstractFunctional.php +++ b/tests/PhpSpreadsheetTests/Functional/AbstractFunctional.php @@ -19,10 +19,13 @@ abstract class AbstractFunctional extends TestCase * * @return Spreadsheet */ - protected function writeAndReload(Spreadsheet $spreadsheet, $format, ?callable $readerCustomizer = null) + protected function writeAndReload(Spreadsheet $spreadsheet, $format, ?callable $readerCustomizer = null, ?callable $writerCustomizer = null) { $filename = File::temporaryFilename(); $writer = IOFactory::createWriter($spreadsheet, $format); + if ($writerCustomizer) { + $writerCustomizer($writer); + } $writer->save($filename); $reader = IOFactory::createReader($format); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32ScatterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32ScatterTest.php new file mode 100644 index 00000000..dc7fbc0b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32ScatterTest.php @@ -0,0 +1,261 @@ +setIncludeCharts(true); + } + + public function writeCharts(XlsxWriter $writer): void + { + $writer->setIncludeCharts(true); + } + + public function testScatter1(): void + { + $file = self::DIRECTORY . '32readwriteScatterChart1.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($file); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame(1, $sheet->getChartCount()); + /** @var callable */ + $callableReader = [$this, 'readCharts']; + /** @var callable */ + $callableWriter = [$this, 'writeCharts']; + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx', $callableReader, $callableWriter); + $spreadsheet->disconnectWorksheets(); + + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame('Charts', $sheet->getTitle()); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0]; + self::assertNotNull($chart); + $title = $chart->getTitle(); + $captionArray = $title->getCaption(); + self::assertIsArray($captionArray); + self::assertCount(1, $captionArray); + $caption = $captionArray[0]; + self::assertInstanceOf(RichText::class, $caption); + self::assertSame('Scatter - No Join and Markers', $caption->getPlainText()); + $elements = $caption->getRichTextElements(); + self::assertCount(1, $elements); + $run = $elements[0]; + self::assertInstanceOf(Run::class, $run); + $font = $run->getFont(); + self::assertInstanceOf(Font::class, $font); + self::assertSame('Calibri', $font->getName()); + self::assertEquals(12, $font->getSize()); + self::assertTrue($font->getBold()); + self::assertFalse($font->getItalic()); + self::assertFalse($font->getSuperscript()); + self::assertFalse($font->getSubscript()); + self::assertFalse($font->getStrikethrough()); + self::assertSame('none', $font->getUnderline()); + self::assertSame('000000', $font->getColor()->getRGB()); + + $plotArea = $chart->getPlotArea(); + $plotSeries = $plotArea->getPlotGroup(); + self::assertCount(1, $plotSeries); + $dataSeries = $plotSeries[0]; + $plotValues = $dataSeries->getPlotValues(); + self::assertCount(3, $plotValues); + $values = $plotValues[0]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[1]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[2]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(7, $values->getPointSize()); + self::assertSame('FFFF00', $values->getFillColor()); + + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public function testScatter6(): void + { + $file = self::DIRECTORY . '32readwriteScatterChart6.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($file); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame(1, $sheet->getChartCount()); + /** @var callable */ + $callableReader = [$this, 'readCharts']; + /** @var callable */ + $callableWriter = [$this, 'writeCharts']; + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx', $callableReader, $callableWriter); + $spreadsheet->disconnectWorksheets(); + + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame('Charts', $sheet->getTitle()); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0]; + self::assertNotNull($chart); + $title = $chart->getTitle(); + $captionArray = $title->getCaption(); + self::assertIsArray($captionArray); + self::assertCount(1, $captionArray); + $caption = $captionArray[0]; + self::assertInstanceOf(RichText::class, $caption); + self::assertSame('Scatter - Rich Text Title No Join and Markers', $caption->getPlainText()); + $elements = $caption->getRichTextElements(); + self::assertCount(3, $elements); + + $run = $elements[0]; + self::assertInstanceOf(Run::class, $run); + $font = $run->getFont(); + self::assertInstanceOf(Font::class, $font); + self::assertSame('Calibri', $font->getName()); + self::assertEquals(12, $font->getSize()); + self::assertTrue($font->getBold()); + self::assertFalse($font->getItalic()); + self::assertFalse($font->getSuperscript()); + self::assertFalse($font->getSubscript()); + self::assertFalse($font->getStrikethrough()); + self::assertSame('none', $font->getUnderline()); + self::assertSame('000000', $font->getColor()->getRGB()); + + $run = $elements[1]; + self::assertInstanceOf(Run::class, $run); + $font = $run->getFont(); + self::assertInstanceOf(Font::class, $font); + self::assertSame('Courier New', $font->getName()); + self::assertEquals(10, $font->getSize()); + self::assertFalse($font->getBold()); + self::assertFalse($font->getItalic()); + self::assertFalse($font->getSuperscript()); + self::assertFalse($font->getSubscript()); + self::assertFalse($font->getStrikethrough()); + self::assertSame('single', $font->getUnderline()); + self::assertSame('00B0F0', $font->getColor()->getRGB()); + + $run = $elements[2]; + self::assertInstanceOf(Run::class, $run); + $font = $run->getFont(); + self::assertInstanceOf(Font::class, $font); + self::assertSame('Calibri', $font->getName()); + self::assertEquals(12, $font->getSize()); + self::assertTrue($font->getBold()); + self::assertFalse($font->getItalic()); + self::assertFalse($font->getSuperscript()); + self::assertFalse($font->getSubscript()); + self::assertFalse($font->getStrikethrough()); + self::assertSame('none', $font->getUnderline()); + self::assertSame('000000', $font->getColor()->getRGB()); + + $plotArea = $chart->getPlotArea(); + $plotSeries = $plotArea->getPlotGroup(); + self::assertCount(1, $plotSeries); + $dataSeries = $plotSeries[0]; + $plotValues = $dataSeries->getPlotValues(); + self::assertCount(3, $plotValues); + $values = $plotValues[0]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[1]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[2]; + self::assertFalse($values->getScatterLines()); + self::assertSame(28575, $values->getLineWidth()); + self::assertSame(7, $values->getPointSize()); + self::assertSame('FFFF00', $values->getFillColor()); + + $reloadedSpreadsheet->disconnectWorksheets(); + } + + public function testScatter3(): void + { + $file = self::DIRECTORY . '32readwriteScatterChart3.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($file); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame(1, $sheet->getChartCount()); + /** @var callable */ + $callableReader = [$this, 'readCharts']; + /** @var callable */ + $callableWriter = [$this, 'writeCharts']; + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx', $callableReader, $callableWriter); + $spreadsheet->disconnectWorksheets(); + + $sheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame('Charts', $sheet->getTitle()); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0]; + self::assertNotNull($chart); + $title = $chart->getTitle(); + $captionArray = $title->getCaption(); + self::assertIsArray($captionArray); + self::assertCount(1, $captionArray); + $caption = $captionArray[0]; + self::assertInstanceOf(RichText::class, $caption); + self::assertSame('Scatter - Join Straight Lines and Markers', $caption->getPlainText()); + $elements = $caption->getRichTextElements(); + self::assertCount(1, $elements); + $run = $elements[0]; + self::assertInstanceOf(Run::class, $run); + $font = $run->getFont(); + self::assertInstanceOf(Font::class, $font); + self::assertSame('Calibri', $font->getName()); + self::assertEquals(12, $font->getSize()); + self::assertTrue($font->getBold()); + self::assertFalse($font->getItalic()); + self::assertFalse($font->getSuperscript()); + self::assertFalse($font->getSubscript()); + self::assertFalse($font->getStrikethrough()); + self::assertSame('none', $font->getUnderline()); + self::assertSame('000000', $font->getColor()->getRGB()); + + $plotArea = $chart->getPlotArea(); + $plotSeries = $plotArea->getPlotGroup(); + self::assertCount(1, $plotSeries); + $dataSeries = $plotSeries[0]; + $plotValues = $dataSeries->getPlotValues(); + self::assertCount(3, $plotValues); + $values = $plotValues[0]; + self::assertTrue($values->getScatterLines()); + self::assertSame(12700, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[1]; + self::assertTrue($values->getScatterLines()); + self::assertSame(12700, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + $values = $plotValues[2]; + self::assertTrue($values->getScatterLines()); + self::assertSame(12700, $values->getLineWidth()); + self::assertSame(3, $values->getPointSize()); + self::assertSame('', $values->getFillColor()); + + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32XmlTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32XmlTest.php new file mode 100644 index 00000000..75a3946c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Charts32XmlTest.php @@ -0,0 +1,89 @@ +outputFileName !== '') { + unlink($this->outputFileName); + $this->outputFileName = ''; + } + } + + /** + * @dataProvider providerScatterCharts + */ + public function testBezierCount(int $expectedCount, string $inputFile): void + { + $file = self::DIRECTORY . $inputFile; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($file); + + $writer = new XlsxWriter($spreadsheet); + $writer->setIncludeCharts(true); + $this->outputFileName = File::temporaryFilename(); + $writer->save($this->outputFileName); + $spreadsheet->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFileName; + $file .= '#xl/charts/chart2.xml'; + $data = file_get_contents($file); + // confirm that file contains expected tags + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertSame(1, substr_count($data, '')); + self::assertSame($expectedCount, substr_count($data, '')); + } + } + + public function providerScatterCharts(): array + { + return [ + 'no line' => [0, '32readwriteScatterChart1.xlsx'], + 'smooth line (Bezier)' => [3, '32readwriteScatterChart2.xlsx'], + 'straight line' => [0, '32readwriteScatterChart3.xlsx'], + ]; + } + + public function testAreaPercentageNoCat(): void + { + $file = self::DIRECTORY . '32readwriteAreaPercentageChart1.xlsx'; + $reader = new XlsxReader(); + $reader->setIncludeCharts(true); + $spreadsheet = $reader->load($file); + + $writer = new XlsxWriter($spreadsheet); + $writer->setIncludeCharts(true); + $this->outputFileName = File::temporaryFilename(); + $writer->save($this->outputFileName); + $spreadsheet->disconnectWorksheets(); + + $file = 'zip://'; + $file .= $this->outputFileName; + $file .= '#xl/charts/chart1.xml'; + $data = file_get_contents($file); + // confirm that file contains expected tags + if ($data === false) { + self::fail('Unable to read file'); + } else { + self::assertSame(0, substr_count($data, '')); + } + } +}