Skip to content

Commit 665178a

Browse files
committed
More Bubble Chart Fixes
Continuing the work from PHPOffice#2828, PHPOffice#2841, PHPOffice#2846, and PHPOffice#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.
1 parent 9e4ff92 commit 665178a

File tree

12 files changed

+405
-36
lines changed

12 files changed

+405
-36
lines changed

phpstan-baseline.neon

+1-1
Original file line numberDiff line numberDiff line change
@@ -4637,7 +4637,7 @@ parameters:
46374637

46384638
-
46394639
message: "#^Parameter \\#2 \\$value of method XMLWriter\\:\\:writeAttribute\\(\\) expects string, int given\\.$#"
4640-
count: 43
4640+
count: 42
46414641
path: src/PhpSpreadsheet/Writer/Xlsx/Chart.php
46424642

46434643
-
+124
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
<?php
2+
3+
use PhpOffice\PhpSpreadsheet\Chart\Chart;
4+
use PhpOffice\PhpSpreadsheet\Chart\DataSeries;
5+
use PhpOffice\PhpSpreadsheet\Chart\DataSeriesValues;
6+
use PhpOffice\PhpSpreadsheet\Chart\Legend as ChartLegend;
7+
use PhpOffice\PhpSpreadsheet\Chart\PlotArea;
8+
use PhpOffice\PhpSpreadsheet\Chart\Title;
9+
use PhpOffice\PhpSpreadsheet\IOFactory;
10+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
11+
12+
require __DIR__ . '/../Header.php';
13+
14+
$spreadsheet = new Spreadsheet();
15+
$worksheet = $spreadsheet->getActiveSheet();
16+
$worksheet->fromArray(
17+
[
18+
['Number of Products', 'Sales in USD', 'Market share'],
19+
[14, 12200, 15],
20+
[20, 60000, 33],
21+
[18, 24400, 10],
22+
[22, 32000, 42],
23+
[],
24+
[12, 8200, 18],
25+
[15, 50000, 30],
26+
[19, 22400, 15],
27+
[25, 25000, 50],
28+
]
29+
);
30+
31+
// Set the Labels for each data series we want to plot
32+
// Datatype
33+
// Cell reference for data
34+
// Format Code
35+
// Number of datapoints in series
36+
// Data values
37+
// Data Marker
38+
39+
$dataSeriesLabels = [
40+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, null, null, 1, ['2013']), // 2013
41+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_STRING, null, null, 1, ['2014']), // 2014
42+
];
43+
44+
// Set the X-Axis values
45+
// Datatype
46+
// Cell reference for data
47+
// Format Code
48+
// Number of datapoints in series
49+
// Data values
50+
// Data Marker
51+
$dataSeriesCategories = [
52+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$A$2:$A$5', null, 4),
53+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$A$7:$A$10', null, 4),
54+
];
55+
56+
// Set the Y-Axis values
57+
// Datatype
58+
// Cell reference for data
59+
// Format Code
60+
// Number of datapoints in series
61+
// Data values
62+
// Data Marker
63+
$dataSeriesValues = [
64+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$2:$B$5', null, 4),
65+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$B$7:$B$10', null, 4),
66+
];
67+
68+
// Set the Z-Axis values (bubble size)
69+
// Datatype
70+
// Cell reference for data
71+
// Format Code
72+
// Number of datapoints in series
73+
// Data values
74+
// Data Marker
75+
$dataSeriesBubbles = [
76+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$2:$C$5', null, 4),
77+
new DataSeriesValues(DataSeriesValues::DATASERIES_TYPE_NUMBER, 'Worksheet!$C$7:$C$10', null, 4),
78+
];
79+
80+
// Build the dataseries
81+
$series = new DataSeries(
82+
DataSeries::TYPE_BUBBLECHART, // plotType
83+
null, // plotGrouping
84+
range(0, count($dataSeriesValues) - 1), // plotOrder
85+
$dataSeriesLabels, // plotLabel
86+
$dataSeriesCategories, // plotCategory
87+
$dataSeriesValues // plotValues
88+
);
89+
$series->setPlotBubbleSizes($dataSeriesBubbles);
90+
91+
// Set the series in the plot area
92+
$plotArea = new PlotArea(null, [$series]);
93+
// Set the chart legend
94+
$legend = new ChartLegend(ChartLegend::POSITION_RIGHT, null, false);
95+
96+
// Create the chart
97+
$chart = new Chart(
98+
'chart1', // name
99+
null, // title
100+
$legend, // legend
101+
$plotArea, // plotArea
102+
true, // plotVisibleOnly
103+
DataSeries::EMPTY_AS_GAP, // displayBlanksAs
104+
null, // xAxisLabel
105+
null // yAxisLabel
106+
);
107+
108+
// Set the position where the chart should appear in the worksheet
109+
$chart->setTopLeftPosition('E1');
110+
$chart->setBottomRightPosition('M15');
111+
112+
// Add the chart to the worksheet
113+
$worksheet->addChart($chart);
114+
$worksheet->getColumnDimension('A')->setAutoSize(true);
115+
$worksheet->getColumnDimension('B')->setAutoSize(true);
116+
$worksheet->getColumnDimension('C')->setAutoSize(true);
117+
118+
// Save Excel 2007 file
119+
$filename = $helper->getFilename(__FILE__);
120+
$writer = IOFactory::createWriter($spreadsheet, 'Xlsx');
121+
$writer->setIncludeCharts(true);
122+
$callStartTime = microtime(true);
123+
$writer->save($filename);
124+
$helper->logWrite($writer, $filename, $callStartTime);
6.45 KB
Binary file not shown.

