From 665178ab81e17d19afe5866c23f5680cd1c4b723 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Wed, 25 May 2022 22:06:43 -0700 Subject: [PATCH] More Bubble Chart Fixes Continuing the work from #2828, #2841, #2846, and #2852. This is probably my last change in this area for a while. Bubble charts can have bubbles of different sizes. Phpspreadsheet had not supported this. Openpyxl comes with sample code to generate such a chart. I was especially drawn to that solution because its namespace usage would have been unexpected before 2852. And it turned out to come with other surprises - use of absolute paths in the .rels files (PhpSpreadsheet expected only relative), use of a one-cell anchor to place the chart (PhpSpreadsheet expected two-cell anchor or absolute positioning), plaintext in the legend (Phpspreadsheet expected RichText), no cached values for chart data. Excel handles the file okay, and this PR makes sure PhpSpreadsheet does as well. This file is now Samples/Templates/32readwriteBubbleChart2, and is used for both generating a sample output file and in formal tests. A new sample in the 33* series demonstrates how to code these. --- phpstan-baseline.neon | 2 +- samples/Chart/33_Chart_create_bubble.php | 124 ++++++++++++++++++ .../templates/32readwriteBubbleChart2.xlsx | Bin 0 -> 6607 bytes src/PhpSpreadsheet/Chart/Chart.php | 15 +++ src/PhpSpreadsheet/Chart/DataSeries.php | 29 ++++ src/PhpSpreadsheet/Chart/DataSeriesValues.php | 2 +- src/PhpSpreadsheet/Reader/Xlsx.php | 39 +++++- src/PhpSpreadsheet/Reader/Xlsx/Chart.php | 22 +++- src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php | 2 + src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 78 +++++++---- src/PhpSpreadsheet/Writer/Xlsx/Drawing.php | 13 ++ .../Reader/Xlsx/ChartsOpenpyxlTest.php | 115 ++++++++++++++++ 12 files changed, 405 insertions(+), 36 deletions(-) create mode 100644 samples/Chart/33_Chart_create_bubble.php create mode 100644 samples/templates/32readwriteBubbleChart2.xlsx create mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/ChartsOpenpyxlTest.php diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index a8da793647..5c39af2f41 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -4637,7 +4637,7 @@ parameters: - message: "#^Parameter \\#2 \\$value of method XMLWriter\\:\\:writeAttribute\\(\\) expects string, int given\\.$#" - count: 43 + count: 42 path: src/PhpSpreadsheet/Writer/Xlsx/Chart.php - diff --git a/samples/Chart/33_Chart_create_bubble.php b/samples/Chart/33_Chart_create_bubble.php new file mode 100644 index 0000000000..33feea621c --- /dev/null +++ b/samples/Chart/33_Chart_create_bubble.php @@ -0,0 +1,124 @@ +getActiveSheet(); +$worksheet->fromArray( + [ + ['Number of Products', 'Sales in USD', 'Market share'], + [14, 12200, 15], + [20, 60000, 33], + [18, 24400, 10], + [22, 32000, 42], + [], + [12, 8200, 18], + [15, 50000, 30], + [19, 22400, 15], + [25, 25000, 50], + ] +); + +// Set the Labels for each data series we want to plot +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker + +$dataSeriesLabels = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, null, null, 1, ['2013']), // 2013 + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, null, null, 1, ['2014']), // 2014 +]; + +// Set the X-Axis values +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$dataSeriesCategories = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$A$2:$A$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$A$7:$A$10', null, 4), +]; + +// Set the Y-Axis values +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$dataSeriesValues = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$2:$B$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$7:$B$10', null, 4), +]; + +// Set the Z-Axis values (bubble size) +// Datatype +// Cell reference for data +// Format Code +// Number of datapoints in series +// Data values +// Data Marker +$dataSeriesBubbles = [ + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$2:$C$5', null, 4), + new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$7:$C$10', null, 4), +]; + +// Build the dataseries +$series = new DataSeries( + DataSeries::TYPE_BUBBLECHART, // plotType + null, // plotGrouping + range(0, count($dataSeriesValues) - 1), // plotOrder + $dataSeriesLabels, // plotLabel + $dataSeriesCategories, // plotCategory + $dataSeriesValues // plotValues +); +$series->setPlotBubbleSizes($dataSeriesBubbles); + +// Set the series in the plot area +$plotArea = new PlotArea(null, [$series]); +// Set the chart legend +$legend = new ChartLegend(ChartLegend::POSITION_RIGHT, null, false); + +// Create the chart +$chart = new Chart( + 'chart1', // name + null, // title + $legend, // legend + $plotArea, // plotArea + true, // plotVisibleOnly + DataSeries::EMPTY_AS_GAP, // displayBlanksAs + null, // xAxisLabel + null // yAxisLabel +); + +// Set the position where the chart should appear in the worksheet +$chart->setTopLeftPosition('E1'); +$chart->setBottomRightPosition('M15'); + +// Add the chart to the worksheet +$worksheet->addChart($chart); +$worksheet->getColumnDimension('A')->setAutoSize(true); +$worksheet->getColumnDimension('B')->setAutoSize(true); +$worksheet->getColumnDimension('C')->setAutoSize(true); + +// Save Excel 2007 file +$filename = $helper->getFilename(__FILE__); +$writer = IOFactory::createWriter($spreadsheet, 'Xlsx'); +$writer->setIncludeCharts(true); +$callStartTime = microtime(true); +$writer->save($filename); +$helper->logWrite($writer, $filename, $callStartTime); diff --git a/samples/templates/32readwriteBubbleChart2.xlsx b/samples/templates/32readwriteBubbleChart2.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..206cfaea4f625e4323051f852e9c24722b2efa06 GIT binary patch literal 6607 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 literal 0 HcmV?d00001 diff --git a/src/PhpSpreadsheet/Chart/Chart.php b/src/PhpSpreadsheet/Chart/Chart.php index 80d3d5f311..ec6342c5a1 100644 --- a/src/PhpSpreadsheet/Chart/Chart.php +++ b/src/PhpSpreadsheet/Chart/Chart.php @@ -152,6 +152,9 @@ class Chart /** @var ?int */ private $perspective; + /** @var bool */ + private $oneCellAnchor = false; + /** * Create a new Chart. * @@ -743,4 +746,16 @@ public function setPerspective(?int $perspective): self return $this; } + + public function getOneCellAnchor(): bool + { + return $this->oneCellAnchor; + } + + public function setOneCellAnchor(bool $oneCellAnchor): self + { + $this->oneCellAnchor = $oneCellAnchor; + + return $this; + } } diff --git a/src/PhpSpreadsheet/Chart/DataSeries.php b/src/PhpSpreadsheet/Chart/DataSeries.php index 067d30e548..dca1186ec5 100644 --- a/src/PhpSpreadsheet/Chart/DataSeries.php +++ b/src/PhpSpreadsheet/Chart/DataSeries.php @@ -107,6 +107,13 @@ class DataSeries */ private $plotValues = []; + /** + * Plot Bubble Sizes. + * + * @var DataSeriesValues[] + */ + private $plotBubbleSizes = []; + /** * Create a new DataSeries. * @@ -339,6 +346,28 @@ public function getPlotValuesByIndex($index) return false; } + /** + * Get Plot Bubble Sizes. + * + * @return DataSeriesValues[] + */ + public function getPlotBubbleSizes(): array + { + return $this->plotBubbleSizes; + } + + /** + * Set Plot Bubble Sizes. + * + * @param DataSeriesValues[] $plotBubbleSizes + */ + public function setPlotBubbleSizes(array $plotBubbleSizes): self + { + $this->plotBubbleSizes = $plotBubbleSizes; + + return $this; + } + /** * Get Number of Plot Series. * diff --git a/src/PhpSpreadsheet/Chart/DataSeriesValues.php b/src/PhpSpreadsheet/Chart/DataSeriesValues.php index cf3e085370..6747934a05 100644 --- a/src/PhpSpreadsheet/Chart/DataSeriesValues.php +++ b/src/PhpSpreadsheet/Chart/DataSeriesValues.php @@ -328,7 +328,7 @@ public function setLineWidth($width) */ public function isMultiLevelSeries() { - if (count($this->dataValues) > 0) { + if (!empty($this->dataValues)) { return is_array(array_values($this->dataValues)[0]); } diff --git a/src/PhpSpreadsheet/Reader/Xlsx.php b/src/PhpSpreadsheet/Reader/Xlsx.php index df2b36c1d2..33023fb390 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/src/PhpSpreadsheet/Reader/Xlsx.php @@ -157,6 +157,10 @@ private function loadZipNonamespace(string $filename, string $ns): SimpleXMLElem Namespaces::PURL_RELATIONSHIPS => Namespaces::PURL_DRAWING, ]; + private const REL_TO_CHART = [ + Namespaces::PURL_RELATIONSHIPS => Namespaces::PURL_CHART, + ]; + /** * Reads names of the worksheets from a file, without parsing the whole file to a Spreadsheet object. * @@ -408,17 +412,21 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet // Read the theme first, because we need the colour scheme when reading the styles [$workbookBasename, $xmlNamespaceBase] = $this->getWorkbookBaseName(); + $drawingNS = self::REL_TO_DRAWING[$xmlNamespaceBase] ?? Namespaces::DRAWINGML; + $chartNS = self::REL_TO_CHART[$xmlNamespaceBase] ?? Namespaces::CHART; $wbRels = $this->loadZip("xl/_rels/${workbookBasename}.rels", Namespaces::RELATIONSHIPS); $theme = null; $this->styleReader = new Styles(); foreach ($wbRels->Relationship as $relx) { $rel = self::getAttributes($relx); $relTarget = (string) $rel['Target']; + if (substr($relTarget, 0, 4) === '/xl/') { + $relTarget = substr($relTarget, 4); + } switch ($rel['Type']) { case "$xmlNamespaceBase/theme": $themeOrderArray = ['lt1', 'dk1', 'lt2', 'dk2']; $themeOrderAdditional = count($themeOrderArray); - $drawingNS = self::REL_TO_DRAWING[$xmlNamespaceBase] ?? Namespaces::DRAWINGML; $xmlTheme = $this->loadZip("xl/{$relTarget}", $drawingNS); $xmlThemeName = self::getAttributes($xmlTheme); @@ -1204,12 +1212,20 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet . '/_rels/' . basename($fileWorksheet) . '.rels'; + if (substr($drawingFilename, 0, 7) === 'xl//xl/') { + $drawingFilename = substr($drawingFilename, 4); + } if ($zip->locateName($drawingFilename)) { $relsWorksheet = $this->loadZipNoNamespace($drawingFilename, Namespaces::RELATIONSHIPS); $drawings = []; foreach ($relsWorksheet->Relationship as $ele) { if ((string) $ele['Type'] === "$xmlNamespaceBase/drawing") { - $drawings[(string) $ele['Id']] = self::dirAdd("$dir/$fileWorksheet", $ele['Target']); + $eleTarget = (string) $ele['Target']; + if (substr($eleTarget, 0, 4) === '/xl/') { + $drawings[(string) $ele['Id']] = substr($eleTarget, 1); + } else { + $drawings[(string) $ele['Id']] = self::dirAdd("$dir/$fileWorksheet", $ele['Target']); + } } } @@ -1234,7 +1250,13 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet $images[(string) $ele['Id']] = self::dirAdd($fileDrawing, $ele['Target']); } elseif ($eleType === "$xmlNamespaceBase/chart") { if ($this->includeCharts) { - $charts[self::dirAdd($fileDrawing, $ele['Target'])] = [ + $eleTarget = (string) $ele['Target']; + if (substr($eleTarget, 0, 4) === '/xl/') { + $index = substr($eleTarget, 1); + } else { + $index = self::dirAdd($fileDrawing, $eleTarget); + } + $charts[$index] = [ 'id' => (string) $ele['Id'], 'sheet' => $docSheet->getTitle(), ]; @@ -1326,6 +1348,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet 'width' => $width, 'height' => $height, 'worksheetTitle' => $docSheet->getTitle(), + 'oneCellAnchor' => true, ]; } } @@ -1645,7 +1668,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet if ($this->includeCharts) { $chartEntryRef = ltrim((string) $contentType['PartName'], '/'); $chartElements = $this->loadZip($chartEntryRef); - $chartReader = new Chart(); + $chartReader = new Chart($chartNS, $drawingNS); $objChart = $chartReader->readChart($chartElements, basename($chartEntryRef, '.xml')); if (isset($charts[$chartEntryRef])) { $chartPositionRef = $charts[$chartEntryRef]['sheet'] . '!' . $charts[$chartEntryRef]['id']; @@ -1662,6 +1685,9 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet // oneCellAnchor or absoluteAnchor (e.g. Chart sheet) $objChart->setTopLeftPosition($chartDetails[$chartPositionRef]['fromCoordinate'], $chartDetails[$chartPositionRef]['fromOffsetX'], $chartDetails[$chartPositionRef]['fromOffsetY']); $objChart->setBottomRightPosition('', $chartDetails[$chartPositionRef]['width'], $chartDetails[$chartPositionRef]['height']); + if (array_key_exists('oneCellAnchor', $chartDetails[$chartPositionRef])) { + $objChart->setOneCellAnchor($chartDetails[$chartPositionRef]['oneCellAnchor']); + } } } } @@ -1823,6 +1849,11 @@ private static function getArrayItem($array, $key = 0) private static function dirAdd($base, $add) { + $add = "$add"; + if (substr($add, 0, 4) === '/xl/') { + $add = substr($add, 4); + } + return preg_replace('~[^/]+/\.\./~', '', dirname($base) . "/$add"); } diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php index f6b1edd59c..98507af8f3 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Chart.php @@ -305,7 +305,7 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType { $multiSeriesType = null; $smoothLine = false; - $seriesLabel = $seriesCategory = $seriesValues = $plotOrder = []; + $seriesLabel = $seriesCategory = $seriesValues = $plotOrder = $seriesBubbles = []; $seriesDetailSet = $chartDetail->children($this->cNamespace); foreach ($seriesDetailSet as $seriesDetailKey => $seriesDetails) { @@ -382,6 +382,10 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType case 'yVal': $seriesValues[$seriesIndex] = $this->chartDataSeriesValueSet($seriesDetail, "$marker", "$srgbClr", "$pointSize"); + break; + case 'bubbleSize': + $seriesBubbles[$seriesIndex] = $this->chartDataSeriesValueSet($seriesDetail, "$marker", "$srgbClr", "$pointSize"); + break; case 'bubble3D': $bubble3D = self::getAttribute($seriesDetail, 'val', 'boolean'); @@ -435,9 +439,11 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType } } } - /** @phpstan-ignore-next-line */ - return new DataSeries($plotType, $multiSeriesType, $plotOrder, $seriesLabel, $seriesCategory, $seriesValues, $smoothLine); + $series = new DataSeries($plotType, $multiSeriesType, $plotOrder, $seriesLabel, $seriesCategory, $seriesValues, $smoothLine); + $series->setPlotBubbleSizes($seriesBubbles); + + return $series; } /** @@ -494,6 +500,16 @@ private function chartDataSeriesValueSet(SimpleXMLElement $seriesDetail, ?string return $seriesValues; } + if (isset($seriesDetail->v)) { + return new DataSeriesValues( + DataSeriesValues::DATASERIES_TYPE_STRING, + null, + null, + 1, + [(string) $seriesDetail->v] + ); + } + return null; } diff --git a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php index eb768d0915..57a88bb0b0 100644 --- a/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php +++ b/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php @@ -76,5 +76,7 @@ class Namespaces const PURL_DRAWING = 'http://purl.oclc.org/ooxml/drawingml/main'; + const PURL_CHART = 'http://purl.oclc.org/ooxml/drawingml/chart'; + const PURL_WORKSHEET = 'http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet'; } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index 4ea264383d..08d578e605 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -284,9 +284,12 @@ private function writePlotArea(XMLWriter $objWriter, PlotArea $plotArea, ?Title $objWriter->endElement(); } } elseif ($chartType === DataSeries::TYPE_BUBBLECHART) { - $objWriter->startElement('c:bubbleScale'); - $objWriter->writeAttribute('val', 25); - $objWriter->endElement(); + $scale = ($plotGroup === null) ? '' : (string) $plotGroup->getPlotStyle(); + if ($scale !== '') { + $objWriter->startElement('c:bubbleScale'); + $objWriter->writeAttribute('val', $scale); + $objWriter->endElement(); + } $objWriter->startElement('c:showNegBubbles'); $objWriter->writeAttribute('val', 0); @@ -1326,7 +1329,23 @@ private function writePlotGroup(?DataSeries $plotGroup, string $groupType, XMLWr } if ($groupType === DataSeries::TYPE_BUBBLECHART) { - $this->writeBubbles($plotSeriesValues, $objWriter); + if (!empty($plotGroup->getPlotBubbleSizes()[$plotSeriesIdx])) { + $objWriter->startElement('c:bubbleSize'); + $this->writePlotSeriesValues( + $plotGroup->getPlotBubbleSizes()[$plotSeriesIdx], + $objWriter, + $groupType, + 'num' + ); + $objWriter->endElement(); + if ($plotSeriesValues !== false) { + $objWriter->startElement('c:bubble3D'); + $objWriter->writeAttribute('val', $plotSeriesValues->getBubble3D() ? '1' : '0'); + $objWriter->endElement(); + } + } else { + $this->writeBubbles($plotSeriesValues, $objWriter); + } } $objWriter->endElement(); @@ -1420,38 +1439,43 @@ private function writePlotSeriesValues(?DataSeriesValues $plotSeriesValues, XMLW $objWriter->writeRawData($plotSeriesValues->getDataSource()); $objWriter->endElement(); - $objWriter->startElement('c:' . $dataType . 'Cache'); + $count = $plotSeriesValues->getPointCount(); + $source = $plotSeriesValues->getDataSource(); + $values = $plotSeriesValues->getDataValues(); + if ($count > 1 || ($count === 1 && "=$source" !== (string) $values[0])) { + $objWriter->startElement('c:' . $dataType . 'Cache'); - if (($groupType != DataSeries::TYPE_PIECHART) && ($groupType != DataSeries::TYPE_PIECHART_3D) && ($groupType != DataSeries::TYPE_DONUTCHART)) { - if (($plotSeriesValues->getFormatCode() !== null) && ($plotSeriesValues->getFormatCode() !== '')) { - $objWriter->startElement('c:formatCode'); - $objWriter->writeRawData($plotSeriesValues->getFormatCode()); - $objWriter->endElement(); + if (($groupType != DataSeries::TYPE_PIECHART) && ($groupType != DataSeries::TYPE_PIECHART_3D) && ($groupType != DataSeries::TYPE_DONUTCHART)) { + if (($plotSeriesValues->getFormatCode() !== null) && ($plotSeriesValues->getFormatCode() !== '')) { + $objWriter->startElement('c:formatCode'); + $objWriter->writeRawData($plotSeriesValues->getFormatCode()); + $objWriter->endElement(); + } } - } - $objWriter->startElement('c:ptCount'); - $objWriter->writeAttribute('val', $plotSeriesValues->getPointCount()); - $objWriter->endElement(); + $objWriter->startElement('c:ptCount'); + $objWriter->writeAttribute('val', $plotSeriesValues->getPointCount()); + $objWriter->endElement(); - $dataValues = $plotSeriesValues->getDataValues(); - if (!empty($dataValues)) { - if (is_array($dataValues)) { - foreach ($dataValues as $plotSeriesKey => $plotSeriesValue) { - $objWriter->startElement('c:pt'); - $objWriter->writeAttribute('idx', $plotSeriesKey); + $dataValues = $plotSeriesValues->getDataValues(); + if (!empty($dataValues)) { + if (is_array($dataValues)) { + foreach ($dataValues as $plotSeriesKey => $plotSeriesValue) { + $objWriter->startElement('c:pt'); + $objWriter->writeAttribute('idx', $plotSeriesKey); - $objWriter->startElement('c:v'); - $objWriter->writeRawData($plotSeriesValue); - $objWriter->endElement(); - $objWriter->endElement(); + $objWriter->startElement('c:v'); + $objWriter->writeRawData($plotSeriesValue); + $objWriter->endElement(); + $objWriter->endElement(); + } } } - } - $objWriter->endElement(); + $objWriter->endElement(); // *Cache + } - $objWriter->endElement(); + $objWriter->endElement(); // *Ref } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php b/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php index 33eee1e08e..7693c72ce2 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php @@ -108,6 +108,19 @@ public function writeChart(XMLWriter $objWriter, \PhpOffice\PhpSpreadsheet\Chart $objWriter->writeElement('xdr:row', (string) ($brColRow[1] - 1)); $objWriter->writeElement('xdr:rowOff', self::stringEmu($br['yOffset'])); $objWriter->endElement(); + } elseif ($chart->getOneCellAnchor()) { + $objWriter->startElement('xdr:oneCellAnchor'); + + $objWriter->startElement('xdr:from'); + $objWriter->writeElement('xdr:col', (string) ($tlColRow[0] - 1)); + $objWriter->writeElement('xdr:colOff', self::stringEmu($tl['xOffset'])); + $objWriter->writeElement('xdr:row', (string) ($tlColRow[1] - 1)); + $objWriter->writeElement('xdr:rowOff', self::stringEmu($tl['yOffset'])); + $objWriter->endElement(); + $objWriter->startElement('xdr:ext'); + $objWriter->writeAttribute('cx', self::stringEmu($br['xOffset'])); + $objWriter->writeAttribute('cy', self::stringEmu($br['yOffset'])); + $objWriter->endElement(); } else { $objWriter->startElement('xdr:absoluteAnchor'); $objWriter->startElement('xdr:pos'); diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ChartsOpenpyxlTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ChartsOpenpyxlTest.php new file mode 100644 index 0000000000..a7343af5ad --- /dev/null +++ b/tests/PhpSpreadsheetTests/Reader/Xlsx/ChartsOpenpyxlTest.php @@ -0,0 +1,115 @@ +setIncludeCharts(true); + $spreadsheet = $reader->load($file); + $sheet = $spreadsheet->getActiveSheet(); + self::assertSame(1, $sheet->getChartCount()); + + self::assertSame('Sheet', $sheet->getTitle()); + $charts = $sheet->getChartCollection(); + self::assertCount(1, $charts); + $chart = $charts[0]; + self::assertNotNull($chart); + self::assertEmpty($chart->getTitle()); + self::assertTrue($chart->getOneCellAnchor()); + + $plotArea = $chart->getPlotArea(); + $plotSeries = $plotArea->getPlotGroup(); + self::assertCount(1, $plotSeries); + $dataSeries = $plotSeries[0]; + $labels = $dataSeries->getPlotLabels(); + self::assertCount(2, $labels); + self::assertSame(['2013'], $labels[0]->getDataValues()); + self::assertSame(['2014'], $labels[1]->getDataValues()); + + $plotCategories = $dataSeries->getPlotCategories(); + self::assertCount(2, $plotCategories); + $categories = $plotCategories[0]; + self::assertSame('Number', $categories->getDataType()); + self::assertSame('\'Sheet\'!$A$2:$A$5', $categories->getDataSource()); + self::assertFalse($categories->getBubble3D()); + $categories = $plotCategories[1]; + self::assertCount(2, $plotCategories); + self::assertSame('Number', $categories->getDataType()); + self::assertSame('\'Sheet\'!$A$7:$A$10', $categories->getDataSource()); + self::assertFalse($categories->getBubble3D()); + + $plotValues = $dataSeries->getPlotValues(); + self::assertCount(2, $plotValues); + $values = $plotValues[0]; + self::assertSame('Number', $values->getDataType()); + self::assertSame('\'Sheet\'!$B$2:$B$5', $values->getDataSource()); + self::assertFalse($values->getBubble3D()); + $values = $plotValues[1]; + self::assertCount(2, $plotValues); + self::assertSame('Number', $values->getDataType()); + self::assertSame('\'Sheet\'!$B$7:$B$10', $values->getDataSource()); + self::assertFalse($values->getBubble3D()); + + $plotValues = $dataSeries->getPlotBubbleSizes(); + self::assertCount(2, $plotValues); + $values = $plotValues[0]; + self::assertSame('Number', $values->getDataType()); + self::assertSame('\'Sheet\'!$C$2:$C$5', $values->getDataSource()); + self::assertFalse($values->getBubble3D()); + $values = $plotValues[1]; + self::assertCount(2, $plotValues); + self::assertSame('Number', $values->getDataType()); + self::assertSame('\'Sheet\'!$C$7:$C$10', $values->getDataSource()); + self::assertFalse($values->getBubble3D()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testXml(): void + { + $infile = self::DIRECTORY . '32readwriteBubbleChart2.xlsx'; + $file = 'zip://'; + $file .= $infile; + $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, 'c:'), 'unusual choice of prefix'); + self::assertSame(0, substr_count($data, 'bubbleScale')); + self::assertSame(1, substr_count($data, '2013'), 'v tag for 2013'); + self::assertSame(1, substr_count($data, '2014'), 'v tag for 2014'); + self::assertSame(0, substr_count($data, 'numCache'), 'no cached values'); + } + $file = 'zip://'; + $file .= $infile; + $file .= '#xl/drawings/_rels/drawing1.xml.rels'; + $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, 'Target="/xl/charts/chart1.xml"'), 'Unusual absolute address in drawing rels file'); + } + $file = 'zip://'; + $file .= $infile; + $file .= '#xl/worksheets/_rels/sheet1.xml.rels'; + $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, 'Target="/xl/drawings/drawing1.xml"'), 'Unusual absolute address in worksheet rels file'); + } + } +}