Skip to content

Commit 3d98d34

Browse files
committed
Ods Reader Sheet Names with Period in Addresses and Formulas
Fix PHPOffice#4311. Period is a valid character in a sheet name. When a sheet with such a name is referenced in Ods format, the sheet name must be enclosed in apostrophes, because Ods uses period to separate sheet name from cell address. (Excel uses exclamation point so doesn't necessarily need to enclose the sheet name in apostrophes.) This causes a problem for the Ods Reader whenever it tries to parse such an address; however, the problem showed up specifically for auto filters, because the Ods xml for those specifies *'sheetname'.startcell:'sheetname'.endcell* (Excel omits sheetname). Ods Reader translates these addresses in 2 different methods in FormulaTranslator. I had a relatively elegant method for handling this situation in convertToExcelAddressValue, but I could not make it work in convertToExcelFormulaValue. A kludgier method works for Formula, and also for Address. I decided it's better to be consistent, so I'm going with the kludgier method for both. It would not surprise me in the least if there are similar problems lying in wait for other special characters in sheet names, and for other formats besides Ods. For now, I will limit myself to fixing the known problem.
1 parent fb757cf commit 3d98d34

File tree

3 files changed

+109
-4
lines changed

3 files changed

+109
-4
lines changed

src/PhpSpreadsheet/Reader/Ods/FormulaTranslator.php

+22-4
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,24 @@
66

77
class FormulaTranslator
88
{
9-
public static function convertToExcelAddressValue(string $openOfficeAddress): string
9+
private static function replaceQuotedPeriod(string $value): string
1010
{
11-
$excelAddress = $openOfficeAddress;
11+
$value2 = '';
12+
$quoted = false;
13+
foreach (mb_str_split($value, 1, 'UTF-8') as $char) {
14+
if ($char === "'") {
15+
$quoted = !$quoted;
16+
} elseif ($char === '.' && $quoted) {
17+
$char = "\u{fffe}";
18+
}
19+
$value2 .= $char;
20+
}
1221

22+
return $value2;
23+
}
24+
25+
public static function convertToExcelAddressValue(string $openOfficeAddress): string
26+
{
1327
// Cell range 3-d reference
1428
// As we don't support 3-d ranges, we're just going to take a quick and dirty approach
1529
// and assume that the second worksheet reference is the same as the first
@@ -20,15 +34,17 @@ public static function convertToExcelAddressValue(string $openOfficeAddress): st
2034
'/\$?([^\.]+)\.([^\.]+)/miu', // Cell reference in another sheet
2135
'/\.([^\.]+):\.([^\.]+)/miu', // Cell range reference
2236
'/\.([^\.]+)/miu', // Simple cell reference
37+
'/\\x{FFFE}/miu', // restore quoted periods
2338
],
2439
[
2540
'$1!$2:$4',
2641
'$1!$2:$3',
2742
'$1!$2',
2843
'$1:$2',
2944
'$1',
45+
'.',
3046
],
31-
$excelAddress
47+
self::replaceQuotedPeriod($openOfficeAddress)
3248
);
3349

