Skip to content

Commit 891180e

Browse files
committed
Protect Sheet But Allow Sort
Fix PHPOffice#3951. When an Excel sheet is protected, even when sorting is explicitly allowed without a password, sorts are permitted only on "protected ranges" within the sheet. PhpSpreadsheet already supports protected ranges, and only minor tinkering is necessary for that (e.g. the protected range can have, but does not require, a password). The more important part of this change is documenting the far-from-intuitive way that Excel handles this. To that end, documentation is updated, and a new sample is added. A new class, `Worksheet\ProtectedRange` is added in place of the string array which had been used. `Worksheet::getProtectedCells` is deprecated in favor of the new `Worksheet::getProtectedCellRanges`.
1 parent 9a94aea commit 891180e

File tree

11 files changed

+316
-20
lines changed

11 files changed

+316
-20
lines changed

docs/topics/recipes.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1295,6 +1295,15 @@ $protection->setInsertRows(false);
12951295
$protection->setFormatCells(false);
12961296
```
12971297

1298+
Note that allowing sort without providing the sheet password
1299+
(similarly with autoFilter) requires that you explicitly
1300+
enable the cell ranges for which sort is permitted,
1301+
with or without a range password:
1302+
```php
1303+
$sheet->protectCells('A:A'); // column A can be sorted without password
1304+
$sheet->protectCells('B:B', 'sortpw'); // column B can be sorted if the range password sortpw is supplied
1305+
```
1306+
12981307
If writing Xlsx files you can specify the algorithm used to hash the password
12991308
before calling `setPassword()` like so:
13001309

samples/Basic4/51_ProtectedSort.php

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
<?php
2+
3+
require __DIR__ . '/../Header.php';
4+
5+
use PhpOffice\PhpSpreadsheet\RichText\RichText;
6+
use PhpOffice\PhpSpreadsheet\RichText\TextElement;
7+
use PhpOffice\PhpSpreadsheet\Spreadsheet;
8+
9+
$spreadsheet = new Spreadsheet();
10+
11+
$helper->log('First sheet - protected, sorts not allowed');
12+
$sheet = $spreadsheet->getActiveSheet();
13+
$sheet->setTitle('sorttrue');
14+
$sheet->getCell('A1')->setValue(10);
15+
$sheet->getCell('A2')->setValue(5);
16+
$sheet->getCell('B1')->setValue(15);
17+
$protection = $sheet->getProtection();
18+
$protection->setPassword('testpassword');
19+
$protection->setSheet(true);
20+
$protection->setInsertRows(true);
21+
$protection->setFormatCells(true);
22+
$protection->setObjects(true);
23+
$protection->setAutoFilter(false);
24+
$protection->setSort(true);
25+
$comment = $sheet->getComment('A1');
26+
$text = new RichText();
27+
$text->addText(new TextElement('Sort options should be grayed out. Sheet password to remove protections is testpassword for all sheets.'));
28+
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
29+
30+
$helper->log('Second sheet - protected, sorts allowed, but no permitted range defined');
31+
$sheet = $spreadsheet->createSheet();
32+
$sheet->setTitle('sortfalse');
33+
$sheet->getCell('A1')->setValue(10);
34+
$sheet->getCell('A2')->setValue(5);
35+
$sheet->getCell('B1')->setValue(15);
36+
$protection = $sheet->getProtection();
37+
$protection->setPassword('testpassword');
38+
$protection->setSheet(true);
39+
$protection->setInsertRows(true);
40+
$protection->setFormatCells(true);
41+
$protection->setObjects(true);
42+
$protection->setAutoFilter(false);
43+
$protection->setSort(false);
44+
$comment = $sheet->getComment('A1');
45+
$text = new RichText();
46+
$text->addText(new TextElement('Sort options not grayed out, but no permissible sort range.'));
47+
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
48+
49+
$helper->log('Third sheet - protected, sorts allowed, but only on permitted range A:A, no range password needed');
50+
$sheet = $spreadsheet->createSheet();
51+
$sheet->setTitle('sortfalsenocolpw');
52+
$sheet->getCell('A1')->setValue(10);
53+
$sheet->getCell('A2')->setValue(5);
54+
$sheet->getCell('C1')->setValue(15);
55+
$protection = $sheet->getProtection();
56+
$protection->setPassword('testpassword');
57+
$protection->setSheet(true);
58+
$protection->setInsertRows(true);
59+
$protection->setFormatCells(true);
60+
$protection->setObjects(true);
61+
$protection->setAutoFilter(false);
62+
$protection->setSort(false);
63+
$sheet->protectCells('A:A');
64+
$comment = $sheet->getComment('A1');
65+
$text = new RichText();
66+
$text->addText(new TextElement('Column A may be sorted without a password. No sort for any other column.'));
67+
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
68+
69+
$helper->log('Fourth sheet - protected, sorts allowed, but only on permitted range A:A, and range password needed');
70+
$sheet = $spreadsheet->createSheet();
71+
$sheet->setTitle('sortfalsecolpw');
72+
$sheet->getCell('A1')->setValue(10);
73+
$sheet->getCell('A2')->setValue(5);
74+
$sheet->getCell('C1')->setValue(15);
75+
$protection = $sheet->getProtection();
76+
$protection->setPassword('testpassword');
77+
$protection->setSheet(true);
78+
$protection->setInsertRows(true);
79+
$protection->setFormatCells(true);
80+
$protection->setObjects(true);
81+
$protection->setAutoFilter(false);
82+
$protection->setSort(false);
83+
$sheet->protectCells('A:A', 'sortpw', false, 'sortrange');
84+
$comment = $sheet->getComment('A1');
85+
$text = new RichText();
86+
$text->addText(new TextElement('Column A may be sorted with password sortpw. No sort for any other column.'));
87+
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
88+
89+
// Save
90+
$helper->write($spreadsheet, __FILE__, ['Xls', 'Xlsx']);
91+
$spreadsheet->disconnectWorksheets();

src/PhpSpreadsheet/Reader/Xls.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4972,7 +4972,7 @@ private function readRangeProtection(): void
49724972

49734973
// Apply range protection to sheet
49744974
if ($cellRanges) {
4975-
$this->phpSheet->protectCells(implode(' ', $cellRanges), strtoupper(dechex($wPassword)), true);
4975+
$this->phpSheet->protectCells(implode(' ', $cellRanges), ($wPassword === 0) ? '' : strtoupper(dechex($wPassword)), true);
49764976
}
49774977
}
49784978
}

src/PhpSpreadsheet/Reader/Xlsx.php

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2175,7 +2175,7 @@ private function readSheetProtection(Worksheet $docSheet, SimpleXMLElement $xmlS
21752175

21762176
if ($xmlSheet->protectedRanges->protectedRange) {
21772177
foreach ($xmlSheet->protectedRanges->protectedRange as $protectedRange) {
2178-
$docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true);
2178+
$docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true, (string) $protectedRange['name'], (string) $protectedRange['securityDescriptor']);
21792179
}
21802180
}
21812181
}
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<?php
2+
3+
namespace PhpOffice\PhpSpreadsheet\Worksheet;
4+
5+
class ProtectedRange
6+
{
7+
private string $name = '';
8+
9+
private string $password = '';
10+
11+
private string $sqref;
12+
13+
private string $securityDescriptor = '';
14+
15+
/**
16+
* No setters aside from constructor.
17+
*/
18+
public function __construct(string $sqref, string $password = '', string $name = '', string $securityDescriptor = '')
19+
{
20+
$this->sqref = $sqref;
21+
$this->name = $name;
22+
$this->password = $password;
23+
$this->securityDescriptor = $securityDescriptor;
24+
}
25+
26+
public function getSqref(): string
27+
{
28+
return $this->sqref;
29+
}
30+
31+
public function getName(): string
32+
{
33+
return $this->name ?: ('p' . md5($this->sqref));
34+
}
35+
36+
public function getPassword(): string
37+
{
38+
return $this->password;
39+
}
40+
41+
public function getSecurityDescriptor(): string
42+
{
43+
return $this->securityDescriptor;
44+
}
45+
}

src/PhpSpreadsheet/Worksheet/Worksheet.php

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -192,7 +192,7 @@ class Worksheet implements IComparable
192192
/**
193193
* Collection of protected cell ranges.
194194
*
195-
* @var string[]
195+
* @var ProtectedRange[]
196196
*/
197197
private array $protectedCells = [];
198198

@@ -1866,14 +1866,14 @@ public function setMergeCells(array $mergeCells): static
18661866
*
18671867
* @return $this
18681868
*/
1869-
public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password, bool $alreadyHashed = false): static
1869+
public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password = '', bool $alreadyHashed = false, string $name = '', string $securityDescriptor = ''): static
18701870
{
18711871
$range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range));
18721872

1873-
if (!$alreadyHashed) {
1873+
if (!$alreadyHashed && $password !== '') {
18741874
$password = Shared\PasswordHasher::hashPassword($password);
18751875
}
1876-
$this->protectedCells[$range] = $password;
1876+
$this->protectedCells[$range] = new ProtectedRange($range, $password, $name, $securityDescriptor);
18771877

18781878
return $this;
18791879
}
@@ -1901,11 +1901,29 @@ public function unprotectCells(AddressRange|CellAddress|int|string|array $range)
19011901
}
19021902

19031903
/**
1904-
* Get protected cells.
1904+
* Get password for protected cells.
19051905
*
19061906
* @return string[]
1907+
*
1908+
* @deprecated 2.0.1 use getProtectedCellRanges instead
1909+
* @see Worksheet::getProtectedCellRanges()
19071910
*/
19081911
public function getProtectedCells(): array
1912+
{
1913+
$array = [];
1914+
foreach ($this->protectedCells as $key => $protectedRange) {
1915+
$array[$key] = $protectedRange->getPassword();
1916+
}
1917+
1918+
return $array;
1919+
}
1920+
1921+
/**
1922+
* Get protected cells.
1923+
*
1924+
* @return ProtectedRange[]
1925+
*/
1926+
public function getProtectedCellRanges(): array
19091927
{
19101928
return $this->protectedCells;
19111929
}

src/PhpSpreadsheet/Writer/Xls/Worksheet.php

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1496,7 +1496,8 @@ private function writeSheetProtection(): void
14961496
*/
14971497
private function writeRangeProtection(): void
14981498
{
1499-
foreach ($this->phpSheet->getProtectedCells() as $range => $password) {
1499+
foreach ($this->phpSheet->getProtectedCellRanges() as $range => $protectedCells) {
1500+
$password = $protectedCells->getPassword();
15001501
// number of ranges, e.g. 'A1:B3 C20:D25'
15011502
$cellRanges = explode(' ', $range);
15021503
$cref = count($cellRanges);

src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -974,19 +974,20 @@ private function writeHyperlinks(XMLWriter $objWriter, PhpspreadsheetWorksheet $
974974
*/
975975
private function writeProtectedRanges(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void
976976
{
977-
if (count($worksheet->getProtectedCells()) > 0) {
977+
if (count($worksheet->getProtectedCellRanges()) > 0) {
978978
// protectedRanges
979979
$objWriter->startElement('protectedRanges');
980980

981981
// Loop protectedRanges
982-
foreach ($worksheet->getProtectedCells() as $protectedCell => $passwordHash) {
982+
foreach ($worksheet->getProtectedCellRanges() as $protectedCell => $protectedRange) {
983983
// protectedRange
984984
$objWriter->startElement('protectedRange');
985-
$objWriter->writeAttribute('name', 'p' . md5($protectedCell));
985+
$objWriter->writeAttribute('name', $protectedRange->getName());
986986
$objWriter->writeAttribute('sqref', $protectedCell);
987-
if (!empty($passwordHash)) {
988-
$objWriter->writeAttribute('password', $passwordHash);
989-
}
987+
$passwordHash = $protectedRange->getPassword();
988+
$this->writeAttributeIf($objWriter, $passwordHash !== '', 'password', $passwordHash);
989+
$securityDescriptor = $protectedRange->getSecurityDescriptor();
990+
$this->writeAttributeIf($objWriter, $securityDescriptor !== '', 'securityDescriptor', $securityDescriptor);
990991
$objWriter->endElement();
991992
}
992993

tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowTest.php

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,10 @@ public function testProtectCellsByColumnAndRow(): void
133133
$sheet->fromArray($data, null, 'B2', true);
134134

135135
$sheet->protectCells([2, 2, 3, 3], 'secret', false);
136-
$protectedRanges = $sheet->getProtectedCells();
136+
$protectedRanges = $sheet->/** @scrutinizer ignore-deprecated*/ getProtectedCells();
137137
self::assertArrayHasKey('B2:C3', $protectedRanges);
138+
$protectedRanges2 = $sheet->getProtectedCellRanges();
139+
self::assertArrayHasKey('B2:C3', $protectedRanges2);
138140
$spreadsheet->disconnectWorksheets();
139141
}
140142

@@ -147,11 +149,11 @@ public function testUnprotectCellsByColumnAndRow(): void
147149
$sheet->fromArray($data, null, 'B2', true);
148150

149151
$sheet->protectCells('B2:C3', 'secret', false);
150-
$protectedRanges = $sheet->getProtectedCells();
152+
$protectedRanges = $sheet->getProtectedCellRanges();
151153
self::assertArrayHasKey('B2:C3', $protectedRanges);
152154

153155
$sheet->unprotectCells([2, 2, 3, 3]);
154-
$protectedRanges = $sheet->getProtectedCells();
156+
$protectedRanges = $sheet->getProtectedCellRanges();
155157
self::assertEmpty($protectedRanges);
156158
$spreadsheet->disconnectWorksheets();
157159
}

tests/PhpSpreadsheetTests/Worksheet/ByColumnAndRowUndeprecatedTest.php

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ public function testProtectCellsByColumnAndRow(): void
144144
$sheet->fromArray($data, null, 'B2', true);
145145

146146
$sheet->protectCells([2, 2, 3, 3], 'secret', false);
147-
$protectedRanges = $sheet->getProtectedCells();
147+
$protectedRanges = $sheet->getProtectedCellRanges();
148148
self::assertArrayHasKey('B2:C3', $protectedRanges);
149149
}
150150

@@ -157,11 +157,11 @@ public function testUnprotectCellsByColumnAndRow(): void
157157
$sheet->fromArray($data, null, 'B2', true);
158158

159159
$sheet->protectCells('B2:C3', 'secret', false);
160-
$protectedRanges = $sheet->getProtectedCells();
160+
$protectedRanges = $sheet->getProtectedCellRanges();
161161
self::assertArrayHasKey('B2:C3', $protectedRanges);
162162

163163
$sheet->unprotectCells([2, 2, 3, 3]);
164-
$protectedRanges = $sheet->getProtectedCells();
164+
$protectedRanges = $sheet->getProtectedCellRanges();
165165
self::assertEmpty($protectedRanges);
166166
}
167167

0 commit comments

Comments
 (0)