src/PhpSpreadsheet/Chart/Chart.php

+15
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,9 @@ class Chart
152152
/** @var ?int */
153153
private $perspective;
154154

155+
/** @var bool */
156+
private $oneCellAnchor = false;
157+
155158
/**
156159
* Create a new Chart.
157160
*
@@ -743,4 +746,16 @@ public function setPerspective(?int $perspective): self
743746

744747
return $this;
745748
}
749+
750+
public function getOneCellAnchor(): bool
751+
{
752+
return $this->oneCellAnchor;
753+
}
754+
755+
public function setOneCellAnchor(bool $oneCellAnchor): self
756+
{
757+
$this->oneCellAnchor = $oneCellAnchor;
758+
759+
return $this;
760+
}
746761
}

src/PhpSpreadsheet/Chart/DataSeries.php

+29
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,13 @@ class DataSeries
107107
*/
108108
private $plotValues = [];
109109

110+
/**
111+
* Plot Bubble Sizes.
112+
*
113+
* @var DataSeriesValues[]
114+
*/
115+
private $plotBubbleSizes = [];
116+
110117
/**
111118
* Create a new DataSeries.
112119
*
@@ -339,6 +346,28 @@ public function getPlotValuesByIndex($index)
339346
return false;
340347
}
341348

349+
/**
350+
* Get Plot Bubble Sizes.
351+
*
352+
* @return DataSeriesValues[]
353+
*/
354+
public function getPlotBubbleSizes(): array
355+
{
356+
return $this->plotBubbleSizes;
357+
}
358+
359+
/**
360+
* Set Plot Bubble Sizes.
361+
*
362+
* @param DataSeriesValues[] $plotBubbleSizes
363+
*/
364+
public function setPlotBubbleSizes(array $plotBubbleSizes): self
365+
{
366+
$this->plotBubbleSizes = $plotBubbleSizes;
367+
368+
return $this;
369+
}
370+
342371
/**
343372
* Get Number of Plot Series.
344373
*

src/PhpSpreadsheet/Chart/DataSeriesValues.php

+1-1
Original file line numberDiff line numberDiff line change
@@ -328,7 +328,7 @@ public function setLineWidth($width)
328328
*/
329329
public function isMultiLevelSeries()
330330
{
331-
if (count($this->dataValues) > 0) {
331+
if (!empty($this->dataValues)) {
332332
return is_array(array_values($this->dataValues)[0]);
333333
}
334334

src/PhpSpreadsheet/Reader/Xlsx.php

+35-4
Original file line numberDiff line numberDiff line change
@@ -157,6 +157,10 @@ private function loadZipNonamespace(string $filename, string $ns): SimpleXMLElem
157157
Namespaces::PURL_RELATIONSHIPS => Namespaces::PURL_DRAWING,
158158
];
159159

160+
private const REL_TO_CHART = [
161+
Namespaces::PURL_RELATIONSHIPS => Namespaces::PURL_CHART,
162+
];
163+
160164
/**
161165
* Reads names of the worksheets from a file, without parsing the whole file to a Spreadsheet object.
162166
*
@@ -408,17 +412,21 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
408412

409413
// Read the theme first, because we need the colour scheme when reading the styles
410414
[$workbookBasename, $xmlNamespaceBase] = $this->getWorkbookBaseName();
415+
$drawingNS = self::REL_TO_DRAWING[$xmlNamespaceBase] ?? Namespaces::DRAWINGML;
416+
$chartNS = self::REL_TO_CHART[$xmlNamespaceBase] ?? Namespaces::CHART;
411417
$wbRels = $this->loadZip("xl/_rels/${workbookBasename}.rels", Namespaces::RELATIONSHIPS);
412418
$theme = null;
413419
$this->styleReader = new Styles();
414420
foreach ($wbRels->Relationship as $relx) {
415421
$rel = self::getAttributes($relx);
416422
$relTarget = (string) $rel['Target'];
423+
if (substr($relTarget, 0, 4) === '/xl/') {
424+
$relTarget = substr($relTarget, 4);
425+
}
417426
switch ($rel['Type']) {
418427
case "$xmlNamespaceBase/theme":
419428
$themeOrderArray = ['lt1', 'dk1', 'lt2', 'dk2'];
420429
$themeOrderAdditional = count($themeOrderArray);
421-
$drawingNS = self::REL_TO_DRAWING[$xmlNamespaceBase] ?? Namespaces::DRAWINGML;
422430

423431
$xmlTheme = $this->loadZip("xl/{$relTarget}", $drawingNS);
424432
$xmlThemeName = self::getAttributes($xmlTheme);
@@ -1204,12 +1212,20 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
12041212
. '/_rels/'
12051213
. basename($fileWorksheet)
12061214
. '.rels';
1215+
if (substr($drawingFilename, 0, 7) === 'xl//xl/') {
1216+
$drawingFilename = substr($drawingFilename, 4);
1217+
}
12071218
if ($zip->locateName($drawingFilename)) {
12081219
$relsWorksheet = $this->loadZipNoNamespace($drawingFilename, Namespaces::RELATIONSHIPS);
12091220
$drawings = [];
12101221
foreach ($relsWorksheet->Relationship as $ele) {
12111222
if ((string) $ele['Type'] === "$xmlNamespaceBase/drawing") {
1212-
$drawings[(string) $ele['Id']] = self::dirAdd("$dir/$fileWorksheet", $ele['Target']);
1223+
$eleTarget = (string) $ele['Target'];
1224+
if (substr($eleTarget, 0, 4) === '/xl/') {
1225+
$drawings[(string) $ele['Id']] = substr($eleTarget, 1);
1226+
} else {
1227+
$drawings[(string) $ele['Id']] = self::dirAdd("$dir/$fileWorksheet", $ele['Target']);
1228+
}
12131229
}
12141230
}
12151231

@@ -1234,7 +1250,13 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
12341250
$images[(string) $ele['Id']] = self::dirAdd($fileDrawing, $ele['Target']);
12351251
} elseif ($eleType === "$xmlNamespaceBase/chart") {
12361252
if ($this->includeCharts) {
1237-
$charts[self::dirAdd($fileDrawing, $ele['Target'])] = [
1253+
$eleTarget = (string) $ele['Target'];
1254+
if (substr($eleTarget, 0, 4) === '/xl/') {
1255+
$index = substr($eleTarget, 1);
1256+
} else {
1257+
$index = self::dirAdd($fileDrawing, $eleTarget);
1258+
}
1259+
$charts[$index] = [
12381260
'id' => (string) $ele['Id'],
12391261
'sheet' => $docSheet->getTitle(),
12401262
];
@@ -1326,6 +1348,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
13261348
'width' => $width,
13271349
'height' => $height,
13281350
'worksheetTitle' => $docSheet->getTitle(),
1351+
'oneCellAnchor' => true,
13291352
];
13301353
}
13311354
}
@@ -1645,7 +1668,7 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
16451668
if ($this->includeCharts) {
16461669
$chartEntryRef = ltrim((string) $contentType['PartName'], '/');
16471670
$chartElements = $this->loadZip($chartEntryRef);
1648-
$chartReader = new Chart();
1671+
$chartReader = new Chart($chartNS, $drawingNS);
16491672
$objChart = $chartReader->readChart($chartElements, basename($chartEntryRef, '.xml'));
16501673
if (isset($charts[$chartEntryRef])) {
16511674
$chartPositionRef = $charts[$chartEntryRef]['sheet'] . '!' . $charts[$chartEntryRef]['id'];
@@ -1662,6 +1685,9 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
16621685
// oneCellAnchor or absoluteAnchor (e.g. Chart sheet)
16631686
$objChart->setTopLeftPosition($chartDetails[$chartPositionRef]['fromCoordinate'], $chartDetails[$chartPositionRef]['fromOffsetX'], $chartDetails[$chartPositionRef]['fromOffsetY']);
16641687
$objChart->setBottomRightPosition('', $chartDetails[$chartPositionRef]['width'], $chartDetails[$chartPositionRef]['height']);
1688+
if (array_key_exists('oneCellAnchor', $chartDetails[$chartPositionRef])) {
1689+
$objChart->setOneCellAnchor($chartDetails[$chartPositionRef]['oneCellAnchor']);
1690+
}
16651691
}
16661692
}
16671693
}
@@ -1823,6 +1849,11 @@ private static function getArrayItem($array, $key = 0)
18231849

18241850
private static function dirAdd($base, $add)
18251851
{
1852+
$add = "$add";
1853+
if (substr($add, 0, 4) === '/xl/') {
1854+
$add = substr($add, 4);
1855+
}
1856+
18261857
return preg_replace('~[^/]+/\.\./~', '', dirname($base) . "/$add");
18271858
}
18281859

src/PhpSpreadsheet/Reader/Xlsx/Chart.php

+19-3
Original file line numberDiff line numberDiff line change
@@ -305,7 +305,7 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType
305305
{
306306
$multiSeriesType = null;
307307
$smoothLine = false;
308-
$seriesLabel = $seriesCategory = $seriesValues = $plotOrder = [];
308+
$seriesLabel = $seriesCategory = $seriesValues = $plotOrder = $seriesBubbles = [];
309309

310310
$seriesDetailSet = $chartDetail->children($this->cNamespace);
311311
foreach ($seriesDetailSet as $seriesDetailKey => $seriesDetails) {
@@ -382,6 +382,10 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType
382382
case 'yVal':
383383
$seriesValues[$seriesIndex] = $this->chartDataSeriesValueSet($seriesDetail, "$marker", "$srgbClr", "$pointSize");
384384

385+
break;
386+
case 'bubbleSize':
387+
$seriesBubbles[$seriesIndex] = $this->chartDataSeriesValueSet($seriesDetail, "$marker", "$srgbClr", "$pointSize");
388+
385389
break;
386390
case 'bubble3D':
387391
$bubble3D = self::getAttribute($seriesDetail, 'val', 'boolean');
@@ -435,9 +439,11 @@ private function chartDataSeries(SimpleXMLElement $chartDetail, string $plotType
435439
}
436440
}
437441
}
438-
439442
/** @phpstan-ignore-next-line */
440-
return new DataSeries($plotType, $multiSeriesType, $plotOrder, $seriesLabel, $seriesCategory, $seriesValues, $smoothLine);
443+
$series = new DataSeries($plotType, $multiSeriesType, $plotOrder, $seriesLabel, $seriesCategory, $seriesValues, $smoothLine);
444+
$series->setPlotBubbleSizes($seriesBubbles);
445+
446+
return $series;
441447
}
442448

443449
/**
@@ -494,6 +500,16 @@ private function chartDataSeriesValueSet(SimpleXMLElement $seriesDetail, ?string
494500
return $seriesValues;
495501
}
496502

503+
if (isset($seriesDetail->v)) {
504+
return new DataSeriesValues(
505+
DataSeriesValues::DATASERIES_TYPE_STRING,
506+
null,
507+
null,
508+
1,
509+
[(string) $seriesDetail->v]
510+
);
511+
}
512+
497513
return null;
498514
}
499515

src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php

+2
Original file line numberDiff line numberDiff line change
@@ -76,5 +76,7 @@ class Namespaces
7676

7777
const PURL_DRAWING = 'http://purl.oclc.org/ooxml/drawingml/main';
7878

79+
const PURL_CHART = 'http://purl.oclc.org/ooxml/drawingml/chart';
80+
7981
const PURL_WORKSHEET = 'http://purl.oclc.org/ooxml/officeDocument/relationships/worksheet';
8082
}

0 commit comments

Comments
 (0)