Skip to content

Commit efd04e6

Browse files
authored
Xlsx Reader Namespacing for Tables, AutoFilters (#3668)
* Xlsx Reader Namespacing for Tables, AutoFilters Fix #3665. The original issue was the use of an absolute path in the rels file pointing to the comments file. That was easy to take care of, but a bigger problem with the spreadsheet accompanying the problem report was that it used unexpected spacing for AutoFilters and Tables. AutoFilters were already known not to be covered, but Tables appeared after the namespacing changes, but without namespacing support. This PR fixes the absolute path problem and adds namespacing support for Tables and AutoFilters. Remaining areas which are still namespace unaware, mainly because of the absence of test samples which use them with unexpected namespacing, include conditional formatting (internal or external), sheet view options, sheet protection, unparsed loaded data, data validation (internal or external), alternate content, and header/footer images. * Mysterious Warning for Php7.4 Only Node no longer exists, doesn't affect result. Suppress warning. * Scrutinizer What would a change be without some new false positives? * Scrutinizer A gift that keeps on giving :-( Now it's deciding that things that it didn't report in a scan from a few minutes ago are worth reporting, even with no relevant code changes. * Scrutinizer It is an idiot. Let me see if I can fix one false positive and succeed in guessing where it might report another one, even though it didn't do so with this last run.
1 parent 3400110 commit efd04e6

File tree

5 files changed

+158
-62
lines changed

5 files changed

+158
-62
lines changed

src/PhpSpreadsheet/Reader/Xlsx.php

+20-7
Original file line numberDiff line numberDiff line change
@@ -793,6 +793,14 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
793793
$docSheet->setTitle((string) $eleSheetAttr['name'], false, false);
794794

795795
$fileWorksheet = (string) $worksheets[$sheetReferenceId];
796+
// issue 3665 adds test for /.
797+
// This broke XlsxRootZipFilesTest,
798+
// but Excel reports an error with that file.
799+
// Testing dir for . avoids this problem.
800+
// It might be better just to drop the test.
801+
if ($fileWorksheet[0] == '/' && $dir !== '.') {
802+
$fileWorksheet = substr($fileWorksheet, strlen($dir) + 2);
803+
}
796804
$xmlSheet = $this->loadZipNoNamespace("$dir/$fileWorksheet", $mainNS);
797805
$xmlSheetNS = $this->loadZip("$dir/$fileWorksheet", $mainNS);
798806

@@ -980,8 +988,8 @@ protected function loadSpreadsheetFromFile(string $filename): Spreadsheet
980988
}
981989

982990
if ($this->readDataOnly === false) {
983-
$this->readAutoFilter($xmlSheet, $docSheet);
984-
$this->readTables($xmlSheet, $docSheet, $dir, $fileWorksheet, $zip);
991+
$this->readAutoFilter($xmlSheetNS, $docSheet);
992+
$this->readTables($xmlSheetNS, $docSheet, $dir, $fileWorksheet, $zip, $mainNS);
985993
}
986994

