Protect Sheet But Allow Sort

Fix #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`.
This commit is contained in:
oleibman
2024-03-20 07:04:24 -07:00
parent 9a94aea7ad
commit 891180e13d
11 changed files with 316 additions and 20 deletions
+9
View File
@@ -1295,6 +1295,15 @@ $protection->setInsertRows(false);
$protection->setFormatCells(false);
```
Note that allowing sort without providing the sheet password
(similarly with autoFilter) requires that you explicitly
enable the cell ranges for which sort is permitted,
with or without a range password:
```php
$sheet->protectCells('A:A'); // column A can be sorted without password
$sheet->protectCells('B:B', 'sortpw'); // column B can be sorted if the range password sortpw is supplied
```
If writing Xlsx files you can specify the algorithm used to hash the password
before calling `setPassword()` like so:
+91
View File
@@ -0,0 +1,91 @@
<?php
require __DIR__ . '/../Header.php';
use PhpOffice\PhpSpreadsheet\RichText\RichText;
use PhpOffice\PhpSpreadsheet\RichText\TextElement;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
$spreadsheet = new Spreadsheet();
$helper->log('First sheet - protected, sorts not allowed');
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('sorttrue');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('B1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(true);
$comment = $sheet->getComment('A1');
$text = new RichText();
$text->addText(new TextElement('Sort options should be grayed out. Sheet password to remove protections is testpassword for all sheets.'));
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
$helper->log('Second sheet - protected, sorts allowed, but no permitted range defined');
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalse');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('B1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$comment = $sheet->getComment('A1');
$text = new RichText();
$text->addText(new TextElement('Sort options not grayed out, but no permissible sort range.'));
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
$helper->log('Third sheet - protected, sorts allowed, but only on permitted range A:A, no range password needed');
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalsenocolpw');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('C1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$sheet->protectCells('A:A');
$comment = $sheet->getComment('A1');
$text = new RichText();
$text->addText(new TextElement('Column A may be sorted without a password. No sort for any other column.'));
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
$helper->log('Fourth sheet - protected, sorts allowed, but only on permitted range A:A, and range password needed');
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalsecolpw');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('C1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$sheet->protectCells('A:A', 'sortpw', false, 'sortrange');
$comment = $sheet->getComment('A1');
$text = new RichText();
$text->addText(new TextElement('Column A may be sorted with password sortpw. No sort for any other column.'));
$comment->setText($text)->setHeight('120pt')->setWidth('120pt');
// Save
$helper->write($spreadsheet, __FILE__, ['Xls', 'Xlsx']);
$spreadsheet->disconnectWorksheets();
+1 -1
View File
@@ -4972,7 +4972,7 @@ class Xls extends BaseReader
// Apply range protection to sheet
if ($cellRanges) {
$this->phpSheet->protectCells(implode(' ', $cellRanges), strtoupper(dechex($wPassword)), true);
$this->phpSheet->protectCells(implode(' ', $cellRanges), ($wPassword === 0) ? '' : strtoupper(dechex($wPassword)), true);
}
}
}
+1 -1
View File
@@ -2175,7 +2175,7 @@ class Xlsx extends BaseReader
if ($xmlSheet->protectedRanges->protectedRange) {
foreach ($xmlSheet->protectedRanges->protectedRange as $protectedRange) {
$docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true);
$docSheet->protectCells((string) $protectedRange['sqref'], (string) $protectedRange['password'], true, (string) $protectedRange['name'], (string) $protectedRange['securityDescriptor']);
}
}
}
@@ -0,0 +1,45 @@
<?php
namespace PhpOffice\PhpSpreadsheet\Worksheet;
class ProtectedRange
{
private string $name = '';
private string $password = '';
private string $sqref;
private string $securityDescriptor = '';
/**
* No setters aside from constructor.
*/
public function __construct(string $sqref, string $password = '', string $name = '', string $securityDescriptor = '')
{
$this->sqref = $sqref;
$this->name = $name;
$this->password = $password;
$this->securityDescriptor = $securityDescriptor;
}
public function getSqref(): string
{
return $this->sqref;
}
public function getName(): string
{
return $this->name ?: ('p' . md5($this->sqref));
}
public function getPassword(): string
{
return $this->password;
}
public function getSecurityDescriptor(): string
{
return $this->securityDescriptor;
}
}
+23 -5
View File
@@ -192,7 +192,7 @@ class Worksheet implements IComparable
/**
* Collection of protected cell ranges.
*
* @var string[]
* @var ProtectedRange[]
*/
private array $protectedCells = [];
@@ -1866,14 +1866,14 @@ class Worksheet implements IComparable
*
* @return $this
*/
public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password, bool $alreadyHashed = false): static
public function protectCells(AddressRange|CellAddress|int|string|array $range, string $password = '', bool $alreadyHashed = false, string $name = '', string $securityDescriptor = ''): static
{
$range = Functions::trimSheetFromCellReference(Validations::validateCellOrCellRange($range));
if (!$alreadyHashed) {
if (!$alreadyHashed && $password !== '') {
$password = Shared\PasswordHasher::hashPassword($password);
}
$this->protectedCells[$range] = $password;
$this->protectedCells[$range] = new ProtectedRange($range, $password, $name, $securityDescriptor);
return $this;
}
@@ -1901,11 +1901,29 @@ class Worksheet implements IComparable
}
/**
* Get protected cells.
* Get password for protected cells.
*
* @return string[]
*
* @deprecated 2.0.1 use getProtectedCellRanges instead
* @see Worksheet::getProtectedCellRanges()
*/
public function getProtectedCells(): array
{
$array = [];
foreach ($this->protectedCells as $key => $protectedRange) {
$array[$key] = $protectedRange->getPassword();
}
return $array;
}
/**
* Get protected cells.
*
* @return ProtectedRange[]
*/
public function getProtectedCellRanges(): array
{
return $this->protectedCells;
}
+2 -1
View File
@@ -1496,7 +1496,8 @@ class Worksheet extends BIFFwriter
*/
private function writeRangeProtection(): void
{
foreach ($this->phpSheet->getProtectedCells() as $range => $password) {
foreach ($this->phpSheet->getProtectedCellRanges() as $range => $protectedCells) {
$password = $protectedCells->getPassword();
// number of ranges, e.g. 'A1:B3 C20:D25'
$cellRanges = explode(' ', $range);
$cref = count($cellRanges);
+7 -6
View File
@@ -974,19 +974,20 @@ class Worksheet extends WriterPart
*/
private function writeProtectedRanges(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void
{
if (count($worksheet->getProtectedCells()) > 0) {
if (count($worksheet->getProtectedCellRanges()) > 0) {
// protectedRanges
$objWriter->startElement('protectedRanges');
// Loop protectedRanges
foreach ($worksheet->getProtectedCells() as $protectedCell => $passwordHash) {
foreach ($worksheet->getProtectedCellRanges() as $protectedCell => $protectedRange) {
// protectedRange
$objWriter->startElement('protectedRange');
$objWriter->writeAttribute('name', 'p' . md5($protectedCell));
$objWriter->writeAttribute('name', $protectedRange->getName());
$objWriter->writeAttribute('sqref', $protectedCell);
if (!empty($passwordHash)) {
$objWriter->writeAttribute('password', $passwordHash);
}
$passwordHash = $protectedRange->getPassword();
$this->writeAttributeIf($objWriter, $passwordHash !== '', 'password', $passwordHash);
$securityDescriptor = $protectedRange->getSecurityDescriptor();
$this->writeAttributeIf($objWriter, $securityDescriptor !== '', 'securityDescriptor', $securityDescriptor);
$objWriter->endElement();
}
@@ -133,8 +133,10 @@ class ByColumnAndRowTest extends TestCase
$sheet->fromArray($data, null, 'B2', true);
$sheet->protectCells([2, 2, 3, 3], 'secret', false);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->/** @scrutinizer ignore-deprecated*/ getProtectedCells();
self::assertArrayHasKey('B2:C3', $protectedRanges);
$protectedRanges2 = $sheet->getProtectedCellRanges();
self::assertArrayHasKey('B2:C3', $protectedRanges2);
$spreadsheet->disconnectWorksheets();
}
@@ -147,11 +149,11 @@ class ByColumnAndRowTest extends TestCase
$sheet->fromArray($data, null, 'B2', true);
$sheet->protectCells('B2:C3', 'secret', false);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->getProtectedCellRanges();
self::assertArrayHasKey('B2:C3', $protectedRanges);
$sheet->unprotectCells([2, 2, 3, 3]);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->getProtectedCellRanges();
self::assertEmpty($protectedRanges);
$spreadsheet->disconnectWorksheets();
}
@@ -144,7 +144,7 @@ class ByColumnAndRowUndeprecatedTest extends TestCase
$sheet->fromArray($data, null, 'B2', true);
$sheet->protectCells([2, 2, 3, 3], 'secret', false);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->getProtectedCellRanges();
self::assertArrayHasKey('B2:C3', $protectedRanges);
}
@@ -157,11 +157,11 @@ class ByColumnAndRowUndeprecatedTest extends TestCase
$sheet->fromArray($data, null, 'B2', true);
$sheet->protectCells('B2:C3', 'secret', false);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->getProtectedCellRanges();
self::assertArrayHasKey('B2:C3', $protectedRanges);
$sheet->unprotectCells([2, 2, 3, 3]);
$protectedRanges = $sheet->getProtectedCells();
$protectedRanges = $sheet->getProtectedCellRanges();
self::assertEmpty($protectedRanges);
}
@@ -0,0 +1,129 @@
<?php
declare(strict_types=1);
namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx;
use PhpOffice\PhpSpreadsheet\Spreadsheet;
use PhpOffice\PhpSpreadsheetTests\Functional\AbstractFunctional;
class Issue3951Test extends AbstractFunctional
{
/**
* @dataProvider providerType
*/
public function testIssue2266(string $type): void
{
$spreadsheet = new Spreadsheet();
$sheet = $spreadsheet->getActiveSheet();
$sheet->setTitle('sorttrue');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('B1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(true);
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalse');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('B1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalsenocolpw');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('C1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$sheet->protectCells('A:A');
$sheet = $spreadsheet->createSheet();
$sheet->setTitle('sortfalsecolpw');
$sheet->getCell('A1')->setValue(10);
$sheet->getCell('A2')->setValue(5);
$sheet->getCell('C1')->setValue(15);
$protection = $sheet->getProtection();
$protection->setPassword('testpassword');
$protection->setSheet(true);
$protection->setInsertRows(true);
$protection->setFormatCells(true);
$protection->setObjects(true);
$protection->setAutoFilter(false);
$protection->setSort(false);
$sheet->protectCells('A:A', 'sortpw', false, 'sortrange');
$reloadedSpreadsheet = $this->writeAndReload($spreadsheet, $type);
$spreadsheet->disconnectWorksheets();
self::assertCount(4, $reloadedSpreadsheet->getAllSheets());
$rsheet1 = $reloadedSpreadsheet->getSheetByNameOrThrow('sorttrue');
$protection = $rsheet1->getProtection();
self::assertNotEquals('', $protection->getPassword());
self::assertTrue($protection->getSort());
self::assertEmpty($rsheet1->getProtectedCellRanges());
$rsheet2 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalse');
$protection = $rsheet2->getProtection();
self::assertNotEquals('', $protection->getPassword());
self::assertFalse($protection->getSort());
self::assertEmpty($rsheet2->getProtectedCellRanges());
$rsheet3 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalsenocolpw');
$protection = $rsheet3->getProtection();
self::assertNotEquals('', $protection->getPassword());
self::assertFalse($protection->getSort());
$maxRow = ($type === 'Xlsx') ? 1048576 : 65536;
$testKey = "A1:A$maxRow";
$protectedCells = $rsheet3->getProtectedCellRanges();
self::assertCount(1, $protectedCells);
self::assertArrayHasKey($testKey, $protectedCells);
self::assertSame('', $protectedCells[$testKey]->getPassword());
$rsheet4 = $reloadedSpreadsheet->getSheetByNameOrThrow('sortfalsecolpw');
$protection = $rsheet4->getProtection();
self::assertNotEquals('', $protection->getPassword());
self::assertFalse($protection->getSort());
$maxRow = ($type === 'Xlsx') ? 1048576 : 65536;
$testKey = "A1:A$maxRow";
$protectedCells = $rsheet4->getProtectedCellRanges();
self::assertCount(1, $protectedCells);
self::assertArrayHasKey($testKey, $protectedCells);
self::assertNotEquals('', $protectedCells[$testKey]->getPassword());
if ($type === 'Xlsx') {
self::assertSame('sortrange', $protectedCells[$testKey]->getName());
}
$reloadedSpreadsheet->disconnectWorksheets();
}
public static function providerType(): array
{
return [
['Xlsx'],
['Xls'],
];
}
}