3450
return $excelAddress;
@@ -52,14 +68,16 @@ public static function convertToExcelFormulaValue(string $openOfficeFormula): st
5268
'/\[\$?([^\.]+)\.([^\.]+)\]/miu', // Cell reference in another sheet
5369
'/\[\.([^\.]+):\.([^\.]+)\]/miu', // Cell range reference
5470
'/\[\.([^\.]+)\]/miu', // Simple cell reference
71+
'/\\x{FFFE}/miu', // restore quoted periods
5572
],
5673
[
5774
'$1!$2:$3',
5875
'$1!$2',
5976
'$1:$2',
6077
'$1',
78+
'.',
6179
],
62-
$value
80+
self::replaceQuotedPeriod($value)
6381
);
6482
// Convert references to defined names/formulae
6583
$value = str_replace('$$', '', $value);
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
<?php
2+
3+
declare(strict_types=1);
4+
5+
namespace PhpOffice\PhpSpreadsheetTests\Reader\Ods;
6+
7+
use PhpOffice\PhpSpreadsheet\Reader\Ods\FormulaTranslator;
8+
use PHPUnit\Framework\Attributes\DataProvider;
9+
use PHPUnit\Framework\TestCase;
10+
11+
class FormulaTranslatorTest extends TestCase
12+
{
13+
#[DataProvider('addressesProvider')]
14+
public function testAddresses(string $result, string $address): void
15+
{
16+
self::assertSame($result, FormulaTranslator::convertToExcelAddressValue($address));
17+
}
18+
19+
public static function addressesProvider(): array
20+
{
21+
return [
22+
'range period in sheet name' => ["'sheet1.bug'!a1:a5", "'sheet1.bug'.a1:'sheet1.bug'.a5"],
23+
'range special chars and period in sheet name' => ["'#sheet1.x'!a1:a5", "'#sheet1.x'.a1:'#sheet1.x'.a5"],
24+
'cell period in sheet name' => ["'sheet1.bug'!b9", "'sheet1.bug'.b9"],
25+
'range unquoted sheet name' => ['sheet1!b9:c12', 'sheet1.b9:sheet1.c12'],
26+
'range unquoted sheet name with $' => ['sheet1!$b9:c$12', 'sheet1.$b9:sheet1.c$12'],
27+
'range quoted sheet name with $' => ["'sheet1'!\$b9:c\$12", '\'sheet1\'.$b9:\'sheet1\'.c$12'],
28+
'cell unquoted sheet name' => ['sheet1!B$9', 'sheet1.B$9'],
29+
'range no sheet name all dollars' => ['$B$9:$C$12', '$B$9:$C$12'],
30+
'range no sheet name some dollars' => ['B$9:$C12', 'B$9:$C12'],
31+
'range no sheet name no dollars' => ['B9:C12', 'B9:C12'],
32+
];
33+
}
34+
35+
#[DataProvider('formulaProvider')]
36+
public function testFormulas(string $result, string $formula): void
37+
{
38+
self::assertSame($result, FormulaTranslator::convertToExcelFormulaValue($formula));
39+
}
40+
41+
public static function formulaProvider(): array
42+
{
43+
return [
44+
'ranges no sheet name' => [
45+
'SUM(A5:A7,B$5:$B7)',
46+
'SUM([.A5:.A7];[.B$5:.$B7])',
47+
],
48+
'ranges sheet name with period' => [
49+
'SUM(\'test.bug\'!A5:A7,\'test.bug\'!B5:B7)',
50+
'SUM([\'test.bug\'.A5:.A7];[\'test.bug\'.B5:.B7])',
51+
],
52+
'ranges unquoted sheet name' => [
53+
'SUM(testbug!A5:A7,testbug!B5:B7)',
54+
'SUM([testbug.A5:.A7];[testbug.B5:.B7])',
55+
],
56+
'ranges quoted sheet name without period' => [
57+
'SUM(\'testbug\'!A5:A7,\'testbug\'!B5:B7)',
58+
'SUM([\'testbug\'.A5:.A7];[\'testbug\'.B5:.B7])',
59+
],
60+
];
61+
}
62+
}

tests/PhpSpreadsheetTests/Writer/Ods/AutoFilterTest.php

+25
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,29 @@ public function testAutoFilterWriter(): void
3232

3333
self::assertSame('A1:C9', $reloaded->getActiveSheet()->getAutoFilter()->getRange());
3434
}
35+
36+
public function testPeriodInSheetNames(): void
37+
{
38+
$spreadsheet = new Spreadsheet();
39+
$worksheet = $spreadsheet->getActiveSheet();
40+
$worksheet->setTitle('work.sheet');
41+
42+
$dataSet = [
43+
['Year', 'Quarter', 'Sales'],
44+
[2020, 'Q1', 100],
45+
[2020, 'Q2', 120],
46+
[2020, 'Q3', 140],
47+
[2020, 'Q4', 160],
48+
[2021, 'Q1', 180],
49+
[2021, 'Q2', 75],
50+
[2021, 'Q3', 0],
51+
[2021, 'Q4', 0],
52+
];
53+
$worksheet->fromArray($dataSet, null, 'A1');
54+
$worksheet->getAutoFilter()->setRange('A1:C9');
55+
56+
$reloaded = $this->writeAndReload($spreadsheet, 'Ods');
57+
58+
self::assertSame('A1:C9', $reloaded->getActiveSheet()->getAutoFilter()->getRange());
59+
}
3560
}

0 commit comments

Comments
 (0)