987995
if ($xmlSheetNS && $xmlSheetNS->mergeCells && $xmlSheetNS->mergeCells->mergeCell && !$this->readDataOnly) {
@@ -2206,10 +2214,14 @@ private function readTables(
22062214
Worksheet $docSheet,
22072215
string $dir,
22082216
string $fileWorksheet,
2209-
ZipArchive $zip
2217+
ZipArchive $zip,
2218+
string $namespaceTable
22102219
): void {
2211-
if ($xmlSheet && $xmlSheet->tableParts && (int) $xmlSheet->tableParts['count'] > 0) {
2212-
$this->readTablesInTablesFile($xmlSheet, $dir, $fileWorksheet, $zip, $docSheet);
2220+
if ($xmlSheet && $xmlSheet->tableParts) {
2221+
$attributes = $xmlSheet->tableParts->attributes() ?? ['count' => 0];
2222+
if (((int) $attributes['count']) > 0) {
2223+
$this->readTablesInTablesFile($xmlSheet, $dir, $fileWorksheet, $zip, $docSheet, $namespaceTable);
2224+
}
22132225
}
22142226
}
22152227

@@ -2218,7 +2230,8 @@ private function readTablesInTablesFile(
22182230
string $dir,
22192231
string $fileWorksheet,
22202232
ZipArchive $zip,
2221-
Worksheet $docSheet
2233+
Worksheet $docSheet,
2234+
string $namespaceTable
22222235
): void {
22232236
foreach ($xmlSheet->tableParts->tablePart as $tablePart) {
22242237
$relation = self::getAttributes($tablePart, Namespaces::SCHEMA_OFFICE_DOCUMENT);
@@ -2236,7 +2249,7 @@ private function readTablesInTablesFile(
22362249
$relationshipFilePath = File::realpath($relationshipFilePath);
22372250

22382251
if ($this->fileExistsInArchive($this->zip, $relationshipFilePath)) {
2239-
$tableXml = $this->loadZip($relationshipFilePath);
2252+
$tableXml = $this->loadZip($relationshipFilePath, $namespaceTable);
22402253
(new TableReader($docSheet, $tableXml))->load();
22412254
}
22422255
}

src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php

+46-34
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace PhpOffice\PhpSpreadsheet\Reader\Xlsx;
44

5+
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
56
use PhpOffice\PhpSpreadsheet\Worksheet\AutoFilter\Column;
67
use PhpOffice\PhpSpreadsheet\Worksheet\AutoFilter\Column\Rule;
78
use PhpOffice\PhpSpreadsheet\Worksheet\Table;
@@ -32,36 +33,40 @@ public function __construct($parent, SimpleXMLElement $worksheetXml)
3233
public function load(): void
3334
{
3435
// Remove all "$" in the auto filter range
35-
$autoFilterRange = (string) preg_replace('/\$/', '', $this->worksheetXml->autoFilter['ref'] ?? '');
36+
$attrs = $this->worksheetXml->autoFilter->attributes() ?? [];
37+
// Mysterious 'Node no longer exists' warning for Php7.4 only.
38+
$autoFilterRange = (string) @preg_replace('/\$/', '', $attrs['ref'] ?? '');
3639
if (strpos($autoFilterRange, ':') !== false) {
37-
$this->readAutoFilter($autoFilterRange, $this->worksheetXml);
40+
$this->readAutoFilter($autoFilterRange);
3841
}
3942
}
4043

41-
private function readAutoFilter(string $autoFilterRange, SimpleXMLElement $xmlSheet): void
44+
private function readAutoFilter(string $autoFilterRange): void
4245
{
4346
$autoFilter = $this->parent->getAutoFilter();
4447
$autoFilter->setRange($autoFilterRange);
4548

46-
foreach ($xmlSheet->autoFilter->filterColumn as $filterColumn) {
47-
$column = $autoFilter->getColumnByOffset((int) $filterColumn['colId']);
49+
foreach ($this->worksheetXml->autoFilter->filterColumn as $filterColumn) {
50+
$attributes = $filterColumn->/** @scrutinizer ignore-call */ attributes() ?? [];
51+
$column = $autoFilter->getColumnByOffset((int) ($attributes['colId'] ?? 0));
4852
// Check for standard filters
4953
if ($filterColumn->filters) {
5054
$column->setFilterType(Column::AUTOFILTER_FILTERTYPE_FILTER);
51-
$filters = $filterColumn->filters;
55+
$filters = Xlsx::testSimpleXml($filterColumn->filters->attributes());
5256
if ((isset($filters['blank'])) && ((int) $filters['blank'] == 1)) {
5357
// Operator is undefined, but always treated as EQUAL
5458
$column->createRule()->setRule('', '')->setRuleType(Rule::AUTOFILTER_RULETYPE_FILTER);
5559
}
5660
// Standard filters are always an OR join, so no join rule needs to be set
5761
// Entries can be either filter elements
58-
foreach ($filters->filter as $filterRule) {
62+
foreach ($filterColumn->filters->filter as $filterRule) {
5963
// Operator is undefined, but always treated as EQUAL
60-
$column->createRule()->setRule('', (string) $filterRule['val'])->setRuleType(Rule::AUTOFILTER_RULETYPE_FILTER);
64+
$attr2 = $filterRule->/** @scrutinizer ignore-call */ attributes() ?? ['val' => ''];
65+
$column->createRule()->setRule('', (string) $attr2['val'])->setRuleType(Rule::AUTOFILTER_RULETYPE_FILTER);
6166
}
6267

6368
// Or Date Group elements
64-
$this->readDateRangeAutoFilter($filters, $column);
69+
$this->readDateRangeAutoFilter($filterColumn->filters, $column);
6570
}
6671

6772
// Check for custom filters
@@ -76,20 +81,23 @@ private function readAutoFilter(string $autoFilterRange, SimpleXMLElement $xmlSh
7681

7782
private function readDateRangeAutoFilter(SimpleXMLElement $filters, Column $column): void
7883
{
79-
foreach ($filters->dateGroupItem as $dateGroupItem) {
84+
foreach ($filters->dateGroupItem as $dateGroupItemx) {
8085
// Operator is undefined, but always treated as EQUAL
81-
$column->createRule()->setRule(
82-
'',
83-
[
84-
'year' => (string) $dateGroupItem['year'],
85-
'month' => (string) $dateGroupItem['month'],
86-
'day' => (string) $dateGroupItem['day'],
87-
'hour' => (string) $dateGroupItem['hour'],
88-
'minute' => (string) $dateGroupItem['minute'],
89-
'second' => (string) $dateGroupItem['second'],
90-
],
91-
(string) $dateGroupItem['dateTimeGrouping']
92-
)->setRuleType(Rule::AUTOFILTER_RULETYPE_DATEGROUP);
86+
$dateGroupItem = $dateGroupItemx->/** @scrutinizer ignore-call */ attributes();
87+
if ($dateGroupItem !== null) {
88+
$column->createRule()->setRule(
89+
'',
90+
[
91+
'year' => (string) $dateGroupItem['year'],
92+
'month' => (string) $dateGroupItem['month'],
93+
'day' => (string) $dateGroupItem['day'],
94+
'hour' => (string) $dateGroupItem['hour'],
95+
'minute' => (string) $dateGroupItem['minute'],
96+
'second' => (string) $dateGroupItem['second'],
97+
],
98+
(string) $dateGroupItem['dateTimeGrouping']
99+
)->setRuleType(Rule::AUTOFILTER_RULETYPE_DATEGROUP);
100+
}
93101
}
94102
}
95103

@@ -98,15 +106,17 @@ private function readCustomAutoFilter(?SimpleXMLElement $filterColumn, Column $c
98106
if (isset($filterColumn, $filterColumn->customFilters)) {
99107
$column->setFilterType(Column::AUTOFILTER_FILTERTYPE_CUSTOMFILTER);
100108
$customFilters = $filterColumn->customFilters;
109+
$attributes = $customFilters->attributes();
101110
// Custom filters can an AND or an OR join;
102111
// and there should only ever be one or two entries
103-
if ((isset($customFilters['and'])) && ((string) $customFilters['and'] === '1')) {
112+
if ((isset($attributes['and'])) && ((string) $attributes['and'] === '1')) {
104113
$column->setJoin(Column::AUTOFILTER_COLUMN_JOIN_AND);
105114
}
106115
foreach ($customFilters->customFilter as $filterRule) {
116+
$attr2 = $filterRule->/** @scrutinizer ignore-call */ attributes() ?? ['operator' => '', 'val' => ''];
107117
$column->createRule()->setRule(
108-
(string) $filterRule['operator'],
109-
(string) $filterRule['val']
118+
(string) $attr2['operator'],
119+
(string) $attr2['val']
110120
)->setRuleType(Rule::AUTOFILTER_RULETYPE_CUSTOMFILTER);
111121
}
112122
}
@@ -119,16 +129,17 @@ private function readDynamicAutoFilter(?SimpleXMLElement $filterColumn, Column $
119129
// We should only ever have one dynamic filter
120130
foreach ($filterColumn->dynamicFilter as $filterRule) {
121131
// Operator is undefined, but always treated as EQUAL
132+
$attr2 = $filterRule->/** @scrutinizer ignore-call */ attributes() ?? [];
122133
$column->createRule()->setRule(
123134
'',
124-
(string) $filterRule['val'],
125-
(string) $filterRule['type']
135+
(string) ($attr2['val'] ?? ''),
136+
(string) ($attr2['type'] ?? '')
126137
)->setRuleType(Rule::AUTOFILTER_RULETYPE_DYNAMICFILTER);
127-
if (isset($filterRule['val'])) {
128-
$column->setAttribute('val', (string) $filterRule['val']);
138+
if (isset($attr2['val'])) {
139+
$column->setAttribute('val', (string) $attr2['val']);
129140
}
130-
if (isset($filterRule['maxVal'])) {
131-
$column->setAttribute('maxVal', (string) $filterRule['maxVal']);
141+
if (isset($attr2['maxVal'])) {
142+
$column->setAttribute('maxVal', (string) $attr2['maxVal']);
132143
}
133144
}
134145
}
@@ -140,15 +151,16 @@ private function readTopTenAutoFilter(?SimpleXMLElement $filterColumn, Column $c
140151
$column->setFilterType(Column::AUTOFILTER_FILTERTYPE_TOPTENFILTER);
141152
// We should only ever have one top10 filter
142153
foreach ($filterColumn->top10 as $filterRule) {
154+
$attr2 = $filterRule->/** @scrutinizer ignore-call */ attributes() ?? [];
143155
$column->createRule()->setRule(
144156
(
145-
((isset($filterRule['percent'])) && ((string) $filterRule['percent'] === '1'))
157+
((isset($attr2['percent'])) && ((string) $attr2['percent'] === '1'))
146158
? Rule::AUTOFILTER_COLUMN_RULE_TOPTEN_PERCENT
147159
: Rule::AUTOFILTER_COLUMN_RULE_TOPTEN_BY_VALUE
148160
),
149-
(string) $filterRule['val'],
161+
(string) ($attr2['val'] ?? ''),
150162
(
151-
((isset($filterRule['top'])) && ((string) $filterRule['top'] === '1'))
163+
((isset($attr2['top'])) && ((string) $attr2['top'] === '1'))
152164
? Rule::AUTOFILTER_COLUMN_RULE_TOPTEN_TOP
153165
: Rule::AUTOFILTER_COLUMN_RULE_TOPTEN_BOTTOM
154166
)

src/PhpSpreadsheet/Reader/Xlsx/TableReader.php

+30-21
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,9 @@ class TableReader
1919
*/
2020
private $tableXml;
2121

22+
/** @var array|SimpleXMLElement */
23+
private $tableAttributes;
24+
2225
public function __construct(Worksheet $workSheet, SimpleXMLElement $tableXml)
2326
{
2427
$this->worksheet = $workSheet;
@@ -30,28 +33,29 @@ public function __construct(Worksheet $workSheet, SimpleXMLElement $tableXml)
3033
*/
3134
public function load(): void
3235
{
36+
$this->tableAttributes = $this->tableXml->attributes() ?? [];
3337
// Remove all "$" in the table range
34-
$tableRange = (string) preg_replace('/\$/', '', $this->tableXml['ref'] ?? '');
38+
$tableRange = (string) preg_replace('/\$/', '', $this->tableAttributes['ref'] ?? '');
3539
if (strpos($tableRange, ':') !== false) {
36-
$this->readTable($tableRange, $this->tableXml);
40+
$this->readTable($tableRange);
3741
}
3842
}
3943

4044
/**
4145
* Read Table from xml.
4246
*/
43-
private function readTable(string $tableRange, SimpleXMLElement $tableXml): void
47+
private function readTable(string $tableRange): void
4448
{
4549
$table = new Table($tableRange);
46-
$table->setName((string) $tableXml['displayName']);
47-
$table->setShowHeaderRow((string) $tableXml['headerRowCount'] !== '0');
48-
$table->setShowTotalsRow((string) $tableXml['totalsRowCount'] === '1');
50+
$table->setName((string) ($this->tableAttributes['displayName'] ?? ''));
51+
$table->setShowHeaderRow(((string) ($this->tableAttributes['headerRowCount'] ?? '')) !== '0');
52+
$table->setShowTotalsRow(((string) ($this->tableAttributes['totalsRowCount'] ?? '')) === '1');
4953

50-
$this->readTableAutoFilter($table, $tableXml->autoFilter);
51-
$this->readTableColumns($table, $tableXml->tableColumns);
52-
$this->readTableStyle($table, $tableXml->tableStyleInfo);
54+
$this->readTableAutoFilter($table, $this->tableXml->autoFilter);
55+
$this->readTableColumns($table, $this->tableXml->tableColumns);
56+
$this->readTableStyle($table, $this->tableXml->tableStyleInfo);
5357

54-
(new AutoFilter($table, $tableXml))->load();
58+
(new AutoFilter($table, $this->tableXml))->load();
5559
$this->worksheet->addTable($table);
5660
}
5761

@@ -67,8 +71,9 @@ private function readTableAutoFilter(Table $table, SimpleXMLElement $autoFilterX
6771
}
6872

6973
foreach ($autoFilterXml->filterColumn as $filterColumn) {
70-
$column = $table->getColumnByOffset((int) $filterColumn['colId']);
71-
$column->setShowFilterButton((string) $filterColumn['hiddenButton'] !== '1');
74+
$attributes = $filterColumn->/** @scrutinizer ignore-call */ attributes() ?? ['colId' => 0, 'hiddenButton' => 0];
75+
$column = $table->getColumnByOffset((int) $attributes['colId']);
76+
$column->setShowFilterButton(((string) $attributes['hiddenButton']) !== '1');
7277
}
7378
}
7479

@@ -79,15 +84,16 @@ private function readTableColumns(Table $table, SimpleXMLElement $tableColumnsXm
7984
{
8085
$offset = 0;
8186
foreach ($tableColumnsXml->tableColumn as $tableColumn) {
87+
$attributes = $tableColumn->/** @scrutinizer ignore-call */ attributes() ?? ['totalsRowLabel' => 0, 'totalsRowFunction' => 0];
8288
$column = $table->getColumnByOffset($offset++);
8389

8490
if ($table->getShowTotalsRow()) {
85-
if ($tableColumn['totalsRowLabel']) {
86-
$column->setTotalsRowLabel((string) $tableColumn['totalsRowLabel']);
91+
if ($attributes['totalsRowLabel']) {
92+
$column->setTotalsRowLabel((string) $attributes['totalsRowLabel']);
8793
}
8894

89-
if ($tableColumn['totalsRowFunction']) {
90-
$column->setTotalsRowFunction((string) $tableColumn['totalsRowFunction']);
95+
if ($attributes['totalsRowFunction']) {
96+
$column->setTotalsRowFunction((string) $attributes['totalsRowFunction']);
9197
}
9298
}
9399

@@ -103,11 +109,14 @@ private function readTableColumns(Table $table, SimpleXMLElement $tableColumnsXm
103109
private function readTableStyle(Table $table, SimpleXMLElement $tableStyleInfoXml): void
104110
{
105111
$tableStyle = new TableStyle();
106-
$tableStyle->setTheme((string) $tableStyleInfoXml['name']);
107-
$tableStyle->setShowRowStripes((string) $tableStyleInfoXml['showRowStripes'] === '1');
108-
$tableStyle->setShowColumnStripes((string) $tableStyleInfoXml['showColumnStripes'] === '1');
109-
$tableStyle->setShowFirstColumn((string) $tableStyleInfoXml['showFirstColumn'] === '1');
110-
$tableStyle->setShowLastColumn((string) $tableStyleInfoXml['showLastColumn'] === '1');
112+
$attributes = $tableStyleInfoXml->attributes();
113+
if ($attributes !== null) {
114+
$tableStyle->setTheme((string) $attributes['name']);
115+
$tableStyle->setShowRowStripes((string) $attributes['showRowStripes'] === '1');
116+
$tableStyle->setShowColumnStripes((string) $attributes['showColumnStripes'] === '1');
117+
$tableStyle->setShowFirstColumn((string) $attributes['showFirstColumn'] === '1');
118+
$tableStyle->setShowLastColumn((string) $attributes['showLastColumn'] === '1');
119+
}
111120
$table->setStyle($tableStyle);
112121
}
113122
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheetTests\Reader\Xlsx;
4+
5+
use PhpOffice\PhpSpreadsheet\Reader\Xlsx;
6+
7+
class Issue3665Test extends \PHPUnit\Framework\TestCase
8+
{
9+
/**
10+
* @var string
11+
*/
12+
private static $testbook = 'tests/data/Reader/XLSX/issue.3665.xlsx';
13+
14+
public function testPreliminaries(): void
15+
{
16+
$file = 'zip://';
17+
$file .= self::$testbook;
18+
$file .= '#xl/tables/table1.xml';
19+
$data = file_get_contents($file);
20+
// confirm that file contains expected namespaced xml tag
21+
if ($data === false) {
22+
self::fail('Unable to read table file');
23+
} else {
24+
self::assertStringContainsString('<x:table id="5" name="Table5" displayName="Table5" ref="A3:M4" tableType="xml" totalsRowShown="0" xmlns:x="http://schemas.openxmlformats.org/spreadsheetml/2006/main">', $data);
25+
self::assertStringContainsString('<x:autoFilter ref="A3:M4" />', $data);
26+
}
27+
28+
$file = 'zip://';
29+
$file .= self::$testbook;
30+
$file .= '#xl/worksheets/_rels/sheet1.xml.rels';
31+
$data = file_get_contents($file);
32+
// confirm absolute path as reference
33+
if ($data === false) {
34+
self::fail('Unable to read rels file');
35+
} else {
36+
self::assertStringContainsString('Target="/xl/comments1.xml"', $data);
37+
}
38+
}
39+
40+
public function testTable(): void
41+
{
42+
$reader = new Xlsx();
43+
$spreadsheet = $reader->load(self::$testbook);
44+
$sheet = $spreadsheet->getActiveSheet();
45+
$tables = $sheet->getTableCollection();
46+
self::assertCount(1, $tables);
47+
$table = $tables->offsetGet(0);
48+
if ($table === null) {
49+
self::fail('Unexpected failure obtaining table');
50+
} else {
51+
self::assertEquals('Table5', $table->getName());
52+
self::assertEquals('A3:M4', $table->getRange());
53+
self::assertTrue($table->getAllowFilter());
54+
self::assertSame('A3:M4', $table->getAutoFilter()->getRange());
55+
}
56+
$comment = $sheet->getComment('A3');
57+
self::assertSame('Code20', (string) $comment);
58+
$comment = $sheet->getComment('B3');
59+
self::assertSame('Text100', (string) $comment);
60+
$spreadsheet->disconnectWorksheets();
61+
}
62+
}
7.46 KB
Binary file not shown.

0 commit comments

Comments
 (0)