Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e276b4378e | ||
|
|
9ce77405e0 | ||
|
|
ef4a32eb5e | ||
|
|
4cb30bc36d | ||
|
|
bf616dee90 | ||
|
|
3a330debb3 | ||
|
|
a19231fb68 | ||
|
|
73d5d0ea17 | ||
|
|
687c321363 | ||
|
|
2fa01cd838 | ||
|
|
752f4bf64e | ||
|
|
b61323d7d2 | ||
|
|
179ab483d6 | ||
|
|
2fafb63115 | ||
|
|
5ef5647558 | ||
|
|
0978d340f0 | ||
|
|
77178122c3 | ||
|
|
23f8cc4f05 | ||
|
|
442a9837f1 | ||
|
|
cc07072cbb | ||
|
|
30aa1b87e2 | ||
|
|
ddb7365a79 | ||
|
|
a07a96f523 | ||
|
|
3e0afd858f | ||
|
|
d4e57b1f0d | ||
|
|
435a9a016e | ||
|
|
5e7a1745ac | ||
|
|
277c353984 | ||
|
|
ff2d54cc8d | ||
|
|
c94694cb60 | ||
|
|
584121d478 | ||
|
|
b2dc0c3fa9 | ||
|
|
7f65993c87 | ||
|
|
b75a3e34fc | ||
|
|
82605ab57b | ||
|
|
7a613eed8c | ||
|
|
54a1a09e29 | ||
|
|
a8eb7ad39c | ||
|
|
ffea8871a6 | ||
|
|
b02d13cd40 | ||
|
|
aa25678a83 | ||
|
|
192659cb24 | ||
|
|
e30bc37448 | ||
|
|
1891c0b053 | ||
|
|
a43c13a36f | ||
|
|
dc31d6e8c2 | ||
|
|
8edd8e2401 | ||
|
|
cd38ba093e | ||
|
|
70c81e809f | ||
|
|
1d3a9f939c | ||
|
|
efebfb2bc2 | ||
|
|
251c0bebc1 | ||
|
|
03866a6604 | ||
|
|
2ed30321b4 | ||
|
|
2c80b1f23a | ||
|
|
a24e794177 | ||
|
|
104cd9b811 | ||
|
|
a6a6b158de | ||
|
|
2d923c7e46 | ||
|
|
b4724906c4 | ||
|
|
bb20d2e6bb | ||
|
|
b8fd789ac0 | ||
|
|
5a7c2c1262 | ||
|
|
e9cd7a397e | ||
|
|
8bb42ebc23 | ||
|
|
71a6f6a937 | ||
|
|
616925148e | ||
|
|
6f0f7c9690 | ||
|
|
b69e28050b | ||
|
|
d6e8fe4b54 | ||
|
|
e321f30c3b | ||
|
|
c31373fb1a | ||
|
|
6c57125c0c | ||
|
|
30837f869d | ||
|
|
e60054f3c4 | ||
|
|
3ee7099c95 | ||
|
|
2b1160bb33 | ||
|
|
049fd990b9 | ||
|
|
d2ac54c578 | ||
|
|
0c90d102ef | ||
|
|
e39dcb3847 | ||
|
|
10d1140a95 | ||
|
|
48debbcbca | ||
|
|
f4d6fb87ee | ||
|
|
771afcb5f1 | ||
|
|
86e26632f6 | ||
|
|
8614f79da3 | ||
|
|
4827e56cac | ||
|
|
03e85ffc21 | ||
|
|
e4cc8b4eaa | ||
|
|
73341d06a9 | ||
|
|
209372462b | ||
|
|
4a5da2ad74 | ||
|
|
c48c07db99 | ||
|
|
a804be4844 | ||
|
|
4bfbb41c95 | ||
|
|
61b71bf379 | ||
|
|
3c3294061a | ||
|
|
1fde6d836a | ||
|
|
05489cda88 | ||
|
|
128db45f22 | ||
|
|
9a85d84a2e | ||
|
|
4407cffeff | ||
|
|
8d27b3097d | ||
|
|
2c6cb1ffe5 | ||
|
|
22daea5f9a | ||
|
|
ed0e8f79cc | ||
|
|
44d72d8245 | ||
|
|
728dd3b399 | ||
|
|
05a9a1b60a | ||
|
|
a76624a721 | ||
|
|
90cbb7b5a6 | ||
|
|
f55520661e | ||
|
|
cb5dae22e4 | ||
|
|
2f6193ce20 | ||
|
|
582da8403d | ||
|
|
8b666fc6cd | ||
|
|
f8aab6eefd | ||
|
|
c4c6dddb20 | ||
|
|
6c97141679 | ||
|
|
9c8cc05364 | ||
|
|
2a9400dfca | ||
|
|
8ef6bdac62 | ||
|
|
46c246e6a4 | ||
|
|
8fd606ae4f | ||
|
|
6767386daf | ||
|
|
3395d3abb3 |
No files matched your search
@@ -0,0 +1,11 @@
|
|||||||
|
# Ignore all test and documentation for archive
|
||||||
|
/tests export-ignore
|
||||||
|
/.editorconfig export-ignore
|
||||||
|
/.gitattributes export-ignore
|
||||||
|
/.gitignore export-ignore
|
||||||
|
/.scrutinizer.yml export-ignore
|
||||||
|
/.travis.yml export-ignore
|
||||||
|
/composer.lock export-ignore
|
||||||
|
/CONTRIBUTING.md export-ignore
|
||||||
|
/logo.png export-ignore
|
||||||
|
/phpunit.xml export-ignore
|
||||||
+6
-3
@@ -15,10 +15,13 @@ tools:
|
|||||||
php_pdepend: true
|
php_pdepend: true
|
||||||
php_loc:
|
php_loc:
|
||||||
enabled: true
|
enabled: true
|
||||||
excluded_dirs: [vendor, tests]
|
filter:
|
||||||
php_cpd:
|
paths: ['src']
|
||||||
|
php_cpd: false # Must be disabled to use php_sim instead
|
||||||
|
php_sim:
|
||||||
enabled: true
|
enabled: true
|
||||||
excluded_dirs: [vendor, tests]
|
filter:
|
||||||
|
paths: ['src']
|
||||||
|
|
||||||
build_failure_conditions:
|
build_failure_conditions:
|
||||||
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
|
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
|
||||||
|
|||||||
+3
-2
@@ -16,7 +16,8 @@ install:
|
|||||||
|
|
||||||
script:
|
script:
|
||||||
- mkdir -p build/logs
|
- mkdir -p build/logs
|
||||||
- php vendor/bin/phpunit --coverage-clover build/logs/clover.xml
|
- php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
|
||||||
|
|
||||||
after_script:
|
after_script:
|
||||||
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml; fi
|
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi
|
||||||
|
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover; fi
|
||||||
@@ -2,13 +2,13 @@
|
|||||||
|
|
||||||
[](https://packagist.org/packages/box/spout)
|
[](https://packagist.org/packages/box/spout)
|
||||||
[](http://opensource.box.com/badges)
|
[](http://opensource.box.com/badges)
|
||||||
[](http://travis-ci.org/box/spout)
|
[](https://travis-ci.org/box/spout)
|
||||||
|
[](https://scrutinizer-ci.com/g/box/spout/?branch=master)
|
||||||
[](https://scrutinizer-ci.com/g/box/spout/?branch=master)
|
[](https://scrutinizer-ci.com/g/box/spout/?branch=master)
|
||||||
[](https://packagist.org/packages/box/spout)
|
[](https://packagist.org/packages/box/spout)
|
||||||
[](https://packagist.org/packages/box/spout)
|
|
||||||
|
|
||||||
Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
|
Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
|
||||||
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 10MB).
|
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 3MB).
|
||||||
|
|
||||||
Join the community and come discuss about Spout: [](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
Join the community and come discuss about Spout: [](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||||
|
|
||||||
@@ -18,16 +18,9 @@ Join the community and come discuss about Spout: [.
|
Spout can be installed directly from [Composer](https://getcomposer.org/).
|
||||||
|
|
||||||
Add "box/spout" as a dependency in your project's composer.json file:
|
Run the following command:
|
||||||
```json
|
|
||||||
"require": {
|
|
||||||
"box/spout": "~2.0"
|
|
||||||
}
|
|
||||||
```
|
```
|
||||||
|
$ composer require box/spout
|
||||||
Then run the install command from Composer:
|
|
||||||
```
|
|
||||||
php composer.phar install
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### Manual installation
|
### Manual installation
|
||||||
@@ -62,8 +55,8 @@ Regardless of the file type, the interface to read a file is always the same:
|
|||||||
use Box\Spout\Reader\ReaderFactory;
|
use Box\Spout\Reader\ReaderFactory;
|
||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
|
|
||||||
$reader = ReaderFactory::create(Type::CSV); // for CSV files
|
$reader = ReaderFactory::create(Type::XLSX); // for XLSX files
|
||||||
//$reader = ReaderFactory::create(Type::XLSX); // for XLSX files
|
//$reader = ReaderFactory::create(Type::CSV); // for CSV files
|
||||||
//$reader = ReaderFactory::create(Type::ODS); // for ODS files
|
//$reader = ReaderFactory::create(Type::ODS); // for ODS files
|
||||||
|
|
||||||
$reader->open($filePath);
|
$reader->open($filePath);
|
||||||
@@ -87,8 +80,8 @@ As with the reader, there is one common interface to write data to a file:
|
|||||||
use Box\Spout\Writer\WriterFactory;
|
use Box\Spout\Writer\WriterFactory;
|
||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
|
|
||||||
$writer = WriterFactory::create(Type::CSV); // for CSV files
|
$writer = WriterFactory::create(Type::XLSX); // for XLSX files
|
||||||
//$writer = WriterFactory::create(Type::XLSX); // for XLSX files
|
//$writer = WriterFactory::create(Type::CSV); // for CSV files
|
||||||
//$writer = WriterFactory::create(Type::ODS); // for ODS files
|
//$writer = WriterFactory::create(Type::ODS); // for ODS files
|
||||||
|
|
||||||
$writer->openToFile($filePath); // write data to a file or to a PHP stream
|
$writer->openToFile($filePath); // write data to a file or to a PHP stream
|
||||||
@@ -105,6 +98,8 @@ For XLSX and ODS files, the number of rows per sheet is limited to 1,048,576. By
|
|||||||
|
|
||||||
## Advanced usage
|
## Advanced usage
|
||||||
|
|
||||||
|
If you are looking for how to perform some common, more advanced tasks with Spout, please take a look at the [Wiki](https://github.com/box/spout/wiki). It contains code snippets, ready to be used.
|
||||||
|
|
||||||
### Configuring the CSV reader and writer
|
### Configuring the CSV reader and writer
|
||||||
|
|
||||||
It is possible to configure both the CSV reader and writer to specify the field separator as well as the field enclosure:
|
It is possible to configure both the CSV reader and writer to specify the field separator as well as the field enclosure:
|
||||||
@@ -115,6 +110,7 @@ use Box\Spout\Common\Type;
|
|||||||
$reader = ReaderFactory::create(Type::CSV);
|
$reader = ReaderFactory::create(Type::CSV);
|
||||||
$reader->setFieldDelimiter('|');
|
$reader->setFieldDelimiter('|');
|
||||||
$reader->setFieldEnclosure('@');
|
$reader->setFieldEnclosure('@');
|
||||||
|
$reader->setEndOfLineCharacter("\r");
|
||||||
```
|
```
|
||||||
|
|
||||||
Additionally, if you need to read non UTF-8 files, you can specify the encoding of your file this way:
|
Additionally, if you need to read non UTF-8 files, you can specify the encoding of your file this way:
|
||||||
@@ -122,14 +118,22 @@ Additionally, if you need to read non UTF-8 files, you can specify the encoding
|
|||||||
$reader->setEncoding('UTF-16LE');
|
$reader->setEncoding('UTF-16LE');
|
||||||
```
|
```
|
||||||
|
|
||||||
The writer always generate CSV files encoded in UTF-8, with a BOM.
|
By default, the writer generates CSV files encoded in UTF-8, with a BOM.
|
||||||
|
It is however possible to not include the BOM:
|
||||||
|
```php
|
||||||
|
use Box\Spout\Writer\WriterFactory;
|
||||||
|
use Box\Spout\Common\Type;
|
||||||
|
|
||||||
|
$writer = WriterFactory::create(Type::CSV);
|
||||||
|
$writer->setShouldAddBOM(false);
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
### Configuring the XLSX and ODS writers
|
### Configuring the XLSX and ODS readers and writers
|
||||||
|
|
||||||
#### Row styling
|
#### Row styling
|
||||||
|
|
||||||
It is possible to apply some formatting options to a row. Spout supports fonts as well as alignment styles.
|
It is possible to apply some formatting options to a row. Spout supports fonts, background, borders as well as alignment styles.
|
||||||
|
|
||||||
```php
|
```php
|
||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
@@ -142,6 +146,7 @@ $style = (new StyleBuilder())
|
|||||||
->setFontSize(15)
|
->setFontSize(15)
|
||||||
->setFontColor(Color::BLUE)
|
->setFontColor(Color::BLUE)
|
||||||
->setShouldWrapText()
|
->setShouldWrapText()
|
||||||
|
->setBackgroundColor(Color::YELLOW)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$writer = WriterFactory::create(Type::XLSX);
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
@@ -154,6 +159,45 @@ $writer->addRowsWithStyle($multipleRows, $style); // style will be applied to al
|
|||||||
$writer->close();
|
$writer->close();
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Adding borders to a row requires a ```Border``` object.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Box\Spout\Common\Type;
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
use Box\Spout\Writer\Style\BorderBuilder;
|
||||||
|
use Box\Spout\Writer\Style\Color;
|
||||||
|
use Box\Spout\Writer\Style\StyleBuilder;
|
||||||
|
use Box\Spout\Writer\WriterFactory;
|
||||||
|
|
||||||
|
$border = (new BorderBuilder())
|
||||||
|
->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$style = (new StyleBuilder())
|
||||||
|
->setBorder($border)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
|
$writer->openToFile($filePath);
|
||||||
|
|
||||||
|
$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style);
|
||||||
|
|
||||||
|
$writer->close();
|
||||||
|
```
|
||||||
|
|
||||||
|
Spout will use a default style for all created rows. This style can be overridden this way:
|
||||||
|
|
||||||
|
```php
|
||||||
|
$defaultStyle = (new StyleBuilder())
|
||||||
|
->setFontName('Arial')
|
||||||
|
->setFontSize(11)
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
|
$writer->setDefaultRowStyle($defaultStyle)
|
||||||
|
->openToFile($filePath);
|
||||||
|
```
|
||||||
|
|
||||||
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
|
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
|
||||||
|
|
||||||
Category | Property | API
|
Category | Property | API
|
||||||
@@ -164,9 +208,8 @@ Font | Bold | `StyleBuilder::setFontBold()`
|
|||||||
| Strikethrough | `StyleBuilder::setFontStrikethrough()`
|
| Strikethrough | `StyleBuilder::setFontStrikethrough()`
|
||||||
| Font name | `StyleBuilder::setFontName('Arial')`
|
| Font name | `StyleBuilder::setFontName('Arial')`
|
||||||
| Font size | `StyleBuilder::setFontSize(14)`
|
| Font size | `StyleBuilder::setFontSize(14)`
|
||||||
| Font color | `StyleBuilder::setFontSize(Color::BLUE)`<br>`StyleBuilder::setFontSize(Color::rgb(0, 128, 255))`
|
| Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))`
|
||||||
Alignment | Wrap text | `StyleBuilder::setShouldWrapText()`
|
Alignment | Wrap text | `StyleBuilder::setShouldWrapText(true|false)`
|
||||||
|
|
||||||
|
|
||||||
#### New sheet creation
|
#### New sheet creation
|
||||||
|
|
||||||
@@ -212,6 +255,20 @@ $writer->setShouldUseInlineStrings(false); // will use shared strings
|
|||||||
> Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings!
|
> Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings!
|
||||||
|
|
||||||
|
|
||||||
|
#### Date/Time formatting
|
||||||
|
|
||||||
|
When reading a spreadsheet containing dates or times, Spout returns the values by default as DateTime objects.
|
||||||
|
It is possible to change this behavior and have a formatted date returned instead (e.g. "2016-11-29 1:22 AM"). The format of the date corresponds to what is specified in the spreadsheet.
|
||||||
|
|
||||||
|
```php
|
||||||
|
use Box\Spout\Reader\ReaderFactory;
|
||||||
|
use Box\Spout\Common\Type;
|
||||||
|
|
||||||
|
$reader = ReaderFactory::create(Type::XLSX);
|
||||||
|
$reader->setShouldFormatDates(false); // default value
|
||||||
|
$reader->setShouldFormatDates(true); // will return formatted dates
|
||||||
|
```
|
||||||
|
|
||||||
### Playing with sheets
|
### Playing with sheets
|
||||||
|
|
||||||
When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet:
|
When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet:
|
||||||
@@ -282,15 +339,17 @@ If you just want to check that everything is working as expected, executing the
|
|||||||
If you want to run performance tests, you will need to checkout the `perf-tests` branch. Multiple test suites can then be run, depending on the expected output:
|
If you want to run performance tests, you will need to checkout the `perf-tests` branch. Multiple test suites can then be run, depending on the expected output:
|
||||||
|
|
||||||
* `phpunit` - runs the whole test suite (unit + functional + performance tests)
|
* `phpunit` - runs the whole test suite (unit + functional + performance tests)
|
||||||
* `phpunit --testuite no-perf-tests` - only runs the unit and functional tests
|
* `phpunit --exclude-group perf-tests` - only runs the unit and functional tests
|
||||||
* `phpunit --testuite perf-tests` - only runs the performance tests
|
* `phpunit --group perf-tests` - only runs the performance tests
|
||||||
|
|
||||||
For information, the performance tests take about one hour to run (processing 2 million rows files is not a quick thing).
|
For information, the performance tests take about 30 minutes to run (processing 1 million rows files is not a quick thing).
|
||||||
|
|
||||||
|
> Performance tests status: [](https://travis-ci.org/box/spout)
|
||||||
|
|
||||||
|
|
||||||
## Frequently Asked Questions
|
## Frequently Asked Questions
|
||||||
|
|
||||||
#### How can Spout handle such large data sets and still use less than 10MB of memory?
|
#### How can Spout handle such large data sets and still use less than 3MB of memory?
|
||||||
|
|
||||||
When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed.
|
When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed.
|
||||||
|
|
||||||
|
|||||||
+7
-2
@@ -18,8 +18,7 @@
|
|||||||
"ext-simplexml": "*"
|
"ext-simplexml": "*"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": ">=3.7",
|
"phpunit/phpunit": "^4.8.0"
|
||||||
"scrutinizer/ocular": "~1.1"
|
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
|
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
|
||||||
@@ -29,5 +28,11 @@
|
|||||||
"psr-4": {
|
"psr-4": {
|
||||||
"Box\\Spout\\": "src/Spout"
|
"Box\\Spout\\": "src/Spout"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"extra": {
|
||||||
|
"branch-alias": {
|
||||||
|
"dev-master": "2.8.x-dev"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Generated
+74
-741
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Common\Escaper;
|
namespace Box\Spout\Common\Escaper;
|
||||||
|
|
||||||
|
use Box\Spout\Common\Singleton;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class ODS
|
* Class ODS
|
||||||
* Provides functions to escape and unescape data for ODS files
|
* Provides functions to escape and unescape data for ODS files
|
||||||
@@ -10,6 +12,33 @@ namespace Box\Spout\Common\Escaper;
|
|||||||
*/
|
*/
|
||||||
class ODS implements EscaperInterface
|
class ODS implements EscaperInterface
|
||||||
{
|
{
|
||||||
|
use Singleton;
|
||||||
|
|
||||||
|
/** @var string Regex pattern to detect control characters that need to be escaped */
|
||||||
|
protected $escapableControlCharactersPattern;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the singleton instance
|
||||||
|
*/
|
||||||
|
protected function init()
|
||||||
|
{
|
||||||
|
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string Regex pattern containing all escapable control characters
|
||||||
|
*/
|
||||||
|
protected function getEscapableControlCharactersPattern()
|
||||||
|
{
|
||||||
|
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
||||||
|
// some characters should not be escaped though: "\t", "\r" and "\n".
|
||||||
|
return '[\x00-\x08' .
|
||||||
|
// skipping "\t" (0x9) and "\n" (0xA)
|
||||||
|
'\x0B-\x0C' .
|
||||||
|
// skipping "\r" (0xD)
|
||||||
|
'\x0E-\x1F]';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Escapes the given string to make it compatible with XLSX
|
* Escapes the given string to make it compatible with XLSX
|
||||||
*
|
*
|
||||||
@@ -18,7 +47,17 @@ class ODS implements EscaperInterface
|
|||||||
*/
|
*/
|
||||||
public function escape($string)
|
public function escape($string)
|
||||||
{
|
{
|
||||||
return htmlspecialchars($string, ENT_QUOTES);
|
if (defined('ENT_DISALLOWED')) {
|
||||||
|
// 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
|
||||||
|
// Otherwise characters like a vertical tab "\v" will make the XML document unreadable by the XML processor
|
||||||
|
// @link https://github.com/box/spout/issues/329
|
||||||
|
return htmlspecialchars($string, ENT_QUOTES | ENT_DISALLOWED);
|
||||||
|
} else {
|
||||||
|
// We are on hhvm or any other engine that does not support ENT_DISALLOWED
|
||||||
|
$escapedString = htmlspecialchars($string, ENT_QUOTES);
|
||||||
|
$replacedString = preg_replace('/'.$this->escapableControlCharactersPattern.'/', '�', $escapedString);
|
||||||
|
return $replacedString;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Common\Escaper;
|
namespace Box\Spout\Common\Escaper;
|
||||||
|
|
||||||
|
use Box\Spout\Common\Singleton;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class XLSX
|
* Class XLSX
|
||||||
* Provides functions to escape and unescape data for XLSX files
|
* Provides functions to escape and unescape data for XLSX files
|
||||||
@@ -10,15 +12,25 @@ namespace Box\Spout\Common\Escaper;
|
|||||||
*/
|
*/
|
||||||
class XLSX implements EscaperInterface
|
class XLSX implements EscaperInterface
|
||||||
{
|
{
|
||||||
/** @var string[] Control characters to be escaped */
|
use Singleton;
|
||||||
|
|
||||||
|
/** @var string Regex pattern to detect control characters that need to be escaped */
|
||||||
|
protected $escapableControlCharactersPattern;
|
||||||
|
|
||||||
|
/** @var string[] Map containing control characters to be escaped (key) and their escaped value (value) */
|
||||||
protected $controlCharactersEscapingMap;
|
protected $controlCharactersEscapingMap;
|
||||||
|
|
||||||
|
/** @var string[] Map containing control characters to be escaped (value) and their escaped value (key) */
|
||||||
|
protected $controlCharactersEscapingReverseMap;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* Initializes the singleton instance
|
||||||
*/
|
*/
|
||||||
public function __construct()
|
protected function init()
|
||||||
{
|
{
|
||||||
|
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
|
||||||
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
|
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
|
||||||
|
$this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -49,6 +61,20 @@ class XLSX implements EscaperInterface
|
|||||||
return $unescapedString;
|
return $unescapedString;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string Regex pattern containing all escapable control characters
|
||||||
|
*/
|
||||||
|
protected function getEscapableControlCharactersPattern()
|
||||||
|
{
|
||||||
|
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
||||||
|
// some characters should not be escaped though: "\t", "\r" and "\n".
|
||||||
|
return '[\x00-\x08' .
|
||||||
|
// skipping "\t" (0x9) and "\n" (0xA)
|
||||||
|
'\x0B-\x0C' .
|
||||||
|
// skipping "\r" (0xD)
|
||||||
|
'\x0E-\x1F]';
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Builds the map containing control characters to be escaped
|
* Builds the map containing control characters to be escaped
|
||||||
* mapped to their escaped values.
|
* mapped to their escaped values.
|
||||||
@@ -62,14 +88,14 @@ class XLSX implements EscaperInterface
|
|||||||
protected function getControlCharactersEscapingMap()
|
protected function getControlCharactersEscapingMap()
|
||||||
{
|
{
|
||||||
$controlCharactersEscapingMap = [];
|
$controlCharactersEscapingMap = [];
|
||||||
$whitelistedControlCharacters = ["\t", "\r", "\n"];
|
|
||||||
|
|
||||||
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
||||||
for ($charValue = 0x0; $charValue <= 0x1F; $charValue++) {
|
for ($charValue = 0x00; $charValue <= 0x1F; $charValue++) {
|
||||||
if (!in_array(chr($charValue), $whitelistedControlCharacters)) {
|
$character = chr($charValue);
|
||||||
|
if (preg_match("/{$this->escapableControlCharactersPattern}/", $character)) {
|
||||||
$charHexValue = dechex($charValue);
|
$charHexValue = dechex($charValue);
|
||||||
$escapedChar = '_x' . sprintf('%04s' , strtoupper($charHexValue)) . '_';
|
$escapedChar = '_x' . sprintf('%04s' , strtoupper($charHexValue)) . '_';
|
||||||
$controlCharactersEscapingMap[$escapedChar] = chr($charValue);
|
$controlCharactersEscapingMap[$escapedChar] = $character;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +118,15 @@ class XLSX implements EscaperInterface
|
|||||||
protected function escapeControlCharacters($string)
|
protected function escapeControlCharacters($string)
|
||||||
{
|
{
|
||||||
$escapedString = $this->escapeEscapeCharacter($string);
|
$escapedString = $this->escapeEscapeCharacter($string);
|
||||||
return str_replace(array_values($this->controlCharactersEscapingMap), array_keys($this->controlCharactersEscapingMap), $escapedString);
|
|
||||||
|
// if no control characters
|
||||||
|
if (!preg_match("/{$this->escapableControlCharactersPattern}/", $escapedString)) {
|
||||||
|
return $escapedString;
|
||||||
|
}
|
||||||
|
|
||||||
|
return preg_replace_callback("/({$this->escapableControlCharactersPattern})/", function($matches) {
|
||||||
|
return $this->controlCharactersEscapingReverseMap[$matches[0]];
|
||||||
|
}, $escapedString);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -122,6 +156,7 @@ class XLSX implements EscaperInterface
|
|||||||
protected function unescapeControlCharacters($string)
|
protected function unescapeControlCharacters($string)
|
||||||
{
|
{
|
||||||
$unescapedString = $string;
|
$unescapedString = $string;
|
||||||
|
|
||||||
foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) {
|
foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) {
|
||||||
// only unescape characters that don't contain the escaped escape character for now
|
// only unescape characters that don't contain the escaped escape character for now
|
||||||
$unescapedString = preg_replace("/(?<!_x005F)($escapedCharValue)/", $charValue, $unescapedString);
|
$unescapedString = preg_replace("/(?<!_x005F)($escapedCharValue)/", $charValue, $unescapedString);
|
||||||
|
|||||||
@@ -59,7 +59,7 @@ class EncodingHelper
|
|||||||
{
|
{
|
||||||
$byteOffsetToSkipBom = 0;
|
$byteOffsetToSkipBom = 0;
|
||||||
|
|
||||||
if ($this->hasBom($filePointer, $encoding)) {
|
if ($this->hasBOM($filePointer, $encoding)) {
|
||||||
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
|
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
|
||||||
|
|
||||||
// we skip the N first bytes
|
// we skip the N first bytes
|
||||||
|
|||||||
@@ -13,15 +13,15 @@ use Box\Spout\Common\Exception\IOException;
|
|||||||
*/
|
*/
|
||||||
class FileSystemHelper
|
class FileSystemHelper
|
||||||
{
|
{
|
||||||
/** @var string Path of the base folder where all the I/O can occur */
|
/** @var string Real path of the base folder where all the I/O can occur */
|
||||||
protected $baseFolderPath;
|
protected $baseFolderRealPath;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
|
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
|
||||||
*/
|
*/
|
||||||
public function __construct($baseFolderPath)
|
public function __construct($baseFolderPath)
|
||||||
{
|
{
|
||||||
$this->baseFolderPath = $baseFolderPath;
|
$this->baseFolderRealPath = realpath($baseFolderPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -124,9 +124,10 @@ class FileSystemHelper
|
|||||||
*/
|
*/
|
||||||
protected function throwIfOperationNotInBaseFolder($operationFolderPath)
|
protected function throwIfOperationNotInBaseFolder($operationFolderPath)
|
||||||
{
|
{
|
||||||
$isInBaseFolder = (strpos($operationFolderPath, $this->baseFolderPath) === 0);
|
$operationFolderRealPath = realpath($operationFolderPath);
|
||||||
|
$isInBaseFolder = (strpos($operationFolderRealPath, $this->baseFolderRealPath) === 0);
|
||||||
if (!$isInBaseFolder) {
|
if (!$isInBaseFolder) {
|
||||||
throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderPath}");
|
throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderRealPath}");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -252,6 +252,19 @@ class GlobalFunctionsHelper
|
|||||||
header($string);
|
header($string);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wrapper around global function ob_end_clean()
|
||||||
|
* @see ob_end_clean()
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function ob_end_clean()
|
||||||
|
{
|
||||||
|
if (ob_get_length() > 0) {
|
||||||
|
ob_end_clean();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around global function iconv()
|
* Wrapper around global function iconv()
|
||||||
* @see iconv()
|
* @see iconv()
|
||||||
@@ -281,32 +294,14 @@ class GlobalFunctionsHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wrapper around global function stream_get_line()
|
* Wrapper around global function stream_get_wrappers()
|
||||||
* @see stream_get_line()
|
* @see stream_get_wrappers()
|
||||||
*
|
*
|
||||||
* @param resource $handle
|
|
||||||
* @param int $length
|
|
||||||
* @param string|void $ending
|
|
||||||
* @return string|bool
|
|
||||||
*/
|
|
||||||
public function stream_get_line($handle, $length, $ending = null)
|
|
||||||
{
|
|
||||||
return stream_get_line($handle, $length, $ending);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Wrapper around global function str_getcsv()
|
|
||||||
* @see str_getcsv()
|
|
||||||
*
|
|
||||||
* @param string $input
|
|
||||||
* @param string|void $delimiter
|
|
||||||
* @param string|void $enclosure
|
|
||||||
* @param string|void $escape
|
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function str_getcsv($input, $delimiter = null, $enclosure = null, $escape = null)
|
public function stream_get_wrappers()
|
||||||
{
|
{
|
||||||
return str_getcsv($input, $delimiter, $enclosure, $escape);
|
return stream_get_wrappers();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Singleton
|
||||||
|
* Defines a class as a singleton.
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Common
|
||||||
|
*/
|
||||||
|
trait Singleton
|
||||||
|
{
|
||||||
|
protected static $instance;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return static
|
||||||
|
*/
|
||||||
|
final public static function getInstance()
|
||||||
|
{
|
||||||
|
return isset(static::$instance)
|
||||||
|
? static::$instance
|
||||||
|
: static::$instance = new static;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Singleton constructor.
|
||||||
|
*/
|
||||||
|
final private function __construct()
|
||||||
|
{
|
||||||
|
$this->init();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initializes the singleton
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function init() {}
|
||||||
|
|
||||||
|
final private function __wakeup() {}
|
||||||
|
final private function __clone() {}
|
||||||
|
}
|
||||||
@@ -19,6 +19,23 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||||
protected $globalFunctionsHelper;
|
protected $globalFunctionsHelper;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\Common\ReaderOptions Reader's customized options */
|
||||||
|
protected $options;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reader's current options
|
||||||
|
*
|
||||||
|
* @return \Box\Spout\Reader\Common\ReaderOptions
|
||||||
|
*/
|
||||||
|
abstract protected function getOptions();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether stream wrappers are supported
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
abstract protected function doesSupportStreamWrapper();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the file at the given file path to make it ready to be read
|
* Opens the file at the given file path to make it ready to be read
|
||||||
*
|
*
|
||||||
@@ -32,7 +49,7 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
*
|
*
|
||||||
* @return \Iterator To iterate over sheets
|
* @return \Iterator To iterate over sheets
|
||||||
*/
|
*/
|
||||||
abstract public function getConcreteSheetIterator();
|
abstract protected function getConcreteSheetIterator();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Closes the reader. To be used after reading the file.
|
* Closes the reader. To be used after reading the file.
|
||||||
@@ -42,7 +59,7 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
abstract protected function closeReader();
|
abstract protected function closeReader();
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
* @return AbstractReader
|
* @return AbstractReader
|
||||||
*/
|
*/
|
||||||
public function setGlobalFunctionsHelper($globalFunctionsHelper)
|
public function setGlobalFunctionsHelper($globalFunctionsHelper)
|
||||||
@@ -51,6 +68,32 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
* @param bool $shouldFormatDates
|
||||||
|
* @return AbstractReader
|
||||||
|
*/
|
||||||
|
public function setShouldFormatDates($shouldFormatDates)
|
||||||
|
{
|
||||||
|
$this->getOptions()->setShouldFormatDates($shouldFormatDates);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether empty rows should be returned or skipped.
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
* @param bool $shouldPreserveEmptyRows
|
||||||
|
* @return AbstractReader
|
||||||
|
*/
|
||||||
|
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
|
||||||
|
{
|
||||||
|
$this->getOptions()->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Prepares the reader to read the given file. It also makes sure
|
* Prepares the reader to read the given file. It also makes sure
|
||||||
* that the file exists and is readable.
|
* that the file exists and is readable.
|
||||||
@@ -62,6 +105,10 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
*/
|
*/
|
||||||
public function open($filePath)
|
public function open($filePath)
|
||||||
{
|
{
|
||||||
|
if ($this->isStreamWrapper($filePath) && (!$this->doesSupportStreamWrapper() || !$this->isSupportedStreamWrapper($filePath))) {
|
||||||
|
throw new IOException("Could not open $filePath for reading! Stream wrapper used is not supported for this type of file.");
|
||||||
|
}
|
||||||
|
|
||||||
if (!$this->isPhpStream($filePath)) {
|
if (!$this->isPhpStream($filePath)) {
|
||||||
// we skip the checks if the provided file path points to a PHP stream
|
// we skip the checks if the provided file path points to a PHP stream
|
||||||
if (!$this->globalFunctionsHelper->file_exists($filePath)) {
|
if (!$this->globalFunctionsHelper->file_exists($filePath)) {
|
||||||
@@ -72,8 +119,7 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Need to use realpath to fix "Can't open file" on some Windows setup
|
$fileRealPath = $this->getFileRealPath($filePath);
|
||||||
$fileRealPath = realpath($filePath);
|
|
||||||
$this->openReader($fileRealPath);
|
$this->openReader($fileRealPath);
|
||||||
$this->isStreamOpened = true;
|
$this->isStreamOpened = true;
|
||||||
} catch (\Exception $exception) {
|
} catch (\Exception $exception) {
|
||||||
@@ -81,6 +127,67 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the real path of the given path.
|
||||||
|
* If the given path is a valid stream wrapper, returns the path unchanged.
|
||||||
|
*
|
||||||
|
* @param string $filePath
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected function getFileRealPath($filePath)
|
||||||
|
{
|
||||||
|
if ($this->isSupportedStreamWrapper($filePath)) {
|
||||||
|
return $filePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Need to use realpath to fix "Can't open file" on some Windows setup
|
||||||
|
return realpath($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the scheme of the custom stream wrapper, if the path indicates a stream wrapper is used.
|
||||||
|
* For example, php://temp => php, s3://path/to/file => s3...
|
||||||
|
*
|
||||||
|
* @param string $filePath Path of the file to be read
|
||||||
|
* @return string|null The stream wrapper scheme or NULL if not a stream wrapper
|
||||||
|
*/
|
||||||
|
protected function getStreamWrapperScheme($filePath)
|
||||||
|
{
|
||||||
|
$streamScheme = null;
|
||||||
|
if (preg_match('/^(\w+):\/\//', $filePath, $matches)) {
|
||||||
|
$streamScheme = $matches[1];
|
||||||
|
}
|
||||||
|
return $streamScheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given path is an unsupported stream wrapper
|
||||||
|
* (like local path, php://temp, mystream://foo/bar...).
|
||||||
|
*
|
||||||
|
* @param string $filePath Path of the file to be read
|
||||||
|
* @return bool Whether the given path is an unsupported stream wrapper
|
||||||
|
*/
|
||||||
|
protected function isStreamWrapper($filePath)
|
||||||
|
{
|
||||||
|
return ($this->getStreamWrapperScheme($filePath) !== null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Checks if the given path is an supported stream wrapper
|
||||||
|
* (like php://temp, mystream://foo/bar...).
|
||||||
|
* If the given path is a local path, returns true.
|
||||||
|
*
|
||||||
|
* @param string $filePath Path of the file to be read
|
||||||
|
* @return bool Whether the given path is an supported stream wrapper
|
||||||
|
*/
|
||||||
|
protected function isSupportedStreamWrapper($filePath)
|
||||||
|
{
|
||||||
|
$streamScheme = $this->getStreamWrapperScheme($filePath);
|
||||||
|
return ($streamScheme !== null) ?
|
||||||
|
in_array($streamScheme, $this->globalFunctionsHelper->stream_get_wrappers()) :
|
||||||
|
true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Checks if a path is a PHP stream (like php://output, php://memory, ...)
|
* Checks if a path is a PHP stream (like php://output, php://memory, ...)
|
||||||
*
|
*
|
||||||
@@ -89,7 +196,8 @@ abstract class AbstractReader implements ReaderInterface
|
|||||||
*/
|
*/
|
||||||
protected function isPhpStream($filePath)
|
protected function isPhpStream($filePath)
|
||||||
{
|
{
|
||||||
return (strpos($filePath, 'php://') === 0);
|
$streamScheme = $this->getStreamWrapperScheme($filePath);
|
||||||
|
return ($streamScheme === 'php');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ namespace Box\Spout\Reader\CSV;
|
|||||||
|
|
||||||
use Box\Spout\Reader\AbstractReader;
|
use Box\Spout\Reader\AbstractReader;
|
||||||
use Box\Spout\Common\Exception\IOException;
|
use Box\Spout\Common\Exception\IOException;
|
||||||
use Box\Spout\Common\Helper\EncodingHelper;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class Reader
|
* Class Reader
|
||||||
@@ -20,14 +19,21 @@ class Reader extends AbstractReader
|
|||||||
/** @var SheetIterator To iterator over the CSV unique "sheet" */
|
/** @var SheetIterator To iterator over the CSV unique "sheet" */
|
||||||
protected $sheetIterator;
|
protected $sheetIterator;
|
||||||
|
|
||||||
/** @var string Defines the character used to delimit fields (one character only) */
|
/** @var string Original value for the "auto_detect_line_endings" INI value */
|
||||||
protected $fieldDelimiter = ',';
|
protected $originalAutoDetectLineEndings;
|
||||||
|
|
||||||
/** @var string Defines the character used to enclose fields (one character only) */
|
/**
|
||||||
protected $fieldEnclosure = '"';
|
* Returns the reader's current options
|
||||||
|
*
|
||||||
/** @var string Encoding of the CSV file to be read */
|
* @return ReaderOptions
|
||||||
protected $encoding = EncodingHelper::ENCODING_UTF8;
|
*/
|
||||||
|
protected function getOptions()
|
||||||
|
{
|
||||||
|
if (!isset($this->options)) {
|
||||||
|
$this->options = new ReaderOptions();
|
||||||
|
}
|
||||||
|
return $this->options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the field delimiter for the CSV.
|
* Sets the field delimiter for the CSV.
|
||||||
@@ -38,7 +44,7 @@ class Reader extends AbstractReader
|
|||||||
*/
|
*/
|
||||||
public function setFieldDelimiter($fieldDelimiter)
|
public function setFieldDelimiter($fieldDelimiter)
|
||||||
{
|
{
|
||||||
$this->fieldDelimiter = $fieldDelimiter;
|
$this->getOptions()->setFieldDelimiter($fieldDelimiter);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,7 +57,7 @@ class Reader extends AbstractReader
|
|||||||
*/
|
*/
|
||||||
public function setFieldEnclosure($fieldEnclosure)
|
public function setFieldEnclosure($fieldEnclosure)
|
||||||
{
|
{
|
||||||
$this->fieldEnclosure = $fieldEnclosure;
|
$this->getOptions()->setFieldEnclosure($fieldEnclosure);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -64,10 +70,33 @@ class Reader extends AbstractReader
|
|||||||
*/
|
*/
|
||||||
public function setEncoding($encoding)
|
public function setEncoding($encoding)
|
||||||
{
|
{
|
||||||
$this->encoding = $encoding;
|
$this->getOptions()->setEncoding($encoding);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the EOL for the CSV.
|
||||||
|
* Needs to be called before opening the reader.
|
||||||
|
*
|
||||||
|
* @param string $endOfLineCharacter used to properly get lines from the CSV file.
|
||||||
|
* @return Reader
|
||||||
|
*/
|
||||||
|
public function setEndOfLineCharacter($endOfLineCharacter)
|
||||||
|
{
|
||||||
|
$this->getOptions()->setEndOfLineCharacter($endOfLineCharacter);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether stream wrappers are supported
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function doesSupportStreamWrapper()
|
||||||
|
{
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the file at the given path to make it ready to be read.
|
* Opens the file at the given path to make it ready to be read.
|
||||||
* If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
|
* If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
|
||||||
@@ -78,6 +107,9 @@ class Reader extends AbstractReader
|
|||||||
*/
|
*/
|
||||||
protected function openReader($filePath)
|
protected function openReader($filePath)
|
||||||
{
|
{
|
||||||
|
$this->originalAutoDetectLineEndings = ini_get('auto_detect_line_endings');
|
||||||
|
ini_set('auto_detect_line_endings', '1');
|
||||||
|
|
||||||
$this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
|
$this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
|
||||||
if (!$this->filePointer) {
|
if (!$this->filePointer) {
|
||||||
throw new IOException("Could not open file $filePath for reading.");
|
throw new IOException("Could not open file $filePath for reading.");
|
||||||
@@ -85,9 +117,7 @@ class Reader extends AbstractReader
|
|||||||
|
|
||||||
$this->sheetIterator = new SheetIterator(
|
$this->sheetIterator = new SheetIterator(
|
||||||
$this->filePointer,
|
$this->filePointer,
|
||||||
$this->fieldDelimiter,
|
$this->getOptions(),
|
||||||
$this->fieldEnclosure,
|
|
||||||
$this->encoding,
|
|
||||||
$this->globalFunctionsHelper
|
$this->globalFunctionsHelper
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -97,7 +127,7 @@ class Reader extends AbstractReader
|
|||||||
*
|
*
|
||||||
* @return SheetIterator To iterate over sheets
|
* @return SheetIterator To iterate over sheets
|
||||||
*/
|
*/
|
||||||
public function getConcreteSheetIterator()
|
protected function getConcreteSheetIterator()
|
||||||
{
|
{
|
||||||
return $this->sheetIterator;
|
return $this->sheetIterator;
|
||||||
}
|
}
|
||||||
@@ -113,5 +143,7 @@ class Reader extends AbstractReader
|
|||||||
if ($this->filePointer) {
|
if ($this->filePointer) {
|
||||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ini_set('auto_detect_line_endings', $this->originalAutoDetectLineEndings);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\CSV;
|
||||||
|
|
||||||
|
use Box\Spout\Common\Helper\EncodingHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ReaderOptions
|
||||||
|
* This class is used to customize the reader's behavior
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\CSV
|
||||||
|
*/
|
||||||
|
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
|
||||||
|
{
|
||||||
|
/** @var string Defines the character used to delimit fields (one character only) */
|
||||||
|
protected $fieldDelimiter = ',';
|
||||||
|
|
||||||
|
/** @var string Defines the character used to enclose fields (one character only) */
|
||||||
|
protected $fieldEnclosure = '"';
|
||||||
|
|
||||||
|
/** @var string Encoding of the CSV file to be read */
|
||||||
|
protected $encoding = EncodingHelper::ENCODING_UTF8;
|
||||||
|
|
||||||
|
/** @var string Defines the End of line */
|
||||||
|
protected $endOfLineCharacter = "\n";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getFieldDelimiter()
|
||||||
|
{
|
||||||
|
return $this->fieldDelimiter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the field delimiter for the CSV.
|
||||||
|
* Needs to be called before opening the reader.
|
||||||
|
*
|
||||||
|
* @param string $fieldDelimiter Character that delimits fields
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setFieldDelimiter($fieldDelimiter)
|
||||||
|
{
|
||||||
|
$this->fieldDelimiter = $fieldDelimiter;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getFieldEnclosure()
|
||||||
|
{
|
||||||
|
return $this->fieldEnclosure;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the field enclosure for the CSV.
|
||||||
|
* Needs to be called before opening the reader.
|
||||||
|
*
|
||||||
|
* @param string $fieldEnclosure Character that enclose fields
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setFieldEnclosure($fieldEnclosure)
|
||||||
|
{
|
||||||
|
$this->fieldEnclosure = $fieldEnclosure;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getEncoding()
|
||||||
|
{
|
||||||
|
return $this->encoding;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the encoding of the CSV file to be read.
|
||||||
|
* Needs to be called before opening the reader.
|
||||||
|
*
|
||||||
|
* @param string $encoding Encoding of the CSV file to be read
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setEncoding($encoding)
|
||||||
|
{
|
||||||
|
$this->encoding = $encoding;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string EOL for the CSV
|
||||||
|
*/
|
||||||
|
public function getEndOfLineCharacter()
|
||||||
|
{
|
||||||
|
return $this->endOfLineCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the EOL for the CSV.
|
||||||
|
* Needs to be called before opening the reader.
|
||||||
|
*
|
||||||
|
* @param string $endOfLineCharacter used to properly get lines from the CSV file.
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setEndOfLineCharacter($endOfLineCharacter)
|
||||||
|
{
|
||||||
|
$this->endOfLineCharacter = $endOfLineCharacter;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,6 +13,12 @@ use Box\Spout\Common\Helper\EncodingHelper;
|
|||||||
*/
|
*/
|
||||||
class RowIterator implements IteratorInterface
|
class RowIterator implements IteratorInterface
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* If no value is given to fgetcsv(), it defaults to 8192 (which may be too low).
|
||||||
|
* Alignement with other functions like fgets() is discussed here: https://bugs.php.net/bug.php?id=48421
|
||||||
|
*/
|
||||||
|
const MAX_READ_BYTES_PER_LINE = 32768;
|
||||||
|
|
||||||
/** @var resource Pointer to the CSV file to read */
|
/** @var resource Pointer to the CSV file to read */
|
||||||
protected $filePointer;
|
protected $filePointer;
|
||||||
|
|
||||||
@@ -34,6 +40,12 @@ class RowIterator implements IteratorInterface
|
|||||||
/** @var string Encoding of the CSV file to be read */
|
/** @var string Encoding of the CSV file to be read */
|
||||||
protected $encoding;
|
protected $encoding;
|
||||||
|
|
||||||
|
/** @var string End of line delimiter, given by the user as input. */
|
||||||
|
protected $inputEOLDelimiter;
|
||||||
|
|
||||||
|
/** @var bool Whether empty rows should be returned or skipped */
|
||||||
|
protected $shouldPreserveEmptyRows;
|
||||||
|
|
||||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||||
protected $globalFunctionsHelper;
|
protected $globalFunctionsHelper;
|
||||||
|
|
||||||
@@ -45,17 +57,17 @@ class RowIterator implements IteratorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param resource $filePointer Pointer to the CSV file to read
|
* @param resource $filePointer Pointer to the CSV file to read
|
||||||
* @param string $fieldDelimiter Character that delimits fields
|
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||||
* @param string $fieldEnclosure Character that enclose fields
|
|
||||||
* @param string $encoding Encoding of the CSV file to be read
|
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
*/
|
*/
|
||||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||||
{
|
{
|
||||||
$this->filePointer = $filePointer;
|
$this->filePointer = $filePointer;
|
||||||
$this->fieldDelimiter = $fieldDelimiter;
|
$this->fieldDelimiter = $options->getFieldDelimiter();
|
||||||
$this->fieldEnclosure = $fieldEnclosure;
|
$this->fieldEnclosure = $options->getFieldEnclosure();
|
||||||
$this->encoding = $encoding;
|
$this->encoding = $options->getEncoding();
|
||||||
|
$this->inputEOLDelimiter = $options->getEndOfLineCharacter();
|
||||||
|
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
|
||||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||||
|
|
||||||
$this->encodingHelper = new EncodingHelper($globalFunctionsHelper);
|
$this->encodingHelper = new EncodingHelper($globalFunctionsHelper);
|
||||||
@@ -95,7 +107,7 @@ class RowIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -103,7 +115,7 @@ class RowIterator implements IteratorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move forward to next element. Empty rows are skipped.
|
* Move forward to next element. Reads data for the next unprocessed row.
|
||||||
* @link http://php.net/manual/en/iterator.next.php
|
* @link http://php.net/manual/en/iterator.next.php
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
@@ -113,48 +125,82 @@ class RowIterator implements IteratorInterface
|
|||||||
{
|
{
|
||||||
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||||
|
|
||||||
if ($this->hasReachedEndOfFile) {
|
if (!$this->hasReachedEndOfFile) {
|
||||||
return;
|
$this->readDataForNextRow();
|
||||||
}
|
|
||||||
|
|
||||||
do {
|
|
||||||
$lineData = false;
|
|
||||||
$utf8EncodedLineData = $this->getNextUTF8EncodedLine();
|
|
||||||
if ($utf8EncodedLineData !== false) {
|
|
||||||
$lineData = $this->globalFunctionsHelper->str_getcsv($utf8EncodedLineData, $this->fieldDelimiter, $this->fieldEnclosure);
|
|
||||||
}
|
|
||||||
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
|
||||||
} while (($lineData === false && !$hasNowReachedEndOfFile) || $this->isEmptyLine($lineData));
|
|
||||||
|
|
||||||
if ($lineData !== false) {
|
|
||||||
$this->rowDataBuffer = $lineData;
|
|
||||||
$this->numReadRows++;
|
|
||||||
} else {
|
|
||||||
// If we reach this point, it means end of file was reached.
|
|
||||||
// This happens when the last lines are empty lines.
|
|
||||||
$this->hasReachedEndOfFile = $hasNowReachedEndOfFile;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the next line, converted if necessary to UTF-8.
|
* @return void
|
||||||
* Neither fgets nor fgetcsv don't work with non UTF-8 data... so we need to do some things manually.
|
|
||||||
*
|
|
||||||
* @return string|false The next line for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
|
|
||||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||||
*/
|
*/
|
||||||
protected function getNextUTF8EncodedLine()
|
protected function readDataForNextRow()
|
||||||
{
|
{
|
||||||
// Read until the EOL delimiter or EOF is reached. The delimiter's encoding needs to match the CSV's encoding.
|
do {
|
||||||
$encodedEOLDelimiter = $this->getEncodedEOLDelimiter();
|
$rowData = $this->getNextUTF8EncodedRow();
|
||||||
$encodedLineData = $this->globalFunctionsHelper->stream_get_line($this->filePointer, 0, $encodedEOLDelimiter);
|
} while ($this->shouldReadNextRow($rowData));
|
||||||
|
|
||||||
// If the line could have been read, it can be converted to UTF-8
|
if ($rowData !== false) {
|
||||||
$utf8EncodedLineData = ($encodedLineData !== false) ?
|
// str_replace will replace NULL values by empty strings
|
||||||
$this->encodingHelper->attemptConversionToUTF8($encodedLineData, $this->encoding) :
|
$this->rowDataBuffer = str_replace(null, null, $rowData);
|
||||||
false;
|
$this->numReadRows++;
|
||||||
|
} else {
|
||||||
|
// If we reach this point, it means end of file was reached.
|
||||||
|
// This happens when the last lines are empty lines.
|
||||||
|
$this->hasReachedEndOfFile = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return $utf8EncodedLineData;
|
/**
|
||||||
|
* @param array|bool $currentRowData
|
||||||
|
* @return bool Whether the data for the current row can be returned or if we need to keep reading
|
||||||
|
*/
|
||||||
|
protected function shouldReadNextRow($currentRowData)
|
||||||
|
{
|
||||||
|
$hasSuccessfullyFetchedRowData = ($currentRowData !== false);
|
||||||
|
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||||
|
$isEmptyLine = $this->isEmptyLine($currentRowData);
|
||||||
|
|
||||||
|
return (
|
||||||
|
(!$hasSuccessfullyFetchedRowData && !$hasNowReachedEndOfFile) ||
|
||||||
|
(!$this->shouldPreserveEmptyRows && $isEmptyLine)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the next row, converted if necessary to UTF-8.
|
||||||
|
* As fgetcsv() does not manage correctly encoding for non UTF-8 data,
|
||||||
|
* we remove manually whitespace with ltrim or rtrim (depending on the order of the bytes)
|
||||||
|
*
|
||||||
|
* @return array|false The row for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
|
||||||
|
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||||
|
*/
|
||||||
|
protected function getNextUTF8EncodedRow()
|
||||||
|
{
|
||||||
|
$encodedRowData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $this->fieldDelimiter, $this->fieldEnclosure);
|
||||||
|
if ($encodedRowData === false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($encodedRowData as $cellIndex => $cellValue) {
|
||||||
|
switch($this->encoding) {
|
||||||
|
case EncodingHelper::ENCODING_UTF16_LE:
|
||||||
|
case EncodingHelper::ENCODING_UTF32_LE:
|
||||||
|
// remove whitespace from the beginning of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
|
||||||
|
$cellValue = ltrim($cellValue);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case EncodingHelper::ENCODING_UTF16_BE:
|
||||||
|
case EncodingHelper::ENCODING_UTF32_BE:
|
||||||
|
// remove whitespace from the end of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
|
||||||
|
$cellValue = rtrim($cellValue);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
$encodedRowData[$cellIndex] = $this->encodingHelper->attemptConversionToUTF8($cellValue, $this->encoding);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $encodedRowData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -166,14 +212,14 @@ class RowIterator implements IteratorInterface
|
|||||||
protected function getEncodedEOLDelimiter()
|
protected function getEncodedEOLDelimiter()
|
||||||
{
|
{
|
||||||
if (!isset($this->encodedEOLDelimiter)) {
|
if (!isset($this->encodedEOLDelimiter)) {
|
||||||
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8("\n", $this->encoding);
|
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8($this->inputEOLDelimiter, $this->encoding);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->encodedEOLDelimiter;
|
return $this->encodedEOLDelimiter;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $lineData Array containing the cells value for the line
|
* @param array|bool $lineData Array containing the cells value for the line
|
||||||
* @return bool Whether the given line is empty
|
* @return bool Whether the given line is empty
|
||||||
*/
|
*/
|
||||||
protected function isEmptyLine($lineData)
|
protected function isEmptyLine($lineData)
|
||||||
|
|||||||
@@ -11,24 +11,22 @@ use Box\Spout\Reader\SheetInterface;
|
|||||||
*/
|
*/
|
||||||
class Sheet implements SheetInterface
|
class Sheet implements SheetInterface
|
||||||
{
|
{
|
||||||
/** @var RowIterator To iterate over the CSV's rows */
|
/** @var \Box\Spout\Reader\CSV\RowIterator To iterate over the CSV's rows */
|
||||||
protected $rowIterator;
|
protected $rowIterator;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param resource $filePointer Pointer to the CSV file to read
|
* @param resource $filePointer Pointer to the CSV file to read
|
||||||
* @param string $fieldDelimiter Character that delimits fields
|
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||||
* @param string $fieldEnclosure Character that enclose fields
|
|
||||||
* @param string $encoding Encoding of the CSV file to be read
|
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
*/
|
*/
|
||||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||||
{
|
{
|
||||||
$this->rowIterator = new RowIterator($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
|
$this->rowIterator = new RowIterator($filePointer, $options, $globalFunctionsHelper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return RowIterator
|
* @return \Box\Spout\Reader\CSV\RowIterator
|
||||||
*/
|
*/
|
||||||
public function getRowIterator()
|
public function getRowIterator()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use Box\Spout\Reader\IteratorInterface;
|
|||||||
*/
|
*/
|
||||||
class SheetIterator implements IteratorInterface
|
class SheetIterator implements IteratorInterface
|
||||||
{
|
{
|
||||||
/** @var Sheet The CSV unique "sheet" */
|
/** @var \Box\Spout\Reader\CSV\Sheet The CSV unique "sheet" */
|
||||||
protected $sheet;
|
protected $sheet;
|
||||||
|
|
||||||
/** @var bool Whether the unique "sheet" has already been read */
|
/** @var bool Whether the unique "sheet" has already been read */
|
||||||
@@ -20,14 +20,12 @@ class SheetIterator implements IteratorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param resource $filePointer
|
* @param resource $filePointer
|
||||||
* @param string $fieldDelimiter Character that delimits fields
|
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||||
* @param string $fieldEnclosure Character that enclose fields
|
|
||||||
* @param string $encoding Encoding of the CSV file to be read
|
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
*/
|
*/
|
||||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||||
{
|
{
|
||||||
$this->sheet = new Sheet($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
|
$this->sheet = new Sheet($filePointer, $options, $globalFunctionsHelper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -45,7 +43,7 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -67,7 +65,7 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Return the current element
|
* Return the current element
|
||||||
* @link http://php.net/manual/en/iterator.current.php
|
* @link http://php.net/manual/en/iterator.current.php
|
||||||
*
|
*
|
||||||
* @return Sheet
|
* @return \Box\Spout\Reader\CSV\Sheet
|
||||||
*/
|
*/
|
||||||
public function current()
|
public function current()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\Common;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ReaderOptions
|
||||||
|
* Readers' common options
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\Common
|
||||||
|
*/
|
||||||
|
class ReaderOptions
|
||||||
|
{
|
||||||
|
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
|
||||||
|
protected $shouldFormatDates = false;
|
||||||
|
|
||||||
|
/** @var bool Whether empty rows should be returned or skipped */
|
||||||
|
protected $shouldPreserveEmptyRows = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool Whether date/time values should be returned as PHP objects or be formatted as strings.
|
||||||
|
*/
|
||||||
|
public function shouldFormatDates()
|
||||||
|
{
|
||||||
|
return $this->shouldFormatDates;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
|
||||||
|
*
|
||||||
|
* @param bool $shouldFormatDates
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setShouldFormatDates($shouldFormatDates)
|
||||||
|
{
|
||||||
|
$this->shouldFormatDates = $shouldFormatDates;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool Whether empty rows should be returned or skipped.
|
||||||
|
*/
|
||||||
|
public function shouldPreserveEmptyRows()
|
||||||
|
{
|
||||||
|
return $this->shouldPreserveEmptyRows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets whether empty rows should be returned or skipped.
|
||||||
|
*
|
||||||
|
* @param bool $shouldPreserveEmptyRows
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
|
||||||
|
{
|
||||||
|
$this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,152 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\Common;
|
||||||
|
|
||||||
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class XMLProcessor
|
||||||
|
* Helps process XML files
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\Common
|
||||||
|
*/
|
||||||
|
class XMLProcessor
|
||||||
|
{
|
||||||
|
/* Node types */
|
||||||
|
const NODE_TYPE_START = XMLReader::ELEMENT;
|
||||||
|
const NODE_TYPE_END = XMLReader::END_ELEMENT;
|
||||||
|
|
||||||
|
/* Keys associated to reflection attributes to invoke a callback */
|
||||||
|
const CALLBACK_REFLECTION_METHOD = 'reflectionMethod';
|
||||||
|
const CALLBACK_REFLECTION_OBJECT = 'reflectionObject';
|
||||||
|
|
||||||
|
/* Values returned by the callbacks to indicate what the processor should do next */
|
||||||
|
const PROCESSING_CONTINUE = 1;
|
||||||
|
const PROCESSING_STOP = 2;
|
||||||
|
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||||
|
protected $xmlReader;
|
||||||
|
|
||||||
|
/** @var array Registered callbacks */
|
||||||
|
private $callbacks = [];
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object
|
||||||
|
*/
|
||||||
|
public function __construct($xmlReader)
|
||||||
|
{
|
||||||
|
$this->xmlReader = $xmlReader;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $nodeName A callback may be triggered when a node with this name is read
|
||||||
|
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||||
|
* @param callable $callback Callback to execute when the read node has the given name and type
|
||||||
|
* @return XMLProcessor
|
||||||
|
*/
|
||||||
|
public function registerCallback($nodeName, $nodeType, $callback)
|
||||||
|
{
|
||||||
|
$callbackKey = $this->getCallbackKey($nodeName, $nodeType);
|
||||||
|
$this->callbacks[$callbackKey] = $this->getInvokableCallbackData($callback);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $nodeName Name of the node
|
||||||
|
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||||
|
* @return string Key used to store the associated callback
|
||||||
|
*/
|
||||||
|
private function getCallbackKey($nodeName, $nodeType)
|
||||||
|
{
|
||||||
|
return "$nodeName$nodeType";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Because the callback can be a "protected" function, we don't want to use call_user_func() directly
|
||||||
|
* but instead invoke the callback using Reflection. This allows the invocation of "protected" functions.
|
||||||
|
* Since some functions can be called a lot, we pre-process the callback to only return the elements that
|
||||||
|
* will be needed to invoke the callback later.
|
||||||
|
*
|
||||||
|
* @param callable $callback Array reference to a callback: [OBJECT, METHOD_NAME]
|
||||||
|
* @return array Associative array containing the elements needed to invoke the callback using Reflection
|
||||||
|
*/
|
||||||
|
private function getInvokableCallbackData($callback)
|
||||||
|
{
|
||||||
|
$callbackObject = $callback[0];
|
||||||
|
$callbackMethodName = $callback[1];
|
||||||
|
$reflectionMethod = new \ReflectionMethod(get_class($callbackObject), $callbackMethodName);
|
||||||
|
$reflectionMethod->setAccessible(true);
|
||||||
|
|
||||||
|
return [
|
||||||
|
self::CALLBACK_REFLECTION_METHOD => $reflectionMethod,
|
||||||
|
self::CALLBACK_REFLECTION_OBJECT => $callbackObject,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resumes the reading of the XML file where it was left off.
|
||||||
|
* Stops whenever a callback indicates that reading should stop or at the end of the file.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
|
||||||
|
*/
|
||||||
|
public function readUntilStopped()
|
||||||
|
{
|
||||||
|
while ($this->xmlReader->read()) {
|
||||||
|
$nodeType = $this->xmlReader->nodeType;
|
||||||
|
$nodeNamePossiblyWithPrefix = $this->xmlReader->name;
|
||||||
|
$nodeNameWithoutPrefix = $this->xmlReader->localName;
|
||||||
|
|
||||||
|
$callbackData = $this->getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType);
|
||||||
|
|
||||||
|
if ($callbackData !== null) {
|
||||||
|
$callbackResponse = $this->invokeCallback($callbackData, [$this->xmlReader]);
|
||||||
|
|
||||||
|
if ($callbackResponse === self::PROCESSING_STOP) {
|
||||||
|
// stop reading
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $nodeNamePossiblyWithPrefix Name of the node, possibly prefixed
|
||||||
|
* @param string $nodeNameWithoutPrefix Name of the same node, un-prefixed
|
||||||
|
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||||
|
* @return array|null Callback data to be used for execution when a node of the given name/type is read or NULL if none found
|
||||||
|
*/
|
||||||
|
private function getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType)
|
||||||
|
{
|
||||||
|
// With prefixed nodes, we should match if (by order of preference):
|
||||||
|
// 1. the callback was registered with the prefixed node name (e.g. "x:worksheet")
|
||||||
|
// 2. the callback was registered with the un-prefixed node name (e.g. "worksheet")
|
||||||
|
$callbackKeyForPossiblyPrefixedName = $this->getCallbackKey($nodeNamePossiblyWithPrefix, $nodeType);
|
||||||
|
$callbackKeyForUnPrefixedName = $this->getCallbackKey($nodeNameWithoutPrefix, $nodeType);
|
||||||
|
$hasPrefix = ($nodeNamePossiblyWithPrefix !== $nodeNameWithoutPrefix);
|
||||||
|
|
||||||
|
$callbackKeyToUse = $callbackKeyForUnPrefixedName;
|
||||||
|
if ($hasPrefix && isset($this->callbacks[$callbackKeyForPossiblyPrefixedName])) {
|
||||||
|
$callbackKeyToUse = $callbackKeyForPossiblyPrefixedName;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
return isset($this->callbacks[$callbackKeyToUse]) ? $this->callbacks[$callbackKeyToUse] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $callbackData Associative array containing data to invoke the callback using Reflection
|
||||||
|
* @param array $args Arguments to pass to the callback
|
||||||
|
* @return int Callback response
|
||||||
|
*/
|
||||||
|
private function invokeCallback($callbackData, $args)
|
||||||
|
{
|
||||||
|
$reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD];
|
||||||
|
$callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT];
|
||||||
|
|
||||||
|
return $reflectionMethod->invokeArgs($callbackObject, $args);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,6 +23,8 @@ class CellValueFormatter
|
|||||||
/** Definition of XML nodes names used to parse data */
|
/** Definition of XML nodes names used to parse data */
|
||||||
const XML_NODE_P = 'p';
|
const XML_NODE_P = 'p';
|
||||||
const XML_NODE_S = 'text:s';
|
const XML_NODE_S = 'text:s';
|
||||||
|
const XML_NODE_A = 'text:a';
|
||||||
|
const XML_NODE_SPAN = 'text:span';
|
||||||
|
|
||||||
/** Definition of XML attribute used to parse data */
|
/** Definition of XML attribute used to parse data */
|
||||||
const XML_ATTRIBUTE_TYPE = 'office:value-type';
|
const XML_ATTRIBUTE_TYPE = 'office:value-type';
|
||||||
@@ -33,21 +35,25 @@ class CellValueFormatter
|
|||||||
const XML_ATTRIBUTE_CURRENCY = 'office:currency';
|
const XML_ATTRIBUTE_CURRENCY = 'office:currency';
|
||||||
const XML_ATTRIBUTE_C = 'text:c';
|
const XML_ATTRIBUTE_C = 'text:c';
|
||||||
|
|
||||||
|
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
|
||||||
|
protected $shouldFormatDates;
|
||||||
|
|
||||||
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
|
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
|
||||||
protected $escaper;
|
protected $escaper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
|
||||||
*/
|
*/
|
||||||
public function __construct()
|
public function __construct($shouldFormatDates)
|
||||||
{
|
{
|
||||||
|
$this->shouldFormatDates = $shouldFormatDates;
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->escaper = new \Box\Spout\Common\Escaper\ODS();
|
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
||||||
* @TODO Add other types !!
|
|
||||||
* @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
|
* @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
|
||||||
*
|
*
|
||||||
* @param \DOMNode $node
|
* @param \DOMNode $node
|
||||||
@@ -99,6 +105,8 @@ class CellValueFormatter
|
|||||||
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
|
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
|
||||||
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
|
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
|
||||||
$currentPValue .= str_repeat(' ', $numSpaces);
|
$currentPValue .= str_repeat(' ', $numSpaces);
|
||||||
|
} else if ($childNode->nodeName === self::XML_NODE_A || $childNode->nodeName === self::XML_NODE_SPAN) {
|
||||||
|
$currentPValue .= $childNode->nodeValue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,7 +127,9 @@ class CellValueFormatter
|
|||||||
protected function formatFloatCellValue($node)
|
protected function formatFloatCellValue($node)
|
||||||
{
|
{
|
||||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
|
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
|
||||||
$cellValue = is_int($nodeValue) ? intval($nodeValue) : floatval($nodeValue);
|
$nodeIntValue = intval($nodeValue);
|
||||||
|
// The "==" is intentionally not a "===" because only the value matters, not the type
|
||||||
|
$cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
|
||||||
return $cellValue;
|
return $cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,10 +151,21 @@ class CellValueFormatter
|
|||||||
* Returns the cell Date value from the given node.
|
* Returns the cell Date value from the given node.
|
||||||
*
|
*
|
||||||
* @param \DOMNode $node
|
* @param \DOMNode $node
|
||||||
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
|
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||||
*/
|
*/
|
||||||
protected function formatDateCellValue($node)
|
protected function formatDateCellValue($node)
|
||||||
{
|
{
|
||||||
|
// The XML node looks like this:
|
||||||
|
// <table:table-cell calcext:value-type="date" office:date-value="2016-05-19T16:39:00" office:value-type="date">
|
||||||
|
// <text:p>05/19/16 04:39 PM</text:p>
|
||||||
|
// </table:table-cell>
|
||||||
|
|
||||||
|
if ($this->shouldFormatDates) {
|
||||||
|
// The date is already formatted in the "p" tag
|
||||||
|
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
|
||||||
|
return $nodeWithValueAlreadyFormatted->nodeValue;
|
||||||
|
} else {
|
||||||
|
// otherwise, get it from the "date-value" attribute
|
||||||
try {
|
try {
|
||||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
|
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
|
||||||
return new \DateTime($nodeValue);
|
return new \DateTime($nodeValue);
|
||||||
@@ -152,15 +173,27 @@ class CellValueFormatter
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the cell Time value from the given node.
|
* Returns the cell Time value from the given node.
|
||||||
*
|
*
|
||||||
* @param \DOMNode $node
|
* @param \DOMNode $node
|
||||||
* @return \DateInterval|null The value associated with the cell or NULL if invalid time value
|
* @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
|
||||||
*/
|
*/
|
||||||
protected function formatTimeCellValue($node)
|
protected function formatTimeCellValue($node)
|
||||||
{
|
{
|
||||||
|
// The XML node looks like this:
|
||||||
|
// <table:table-cell calcext:value-type="time" office:time-value="PT13H24M00S" office:value-type="time">
|
||||||
|
// <text:p>01:24:00 PM</text:p>
|
||||||
|
// </table:table-cell>
|
||||||
|
|
||||||
|
if ($this->shouldFormatDates) {
|
||||||
|
// The date is already formatted in the "p" tag
|
||||||
|
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
|
||||||
|
return $nodeWithValueAlreadyFormatted->nodeValue;
|
||||||
|
} else {
|
||||||
|
// otherwise, get it from the "time-value" attribute
|
||||||
try {
|
try {
|
||||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
|
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
|
||||||
return new \DateInterval($nodeValue);
|
return new \DateInterval($nodeValue);
|
||||||
@@ -168,6 +201,7 @@ class CellValueFormatter
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the cell Currency value from the given node.
|
* Returns the cell Currency value from the given node.
|
||||||
|
|||||||
@@ -19,6 +19,29 @@ class Reader extends AbstractReader
|
|||||||
/** @var SheetIterator To iterator over the ODS sheets */
|
/** @var SheetIterator To iterator over the ODS sheets */
|
||||||
protected $sheetIterator;
|
protected $sheetIterator;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reader's current options
|
||||||
|
*
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
protected function getOptions()
|
||||||
|
{
|
||||||
|
if (!isset($this->options)) {
|
||||||
|
$this->options = new ReaderOptions();
|
||||||
|
}
|
||||||
|
return $this->options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether stream wrappers are supported
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function doesSupportStreamWrapper()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the file at the given file path to make it ready to be read.
|
* Opens the file at the given file path to make it ready to be read.
|
||||||
*
|
*
|
||||||
@@ -32,7 +55,7 @@ class Reader extends AbstractReader
|
|||||||
$this->zip = new \ZipArchive();
|
$this->zip = new \ZipArchive();
|
||||||
|
|
||||||
if ($this->zip->open($filePath) === true) {
|
if ($this->zip->open($filePath) === true) {
|
||||||
$this->sheetIterator = new SheetIterator($filePath);
|
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions());
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("Could not open $filePath for reading.");
|
throw new IOException("Could not open $filePath for reading.");
|
||||||
}
|
}
|
||||||
@@ -43,7 +66,7 @@ class Reader extends AbstractReader
|
|||||||
*
|
*
|
||||||
* @return SheetIterator To iterate over sheets
|
* @return SheetIterator To iterate over sheets
|
||||||
*/
|
*/
|
||||||
public function getConcreteSheetIterator()
|
protected function getConcreteSheetIterator()
|
||||||
{
|
{
|
||||||
return $this->sheetIterator;
|
return $this->sheetIterator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,14 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\ODS;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ReaderOptions
|
||||||
|
* This class is used to customize the reader's behavior
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\ODS
|
||||||
|
*/
|
||||||
|
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
|
||||||
|
{
|
||||||
|
// No extra options
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ use Box\Spout\Reader\Exception\XMLProcessingException;
|
|||||||
use Box\Spout\Reader\IteratorInterface;
|
use Box\Spout\Reader\IteratorInterface;
|
||||||
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
|
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
|
||||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
|
use Box\Spout\Reader\Common\XMLProcessor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class RowIterator
|
* Class RowIterator
|
||||||
@@ -20,21 +21,29 @@ class RowIterator implements IteratorInterface
|
|||||||
const XML_NODE_TABLE = 'table:table';
|
const XML_NODE_TABLE = 'table:table';
|
||||||
const XML_NODE_ROW = 'table:table-row';
|
const XML_NODE_ROW = 'table:table-row';
|
||||||
const XML_NODE_CELL = 'table:table-cell';
|
const XML_NODE_CELL = 'table:table-cell';
|
||||||
|
const MAX_COLUMNS_EXCEL = 16384;
|
||||||
|
|
||||||
/** Definition of XML attribute used to parse data */
|
/** Definition of XML attribute used to parse data */
|
||||||
|
const XML_ATTRIBUTE_NUM_ROWS_REPEATED = 'table:number-rows-repeated';
|
||||||
const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated';
|
const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated';
|
||||||
|
|
||||||
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||||
protected $xmlReader;
|
protected $xmlReader;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
|
||||||
|
protected $xmlProcessor;
|
||||||
|
|
||||||
|
/** @var bool Whether empty rows should be returned or skipped */
|
||||||
|
protected $shouldPreserveEmptyRows;
|
||||||
|
|
||||||
/** @var Helper\CellValueFormatter Helper to format cell values */
|
/** @var Helper\CellValueFormatter Helper to format cell values */
|
||||||
protected $cellValueFormatter;
|
protected $cellValueFormatter;
|
||||||
|
|
||||||
/** @var bool Whether the iterator has already been rewound once */
|
/** @var bool Whether the iterator has already been rewound once */
|
||||||
protected $hasAlreadyBeenRewound = false;
|
protected $hasAlreadyBeenRewound = false;
|
||||||
|
|
||||||
/** @var int Number of read rows */
|
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
|
||||||
protected $numReadRows = 0;
|
protected $currentlyProcessedRowData = [];
|
||||||
|
|
||||||
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
|
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
|
||||||
protected $rowDataBuffer = null;
|
protected $rowDataBuffer = null;
|
||||||
@@ -42,13 +51,41 @@ class RowIterator implements IteratorInterface
|
|||||||
/** @var bool Indicates whether all rows have been read */
|
/** @var bool Indicates whether all rows have been read */
|
||||||
protected $hasReachedEndOfFile = false;
|
protected $hasReachedEndOfFile = false;
|
||||||
|
|
||||||
|
/** @var int Last row index processed (one-based) */
|
||||||
|
protected $lastRowIndexProcessed = 0;
|
||||||
|
|
||||||
|
/** @var int Row index to be processed next (one-based) */
|
||||||
|
protected $nextRowIndexToBeProcessed = 1;
|
||||||
|
|
||||||
|
/** @var mixed|null Value of the last processed cell (because when reading cell at column N+1, cell N is processed) */
|
||||||
|
protected $lastProcessedCellValue = null;
|
||||||
|
|
||||||
|
/** @var int Number of times the last processed row should be repeated */
|
||||||
|
protected $numRowsRepeated = 1;
|
||||||
|
|
||||||
|
/** @var int Number of times the last cell value should be copied to the cells on its right */
|
||||||
|
protected $numColumnsRepeated = 1;
|
||||||
|
|
||||||
|
/** @var bool Whether at least one cell has been read for the row currently being processed */
|
||||||
|
protected $hasAlreadyReadOneCellInCurrentRow = false;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
||||||
|
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||||
*/
|
*/
|
||||||
public function __construct($xmlReader)
|
public function __construct($xmlReader, $options)
|
||||||
{
|
{
|
||||||
$this->xmlReader = $xmlReader;
|
$this->xmlReader = $xmlReader;
|
||||||
$this->cellValueFormatter = new CellValueFormatter();
|
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
|
||||||
|
$this->cellValueFormatter = new CellValueFormatter($options->shouldFormatDates());
|
||||||
|
|
||||||
|
// Register all callbacks to process different nodes when reading the XML file
|
||||||
|
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_TABLE, XMLProcessor::NODE_TYPE_END, [$this, 'processTableEndingNode']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -69,7 +106,8 @@ class RowIterator implements IteratorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->hasAlreadyBeenRewound = true;
|
$this->hasAlreadyBeenRewound = true;
|
||||||
$this->numReadRows = 0;
|
$this->lastRowIndexProcessed = 0;
|
||||||
|
$this->nextRowIndexToBeProcessed = 1;
|
||||||
$this->rowDataBuffer = null;
|
$this->rowDataBuffer = null;
|
||||||
$this->hasReachedEndOfFile = false;
|
$this->hasReachedEndOfFile = false;
|
||||||
|
|
||||||
@@ -80,7 +118,7 @@ class RowIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -97,76 +135,155 @@ class RowIterator implements IteratorInterface
|
|||||||
*/
|
*/
|
||||||
public function next()
|
public function next()
|
||||||
{
|
{
|
||||||
$rowData = [];
|
if ($this->doesNeedDataForNextRowToBeProcessed()) {
|
||||||
$cellValue = null;
|
$this->readDataForNextRow();
|
||||||
$numColumnsRepeated = 1;
|
}
|
||||||
$numCellsRead = 0;
|
|
||||||
$hasAlreadyReadOneCell = false;
|
$this->lastRowIndexProcessed++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether we need data for the next row to be processed.
|
||||||
|
* We DO need to read data if:
|
||||||
|
* - we have not read any rows yet
|
||||||
|
* OR
|
||||||
|
* - the next row to be processed immediately follows the last read row
|
||||||
|
*
|
||||||
|
* @return bool Whether we need data for the next row to be processed.
|
||||||
|
*/
|
||||||
|
protected function doesNeedDataForNextRowToBeProcessed()
|
||||||
|
{
|
||||||
|
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!$hasReadAtLeastOneRow ||
|
||||||
|
$this->lastRowIndexProcessed === $this->nextRowIndexToBeProcessed - 1
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
|
||||||
|
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
|
||||||
|
*/
|
||||||
|
protected function readDataForNextRow()
|
||||||
|
{
|
||||||
|
$this->currentlyProcessedRowData = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while ($this->xmlReader->read()) {
|
$this->xmlProcessor->readUntilStopped();
|
||||||
if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) {
|
|
||||||
// Start of a cell description
|
|
||||||
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode();
|
|
||||||
|
|
||||||
$node = $this->xmlReader->expand();
|
|
||||||
$currentCellValue = $this->getCellValue($node);
|
|
||||||
|
|
||||||
// process cell N only after having read cell N+1 (see below why)
|
|
||||||
if ($hasAlreadyReadOneCell) {
|
|
||||||
for ($i = 0; $i < $numColumnsRepeated; $i++) {
|
|
||||||
$rowData[] = $cellValue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$cellValue = $currentCellValue;
|
|
||||||
$numColumnsRepeated = $currentNumColumnsRepeated;
|
|
||||||
|
|
||||||
$numCellsRead++;
|
|
||||||
$hasAlreadyReadOneCell = true;
|
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
|
|
||||||
// End of the row description
|
|
||||||
$isEmptyRow = ($numCellsRead <= 1 && empty($cellValue));
|
|
||||||
if ($isEmptyRow) {
|
|
||||||
// skip empty rows
|
|
||||||
$this->next();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Only add value if the last read cell is not empty or does not need to repeat cell values.
|
|
||||||
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
|
|
||||||
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
|
|
||||||
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with always 16384 cells.
|
|
||||||
if (!empty($cellValue) || $numColumnsRepeated === 1) {
|
|
||||||
for ($i = 0; $i < $numColumnsRepeated; $i++) {
|
|
||||||
$rowData[] = $cellValue;
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->numReadRows++;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_TABLE)) {
|
|
||||||
// The closing "</table:table>" marks the end of the file
|
|
||||||
$this->hasReachedEndOfFile = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
} catch (XMLProcessingException $exception) {
|
} catch (XMLProcessingException $exception) {
|
||||||
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
|
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->rowDataBuffer = $rowData;
|
$this->rowDataBuffer = $this->currentlyProcessedRowData;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processRowStartingNode($xmlReader)
|
||||||
|
{
|
||||||
|
// Reset data from current row
|
||||||
|
$this->hasAlreadyReadOneCellInCurrentRow = false;
|
||||||
|
$this->lastProcessedCellValue = null;
|
||||||
|
$this->numColumnsRepeated = 1;
|
||||||
|
$this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader);
|
||||||
|
|
||||||
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processCellStartingNode($xmlReader)
|
||||||
|
{
|
||||||
|
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
|
||||||
|
|
||||||
|
$node = $xmlReader->expand();
|
||||||
|
$currentCellValue = $this->getCellValue($node);
|
||||||
|
|
||||||
|
// process cell N only after having read cell N+1 (see below why)
|
||||||
|
if ($this->hasAlreadyReadOneCellInCurrentRow) {
|
||||||
|
for ($i = 0; $i < $this->numColumnsRepeated; $i++) {
|
||||||
|
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->hasAlreadyReadOneCellInCurrentRow = true;
|
||||||
|
$this->lastProcessedCellValue = $currentCellValue;
|
||||||
|
$this->numColumnsRepeated = $currentNumColumnsRepeated;
|
||||||
|
|
||||||
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processRowEndingNode()
|
||||||
|
{
|
||||||
|
$isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRowData, $this->lastProcessedCellValue);
|
||||||
|
|
||||||
|
// if the fetched row is empty and we don't want to preserve it...
|
||||||
|
if (!$this->shouldPreserveEmptyRows && $isEmptyRow) {
|
||||||
|
// ... skip it
|
||||||
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
// if the row is empty, we don't want to return more than one cell
|
||||||
|
$actualNumColumnsRepeated = (!$isEmptyRow) ? $this->numColumnsRepeated : 1;
|
||||||
|
|
||||||
|
// Only add the value if the last read cell is not a trailing empty cell repeater in Excel.
|
||||||
|
// The current count of read columns is determined by counting the values in "$this->currentlyProcessedRowData".
|
||||||
|
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
|
||||||
|
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
|
||||||
|
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with
|
||||||
|
// always 16384 cells.
|
||||||
|
if ((count($this->currentlyProcessedRowData) + $actualNumColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
|
||||||
|
for ($i = 0; $i < $actualNumColumnsRepeated; $i++) {
|
||||||
|
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we are processing row N and the row is repeated M times,
|
||||||
|
// then the next row to be processed will be row (N+M).
|
||||||
|
$this->nextRowIndexToBeProcessed += $this->numRowsRepeated;
|
||||||
|
|
||||||
|
// at this point, we have all the data we need for the row
|
||||||
|
// so that we can populate the buffer
|
||||||
|
return XMLProcessor::PROCESSING_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processTableEndingNode()
|
||||||
|
{
|
||||||
|
// The closing "</table:table>" marks the end of the file
|
||||||
|
$this->hasReachedEndOfFile = true;
|
||||||
|
|
||||||
|
return XMLProcessor::PROCESSING_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
|
||||||
|
* @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing
|
||||||
|
*/
|
||||||
|
protected function getNumRowsRepeatedForCurrentNode($xmlReader)
|
||||||
|
{
|
||||||
|
$numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED);
|
||||||
|
return ($numRowsRepeated !== null) ? intval($numRowsRepeated) : 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
|
||||||
* @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
|
* @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
|
||||||
*/
|
*/
|
||||||
protected function getNumColumnsRepeatedForCurrentNode()
|
protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
|
||||||
{
|
{
|
||||||
$numColumnsRepeated = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
|
$numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
|
||||||
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
|
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,6 +298,24 @@ class RowIterator implements IteratorInterface
|
|||||||
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
|
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After finishing processing each cell, a row is considered empty if it contains
|
||||||
|
* no cells or if the value of the last read cell is an empty string.
|
||||||
|
* After finishing processing each cell, the last read cell is not part of the
|
||||||
|
* row data yet (as we still need to apply the "num-columns-repeated" attribute).
|
||||||
|
*
|
||||||
|
* @param array $rowData
|
||||||
|
* @param string|int|float|bool|\DateTime|\DateInterval|null The value of the last read cell
|
||||||
|
* @return bool Whether the row is empty
|
||||||
|
*/
|
||||||
|
protected function isEmptyRow($rowData, $lastReadCellValue)
|
||||||
|
{
|
||||||
|
return (
|
||||||
|
count($rowData) === 0 &&
|
||||||
|
(!isset($lastReadCellValue) || trim($lastReadCellValue) === '')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the current element, from the buffer.
|
* Return the current element, from the buffer.
|
||||||
* @link http://php.net/manual/en/iterator.current.php
|
* @link http://php.net/manual/en/iterator.current.php
|
||||||
@@ -200,7 +335,7 @@ class RowIterator implements IteratorInterface
|
|||||||
*/
|
*/
|
||||||
public function key()
|
public function key()
|
||||||
{
|
{
|
||||||
return $this->numReadRows;
|
return $this->lastRowIndexProcessed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,13 +13,13 @@ use Box\Spout\Reader\Wrapper\XMLReader;
|
|||||||
*/
|
*/
|
||||||
class Sheet implements SheetInterface
|
class Sheet implements SheetInterface
|
||||||
{
|
{
|
||||||
/** @var RowIterator To iterate over sheet's rows */
|
/** @var \Box\Spout\Reader\ODS\RowIterator To iterate over sheet's rows */
|
||||||
protected $rowIterator;
|
protected $rowIterator;
|
||||||
|
|
||||||
/** @var int ID of the sheet */
|
/** @var int ID of the sheet */
|
||||||
protected $id;
|
protected $id;
|
||||||
|
|
||||||
/** @var int Index of the sheet, based on order of creation (zero-based) */
|
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||||
protected $index;
|
protected $index;
|
||||||
|
|
||||||
/** @var string Name of the sheet */
|
/** @var string Name of the sheet */
|
||||||
@@ -27,19 +27,20 @@ class Sheet implements SheetInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
||||||
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
|
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||||
|
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||||
* @param string $sheetName Name of the sheet
|
* @param string $sheetName Name of the sheet
|
||||||
*/
|
*/
|
||||||
public function __construct($xmlReader, $sheetIndex, $sheetName)
|
public function __construct($xmlReader, $sheetIndex, $sheetName, $options)
|
||||||
{
|
{
|
||||||
$this->rowIterator = new RowIterator($xmlReader);
|
$this->rowIterator = new RowIterator($xmlReader, $options);
|
||||||
$this->index = $sheetIndex;
|
$this->index = $sheetIndex;
|
||||||
$this->name = $sheetName;
|
$this->name = $sheetName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return RowIterator
|
* @return \Box\Spout\Reader\ODS\RowIterator
|
||||||
*/
|
*/
|
||||||
public function getRowIterator()
|
public function getRowIterator()
|
||||||
{
|
{
|
||||||
@@ -48,7 +49,7 @@ class Sheet implements SheetInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return int Index of the sheet, based on order of creation (zero-based)
|
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||||
*/
|
*/
|
||||||
public function getIndex()
|
public function getIndex()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ use Box\Spout\Reader\Wrapper\XMLReader;
|
|||||||
*/
|
*/
|
||||||
class SheetIterator implements IteratorInterface
|
class SheetIterator implements IteratorInterface
|
||||||
{
|
{
|
||||||
|
const CONTENT_XML_FILE_PATH = 'content.xml';
|
||||||
|
|
||||||
/** Definition of XML nodes name and attribute used to parse sheet data */
|
/** Definition of XML nodes name and attribute used to parse sheet data */
|
||||||
const XML_NODE_TABLE = 'table:table';
|
const XML_NODE_TABLE = 'table:table';
|
||||||
const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
|
const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
|
||||||
@@ -22,6 +24,9 @@ class SheetIterator implements IteratorInterface
|
|||||||
/** @var string $filePath Path of the file to be read */
|
/** @var string $filePath Path of the file to be read */
|
||||||
protected $filePath;
|
protected $filePath;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\ODS\ReaderOptions Reader's current options */
|
||||||
|
protected $options;
|
||||||
|
|
||||||
/** @var XMLReader The XMLReader object that will help read sheet's XML data */
|
/** @var XMLReader The XMLReader object that will help read sheet's XML data */
|
||||||
protected $xmlReader;
|
protected $xmlReader;
|
||||||
|
|
||||||
@@ -36,15 +41,17 @@ class SheetIterator implements IteratorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the file to be read
|
* @param string $filePath Path of the file to be read
|
||||||
|
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||||
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath)
|
public function __construct($filePath, $options)
|
||||||
{
|
{
|
||||||
$this->filePath = $filePath;
|
$this->filePath = $filePath;
|
||||||
|
$this->options = $options;
|
||||||
$this->xmlReader = new XMLReader();
|
$this->xmlReader = new XMLReader();
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->escaper = new \Box\Spout\Common\Escaper\ODS();
|
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -58,8 +65,8 @@ class SheetIterator implements IteratorInterface
|
|||||||
{
|
{
|
||||||
$this->xmlReader->close();
|
$this->xmlReader->close();
|
||||||
|
|
||||||
$contentXmlFilePath = $this->filePath . '#content.xml';
|
if ($this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH) === false) {
|
||||||
if ($this->xmlReader->open('zip://' . $contentXmlFilePath) === false) {
|
$contentXmlFilePath = $this->filePath . '#' . self::CONTENT_XML_FILE_PATH;
|
||||||
throw new IOException("Could not open \"{$contentXmlFilePath}\".");
|
throw new IOException("Could not open \"{$contentXmlFilePath}\".");
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +83,7 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -102,14 +109,14 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Return the current element
|
* Return the current element
|
||||||
* @link http://php.net/manual/en/iterator.current.php
|
* @link http://php.net/manual/en/iterator.current.php
|
||||||
*
|
*
|
||||||
* @return Sheet
|
* @return \Box\Spout\Reader\ODS\Sheet
|
||||||
*/
|
*/
|
||||||
public function current()
|
public function current()
|
||||||
{
|
{
|
||||||
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
|
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
|
||||||
$sheetName = $this->escaper->unescape($escapedSheetName);
|
$sheetName = $this->escaper->unescape($escapedSheetName);
|
||||||
|
|
||||||
return new Sheet($this->xmlReader, $sheetName, $this->currentSheetIndex);
|
return new Sheet($this->xmlReader, $sheetName, $this->currentSheetIndex, $this->options);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -40,8 +40,6 @@ class SimpleXMLElement
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->resetXMLInternalErrorsSetting();
|
$this->resetXMLInternalErrorsSetting();
|
||||||
|
|
||||||
return $this->simpleXMLElement;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -14,66 +14,44 @@ class XMLReader extends \XMLReader
|
|||||||
{
|
{
|
||||||
use XMLInternalErrorsHelper;
|
use XMLInternalErrorsHelper;
|
||||||
|
|
||||||
|
const ZIP_WRAPPER = 'zip://';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Set the URI containing the XML to parse
|
* Opens the XML Reader to read a file located inside a ZIP file.
|
||||||
* @see \XMLReader::open
|
|
||||||
*
|
*
|
||||||
* @param string $URI URI pointing to the document
|
* @param string $zipFilePath Path to the ZIP file
|
||||||
* @param string|null|void $encoding The document encoding
|
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
|
||||||
* @param int $options A bitmask of the LIBXML_* constants
|
|
||||||
* @return bool TRUE on success or FALSE on failure
|
* @return bool TRUE on success or FALSE on failure
|
||||||
*/
|
*/
|
||||||
public function open($URI, $encoding = null, $options = 0)
|
public function openFileInZip($zipFilePath, $fileInsideZipPath)
|
||||||
{
|
{
|
||||||
$wasOpenSuccessful = false;
|
$wasOpenSuccessful = false;
|
||||||
$realPathURI = $this->convertURIToUseRealPath($URI);
|
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
|
||||||
|
|
||||||
// HHVM does not check if file exists within zip file
|
// HHVM does not check if file exists within zip file
|
||||||
// @link https://github.com/facebook/hhvm/issues/5779
|
// @link https://github.com/facebook/hhvm/issues/5779
|
||||||
if ($this->isRunningHHVM() && $this->isZipStream($realPathURI)) {
|
if ($this->isRunningHHVM()) {
|
||||||
if ($this->fileExistsWithinZip($realPathURI)) {
|
if ($this->fileExistsWithinZip($realPathURI)) {
|
||||||
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET);
|
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET);
|
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $wasOpenSuccessful;
|
return $wasOpenSuccessful;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Updates the given URI to use a real path.
|
* Returns the real path for the given path components.
|
||||||
* This is to avoid issues on some Windows setup.
|
* This is useful to avoid issues on some Windows setup.
|
||||||
*
|
*
|
||||||
* @param string $URI URI
|
* @param string $zipFilePath Path to the ZIP file
|
||||||
* @return string The URI using a real path
|
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
|
||||||
|
* @return string The real path URI
|
||||||
*/
|
*/
|
||||||
protected function convertURIToUseRealPath($URI)
|
public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
|
||||||
{
|
{
|
||||||
$realPathURI = $URI;
|
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
|
||||||
|
|
||||||
if ($this->isZipStream($URI)) {
|
|
||||||
if (preg_match('/zip:\/\/(.*)#(.*)/', $URI, $matches)) {
|
|
||||||
$documentPath = $matches[1];
|
|
||||||
$documentInsideZipPath = $matches[2];
|
|
||||||
$realPathURI = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
$realPathURI = realpath($URI);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $realPathURI;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns whether the given URI is a zip stream.
|
|
||||||
*
|
|
||||||
* @param string $URI URI pointing to a document
|
|
||||||
* @return bool TRUE if URI is a zip stream, FALSE otherwise
|
|
||||||
*/
|
|
||||||
protected function isZipStream($URI)
|
|
||||||
{
|
|
||||||
return (strpos($URI, 'zip://') === 0);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -138,9 +116,10 @@ class XMLReader extends \XMLReader
|
|||||||
*/
|
*/
|
||||||
public function readUntilNodeFound($nodeName)
|
public function readUntilNodeFound($nodeName)
|
||||||
{
|
{
|
||||||
while (($wasReadSuccessful = $this->read()) && ($this->nodeType !== \XMLReader::ELEMENT || $this->name !== $nodeName)) {
|
do {
|
||||||
// do nothing
|
$wasReadSuccessful = $this->read();
|
||||||
}
|
$isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
|
||||||
|
} while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
|
||||||
|
|
||||||
return $wasReadSuccessful;
|
return $wasReadSuccessful;
|
||||||
}
|
}
|
||||||
@@ -170,7 +149,7 @@ class XMLReader extends \XMLReader
|
|||||||
*/
|
*/
|
||||||
public function isPositionedOnStartingNode($nodeName)
|
public function isPositionedOnStartingNode($nodeName)
|
||||||
{
|
{
|
||||||
return ($this->nodeType === XMLReader::ELEMENT && $this->name === $nodeName);
|
return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -179,6 +158,22 @@ class XMLReader extends \XMLReader
|
|||||||
*/
|
*/
|
||||||
public function isPositionedOnEndingNode($nodeName)
|
public function isPositionedOnEndingNode($nodeName)
|
||||||
{
|
{
|
||||||
return ($this->nodeType === XMLReader::END_ELEMENT && $this->name === $nodeName);
|
return $this->isPositionedOnNode($nodeName, XMLReader::END_ELEMENT);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $nodeName
|
||||||
|
* @param int $nodeType
|
||||||
|
* @return bool Whether the XML Reader is currently positioned on the node with given name and type
|
||||||
|
*/
|
||||||
|
private function isPositionedOnNode($nodeName, $nodeType)
|
||||||
|
{
|
||||||
|
// In some cases, the node has a prefix (for instance, "<sheet>" can also be "<x:sheet>").
|
||||||
|
// So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
|
||||||
|
// @see https://github.com/box/spout/issues/233
|
||||||
|
$hasPrefix = (strpos($nodeName, ':') !== false);
|
||||||
|
$currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
|
||||||
|
|
||||||
|
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,14 @@ use Box\Spout\Common\Exception\InvalidArgumentException;
|
|||||||
*/
|
*/
|
||||||
class CellHelper
|
class CellHelper
|
||||||
{
|
{
|
||||||
|
// Using ord() is super slow... Using a pre-computed hash table instead.
|
||||||
|
private static $columnLetterToIndexMapping = [
|
||||||
|
'A' => 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6,
|
||||||
|
'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13,
|
||||||
|
'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20,
|
||||||
|
'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25,
|
||||||
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Fills the missing indexes of an array with a given value.
|
* Fills the missing indexes of an array with a given value.
|
||||||
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
|
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
|
||||||
@@ -50,34 +58,31 @@ class CellHelper
|
|||||||
}
|
}
|
||||||
|
|
||||||
$columnIndex = 0;
|
$columnIndex = 0;
|
||||||
$capitalAAsciiValue = ord('A');
|
|
||||||
$capitalZAsciiValue = ord('Z');
|
|
||||||
$step = $capitalZAsciiValue - $capitalAAsciiValue + 1;
|
|
||||||
|
|
||||||
// Remove row information
|
// Remove row information
|
||||||
$column = preg_replace('/\d/', '', $cellIndex);
|
$columnLetters = preg_replace('/\d/', '', $cellIndex);
|
||||||
$columnLength = strlen($column);
|
|
||||||
|
|
||||||
/*
|
// strlen() is super slow too... Using isset() is way faster and not too unreadable,
|
||||||
* This is how the following loop will process the data:
|
// since we checked before that there are between 1 and 3 letters.
|
||||||
* A => 0
|
$columnLength = isset($columnLetters[1]) ? (isset($columnLetters[2]) ? 3 : 2) : 1;
|
||||||
* Z => 25
|
|
||||||
* AA => 26 : (26^(2-1) * (0+1)) + 0
|
|
||||||
* AB => 27 : (26^(2-1) * (0+1)) + 1
|
|
||||||
* BC => 54 : (26^(2-1) * (1+1)) + 2
|
|
||||||
* BCZ => 1455 : (26^(3-1) * (1+1)) + (26^(2-1) * (2+1)) + 25
|
|
||||||
*/
|
|
||||||
foreach (str_split($column) as $single_cell_index)
|
|
||||||
{
|
|
||||||
$currentColumnIndex = ord($single_cell_index) - $capitalAAsciiValue;
|
|
||||||
|
|
||||||
if ($columnLength === 1) {
|
// Looping over the different letters of the column is slower than this method.
|
||||||
$columnIndex += $currentColumnIndex;
|
// Also, not using the pow() function because it's slooooow...
|
||||||
} else {
|
switch ($columnLength) {
|
||||||
$columnIndex += pow($step, ($columnLength - 1)) * ($currentColumnIndex + 1);
|
case 1:
|
||||||
}
|
$columnIndex = (self::$columnLetterToIndexMapping[$columnLetters]);
|
||||||
|
break;
|
||||||
$columnLength--;
|
case 2:
|
||||||
|
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 26;
|
||||||
|
$secondLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[1]];
|
||||||
|
$columnIndex = $firstLetterIndex + $secondLetterIndex;
|
||||||
|
break;
|
||||||
|
case 3:
|
||||||
|
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 676;
|
||||||
|
$secondLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[1]] + 1) * 26;
|
||||||
|
$thirdLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[2]];
|
||||||
|
$columnIndex = $firstLetterIndex + $secondLetterIndex + $thirdLetterIndex;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
|
|
||||||
return $columnIndex;
|
return $columnIndex;
|
||||||
@@ -86,12 +91,13 @@ class CellHelper
|
|||||||
/**
|
/**
|
||||||
* Returns whether a cell index is valid, in an Excel world.
|
* Returns whether a cell index is valid, in an Excel world.
|
||||||
* To be valid, the cell index should start with capital letters and be followed by numbers.
|
* To be valid, the cell index should start with capital letters and be followed by numbers.
|
||||||
|
* There can only be 3 letters, as there can only be 16,384 rows, which is equivalent to 'XFE'.
|
||||||
*
|
*
|
||||||
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
|
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
protected static function isValidCellIndex($cellIndex)
|
protected static function isValidCellIndex($cellIndex)
|
||||||
{
|
{
|
||||||
return (preg_match('/^[A-Z]+\d+$/', $cellIndex) === 1);
|
return (preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex) === 1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -25,22 +25,44 @@ class CellValueFormatter
|
|||||||
|
|
||||||
/** Definition of XML attributes used to parse data */
|
/** Definition of XML attributes used to parse data */
|
||||||
const XML_ATTRIBUTE_TYPE = 't';
|
const XML_ATTRIBUTE_TYPE = 't';
|
||||||
|
const XML_ATTRIBUTE_STYLE_ID = 's';
|
||||||
|
|
||||||
|
/** Constants used for date formatting */
|
||||||
|
const NUM_SECONDS_IN_ONE_DAY = 86400;
|
||||||
|
const NUM_SECONDS_IN_ONE_HOUR = 3600;
|
||||||
|
const NUM_SECONDS_IN_ONE_MINUTE = 60;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* February 29th, 1900 is NOT a leap year but Excel thinks it is...
|
||||||
|
* @see https://en.wikipedia.org/wiki/Year_1900_problem#Microsoft_Excel
|
||||||
|
*/
|
||||||
|
const ERRONEOUS_EXCEL_LEAP_YEAR_DAY = 60;
|
||||||
|
|
||||||
/** @var SharedStringsHelper Helper to work with shared strings */
|
/** @var SharedStringsHelper Helper to work with shared strings */
|
||||||
protected $sharedStringsHelper;
|
protected $sharedStringsHelper;
|
||||||
|
|
||||||
|
/** @var StyleHelper Helper to work with styles */
|
||||||
|
protected $styleHelper;
|
||||||
|
|
||||||
|
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
|
||||||
|
protected $shouldFormatDates;
|
||||||
|
|
||||||
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
|
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
|
||||||
protected $escaper;
|
protected $escaper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
|
* @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
|
||||||
|
* @param StyleHelper $styleHelper Helper to work with styles
|
||||||
|
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
|
||||||
*/
|
*/
|
||||||
public function __construct($sharedStringsHelper)
|
public function __construct($sharedStringsHelper, $styleHelper, $shouldFormatDates)
|
||||||
{
|
{
|
||||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||||
|
$this->styleHelper = $styleHelper;
|
||||||
|
$this->shouldFormatDates = $shouldFormatDates;
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -53,6 +75,7 @@ class CellValueFormatter
|
|||||||
{
|
{
|
||||||
// Default cell type is "n"
|
// Default cell type is "n"
|
||||||
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
|
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
|
||||||
|
$cellStyleId = intval($node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID));
|
||||||
$vNodeValue = $this->getVNodeValue($node);
|
$vNodeValue = $this->getVNodeValue($node);
|
||||||
|
|
||||||
if (($vNodeValue === '') && ($cellType !== self::CELL_TYPE_INLINE_STRING)) {
|
if (($vNodeValue === '') && ($cellType !== self::CELL_TYPE_INLINE_STRING)) {
|
||||||
@@ -69,7 +92,7 @@ class CellValueFormatter
|
|||||||
case self::CELL_TYPE_BOOLEAN:
|
case self::CELL_TYPE_BOOLEAN:
|
||||||
return $this->formatBooleanCellValue($vNodeValue);
|
return $this->formatBooleanCellValue($vNodeValue);
|
||||||
case self::CELL_TYPE_NUMERIC:
|
case self::CELL_TYPE_NUMERIC:
|
||||||
return $this->formatNumericCellValue($vNodeValue);
|
return $this->formatNumericCellValue($vNodeValue, $cellStyleId);
|
||||||
case self::CELL_TYPE_DATE:
|
case self::CELL_TYPE_DATE:
|
||||||
return $this->formatDateCellValue($vNodeValue);
|
return $this->formatDateCellValue($vNodeValue);
|
||||||
default:
|
default:
|
||||||
@@ -102,8 +125,7 @@ class CellValueFormatter
|
|||||||
// inline strings are formatted this way:
|
// inline strings are formatted this way:
|
||||||
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
|
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
|
||||||
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
|
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
|
||||||
$escapedCellValue = trim($tNode->nodeValue);
|
$cellValue = $this->escaper->unescape($tNode->nodeValue);
|
||||||
$cellValue = $this->escaper->unescape($escapedCellValue);
|
|
||||||
return $cellValue;
|
return $cellValue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,14 +160,112 @@ class CellValueFormatter
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the cell Numeric value from string of nodeValue.
|
* Returns the cell Numeric value from string of nodeValue.
|
||||||
|
* The value can also represent a timestamp and a DateTime will be returned.
|
||||||
*
|
*
|
||||||
* @param string $nodeValue
|
* @param string $nodeValue
|
||||||
* @return int|float The value associated with the cell
|
* @param int $cellStyleId 0 being the default style
|
||||||
|
* @return int|float|\DateTime|null The value associated with the cell
|
||||||
*/
|
*/
|
||||||
protected function formatNumericCellValue($nodeValue)
|
protected function formatNumericCellValue($nodeValue, $cellStyleId)
|
||||||
{
|
{
|
||||||
$cellValue = is_int($nodeValue) ? intval($nodeValue) : floatval($nodeValue);
|
// Numeric values can represent numbers as well as timestamps.
|
||||||
return $cellValue;
|
// We need to look at the style of the cell to determine whether it is one or the other.
|
||||||
|
$shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId);
|
||||||
|
|
||||||
|
if ($shouldFormatAsDate) {
|
||||||
|
return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId);
|
||||||
|
} else {
|
||||||
|
$nodeIntValue = intval($nodeValue);
|
||||||
|
return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cell's PHP Date value, associated to the given timestamp.
|
||||||
|
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
|
||||||
|
* NOTE: The timestamp can also represent a time, if it is a value between 0 and 1.
|
||||||
|
*
|
||||||
|
* @param float $nodeValue
|
||||||
|
* @param int $cellStyleId 0 being the default style
|
||||||
|
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
|
||||||
|
*/
|
||||||
|
protected function formatExcelTimestampValue($nodeValue, $cellStyleId)
|
||||||
|
{
|
||||||
|
// Fix for the erroneous leap year in Excel
|
||||||
|
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
|
||||||
|
--$nodeValue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($nodeValue >= 1) {
|
||||||
|
// Values greater than 1 represent "dates". The value 1.0 representing the "base" date: 1900-01-01.
|
||||||
|
return $this->formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId);
|
||||||
|
} else if ($nodeValue >= 0) {
|
||||||
|
// Values between 0 and 1 represent "times".
|
||||||
|
return $this->formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId);
|
||||||
|
} else {
|
||||||
|
// invalid date
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cell's PHP DateTime value, associated to the given timestamp.
|
||||||
|
* Only the time value matters. The date part is set to Jan 1st, 1900 (base Excel date).
|
||||||
|
*
|
||||||
|
* @param float $nodeValue
|
||||||
|
* @param int $cellStyleId 0 being the default style
|
||||||
|
* @return \DateTime|string The value associated with the cell
|
||||||
|
*/
|
||||||
|
protected function formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId)
|
||||||
|
{
|
||||||
|
$time = round($nodeValue * self::NUM_SECONDS_IN_ONE_DAY);
|
||||||
|
$hours = floor($time / self::NUM_SECONDS_IN_ONE_HOUR);
|
||||||
|
$minutes = floor($time / self::NUM_SECONDS_IN_ONE_MINUTE) - ($hours * self::NUM_SECONDS_IN_ONE_MINUTE);
|
||||||
|
$seconds = $time - ($hours * self::NUM_SECONDS_IN_ONE_HOUR) - ($minutes * self::NUM_SECONDS_IN_ONE_MINUTE);
|
||||||
|
|
||||||
|
// using the base Excel date (Jan 1st, 1900) - not relevant here
|
||||||
|
$dateObj = new \DateTime('1900-01-01');
|
||||||
|
$dateObj->setTime($hours, $minutes, $seconds);
|
||||||
|
|
||||||
|
if ($this->shouldFormatDates) {
|
||||||
|
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
|
||||||
|
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
|
||||||
|
return $dateObj->format($phpDateFormat);
|
||||||
|
} else {
|
||||||
|
return $dateObj;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a cell's PHP Date value, associated to the given timestamp.
|
||||||
|
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
|
||||||
|
*
|
||||||
|
* @param float $nodeValue
|
||||||
|
* @param int $cellStyleId 0 being the default style
|
||||||
|
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||||
|
*/
|
||||||
|
protected function formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId)
|
||||||
|
{
|
||||||
|
// Do not use any unix timestamps for calculation to prevent
|
||||||
|
// issues with numbers exceeding 2^31.
|
||||||
|
$secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY;
|
||||||
|
$secondsRemainder = round($secondsRemainder, 0);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
|
||||||
|
$dateObj->modify('+' . intval($nodeValue) . 'days');
|
||||||
|
$dateObj->modify('+' . $secondsRemainder . 'seconds');
|
||||||
|
|
||||||
|
if ($this->shouldFormatDates) {
|
||||||
|
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
|
||||||
|
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
|
||||||
|
return $dateObj->format($phpDateFormat);
|
||||||
|
} else {
|
||||||
|
return $dateObj;
|
||||||
|
}
|
||||||
|
} catch (\Exception $e) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -163,16 +283,16 @@ class CellValueFormatter
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
|
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
|
||||||
|
* @see ECMA-376 Part 1 - §18.17.4
|
||||||
*
|
*
|
||||||
* @param string $nodeValue
|
* @param string $nodeValue ISO 8601 Date string
|
||||||
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
|
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||||
*/
|
*/
|
||||||
protected function formatDateCellValue($nodeValue)
|
protected function formatDateCellValue($nodeValue)
|
||||||
{
|
{
|
||||||
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
|
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
|
||||||
try {
|
try {
|
||||||
$cellValue = new \DateTime($nodeValue);
|
return ($this->shouldFormatDates) ? $nodeValue : new \DateTime($nodeValue);
|
||||||
return $cellValue;
|
|
||||||
} catch (\Exception $e) {
|
} catch (\Exception $e) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,124 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class DateFormatHelper
|
||||||
|
* This class provides helper functions to format Excel dates
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX\Helper
|
||||||
|
*/
|
||||||
|
class DateFormatHelper
|
||||||
|
{
|
||||||
|
const KEY_GENERAL = 'general';
|
||||||
|
const KEY_HOUR_12 = '12h';
|
||||||
|
const KEY_HOUR_24 = '24h';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This map is used to replace Excel format characters by their PHP equivalent.
|
||||||
|
* Keys should be ordered from longest to smallest.
|
||||||
|
*
|
||||||
|
* @var array Mapping between Excel format characters and PHP format characters
|
||||||
|
*/
|
||||||
|
private static $excelDateFormatToPHPDateFormatMapping = [
|
||||||
|
self::KEY_GENERAL => [
|
||||||
|
// Time
|
||||||
|
'am/pm' => 'A', // Uppercase Ante meridiem and Post meridiem
|
||||||
|
':mm' => ':i', // Minutes with leading zeros - if preceded by a ":" (otherwise month)
|
||||||
|
'mm:' => 'i:', // Minutes with leading zeros - if followed by a ":" (otherwise month)
|
||||||
|
'ss' => 's', // Seconds, with leading zeros
|
||||||
|
'.s' => '', // Ignore (fractional seconds format does not exist in PHP)
|
||||||
|
|
||||||
|
// Date
|
||||||
|
'e' => 'Y', // Full numeric representation of a year, 4 digits
|
||||||
|
'yyyy' => 'Y', // Full numeric representation of a year, 4 digits
|
||||||
|
'yy' => 'y', // Two digit representation of a year
|
||||||
|
'mmmmm' => 'M', // Short textual representation of a month, three letters ("mmmmm" should only contain the 1st letter...)
|
||||||
|
'mmmm' => 'F', // Full textual representation of a month
|
||||||
|
'mmm' => 'M', // Short textual representation of a month, three letters
|
||||||
|
'mm' => 'm', // Numeric representation of a month, with leading zeros
|
||||||
|
'm' => 'n', // Numeric representation of a month, without leading zeros
|
||||||
|
'dddd' => 'l', // Full textual representation of the day of the week
|
||||||
|
'ddd' => 'D', // Textual representation of a day, three letters
|
||||||
|
'dd' => 'd', // Day of the month, 2 digits with leading zeros
|
||||||
|
'd' => 'j', // Day of the month without leading zeros
|
||||||
|
],
|
||||||
|
self::KEY_HOUR_12 => [
|
||||||
|
'hh' => 'h', // 12-hour format of an hour without leading zeros
|
||||||
|
'h' => 'g', // 12-hour format of an hour without leading zeros
|
||||||
|
],
|
||||||
|
self::KEY_HOUR_24 => [
|
||||||
|
'hh' => 'H', // 24-hour hours with leading zero
|
||||||
|
'h' => 'G', // 24-hour format of an hour without leading zeros
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts the given Excel date format to a format understandable by the PHP date function.
|
||||||
|
*
|
||||||
|
* @param string $excelDateFormat Excel date format
|
||||||
|
* @return string PHP date format (as defined here: http://php.net/manual/en/function.date.php)
|
||||||
|
*/
|
||||||
|
public static function toPHPDateFormat($excelDateFormat)
|
||||||
|
{
|
||||||
|
// Remove brackets potentially present at the beginning of the format string
|
||||||
|
// and text portion of the format at the end of it (starting with ";")
|
||||||
|
// See §18.8.31 of ECMA-376 for more detail.
|
||||||
|
$dateFormat = preg_replace('/^(?:\[\$[^\]]+?\])?([^;]*).*/', '$1', $excelDateFormat);
|
||||||
|
|
||||||
|
// Double quotes are used to escape characters that must not be interpreted.
|
||||||
|
// For instance, ["Day " dd] should result in "Day 13" and we should not try to interpret "D", "a", "y"
|
||||||
|
// By exploding the format string using double quote as a delimiter, we can get all parts
|
||||||
|
// that must be transformed (even indexes) and all parts that must not be (odd indexes).
|
||||||
|
$dateFormatParts = explode('"', $dateFormat);
|
||||||
|
|
||||||
|
foreach ($dateFormatParts as $partIndex => $dateFormatPart) {
|
||||||
|
// do not look at odd indexes
|
||||||
|
if ($partIndex % 2 === 1) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Make sure all characters are lowercase, as the mapping table is using lowercase characters
|
||||||
|
$transformedPart = strtolower($dateFormatPart);
|
||||||
|
|
||||||
|
// Remove escapes related to non-format characters
|
||||||
|
$transformedPart = str_replace('\\', '', $transformedPart);
|
||||||
|
|
||||||
|
// Apply general transformation first...
|
||||||
|
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_GENERAL]);
|
||||||
|
|
||||||
|
// ... then apply hour transformation, for 12-hour or 24-hour format
|
||||||
|
if (self::has12HourFormatMarker($dateFormatPart)) {
|
||||||
|
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_12]);
|
||||||
|
} else {
|
||||||
|
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_24]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// overwrite the parts array with the new transformed part
|
||||||
|
$dateFormatParts[$partIndex] = $transformedPart;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Merge all transformed parts back together
|
||||||
|
$phpDateFormat = implode('"', $dateFormatParts);
|
||||||
|
|
||||||
|
// Finally, to have the date format compatible with the DateTime::format() function, we need to escape
|
||||||
|
// all characters that are inside double quotes (and double quotes must be removed).
|
||||||
|
// For instance, ["Day " dd] should become [\D\a\y\ dd]
|
||||||
|
$phpDateFormat = preg_replace_callback('/"(.+?)"/', function($matches) {
|
||||||
|
$stringToEscape = $matches[1];
|
||||||
|
$letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY);
|
||||||
|
return '\\' . implode('\\', $letters);
|
||||||
|
}, $phpDateFormat);
|
||||||
|
|
||||||
|
return $phpDateFormat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $excelDateFormat Date format as defined by Excel
|
||||||
|
* @return bool Whether the given date format has the 12-hour format marker
|
||||||
|
*/
|
||||||
|
private static function has12HourFormatMarker($excelDateFormat)
|
||||||
|
{
|
||||||
|
return (stripos($excelDateFormat, 'am/pm') !== false);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,7 +78,7 @@ class CachingStrategyFactory
|
|||||||
* Returns the best caching strategy, given the number of unique shared strings
|
* Returns the best caching strategy, given the number of unique shared strings
|
||||||
* and the amount of memory available.
|
* and the amount of memory available.
|
||||||
*
|
*
|
||||||
* @param int $sharedStringsUniqueCount Number of unique shared strings
|
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||||
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||||
* @return CachingStrategyInterface The best caching strategy
|
* @return CachingStrategyInterface The best caching strategy
|
||||||
*/
|
*/
|
||||||
@@ -95,11 +95,16 @@ class CachingStrategyFactory
|
|||||||
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings
|
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings
|
||||||
* and the amount of memory available.
|
* and the amount of memory available.
|
||||||
*
|
*
|
||||||
* @param int $sharedStringsUniqueCount Number of unique shared strings
|
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||||
* @return bool
|
* @return bool
|
||||||
*/
|
*/
|
||||||
protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)
|
protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)
|
||||||
{
|
{
|
||||||
|
// if the number of shared strings in unknown, do not use "in memory" strategy
|
||||||
|
if ($sharedStringsUniqueCount === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
$memoryAvailable = $this->getMemoryLimitInKB();
|
$memoryAvailable = $this->getMemoryLimitInKB();
|
||||||
|
|
||||||
if ($memoryAvailable === -1) {
|
if ($memoryAvailable === -1) {
|
||||||
|
|||||||
@@ -143,7 +143,9 @@ class FileBasedStrategy implements CachingStrategyInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$sharedString = null;
|
$sharedString = null;
|
||||||
if (array_key_exists($indexInFile, $this->inMemoryTempFileContents)) {
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
if (isset($this->inMemoryTempFileContents[$indexInFile])) {
|
||||||
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
|
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
|
||||||
$sharedString = $this->unescapeLineFeed($escapedSharedString);
|
$sharedString = $this->unescapeLineFeed($escapedSharedString);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ class SharedStringsHelper
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the XLSX file being read
|
* @param string $filePath Path of the XLSX file being read
|
||||||
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
* @param string|null|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath, $tempFolder = null)
|
public function __construct($filePath, $tempFolder = null)
|
||||||
{
|
{
|
||||||
@@ -80,7 +80,7 @@ class SharedStringsHelper
|
|||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$sharedStringIndex = 0;
|
$sharedStringIndex = 0;
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
|
|
||||||
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
||||||
if ($xmlReader->open($sharedStringsFilePath) === false) {
|
if ($xmlReader->open($sharedStringsFilePath) === false) {
|
||||||
@@ -94,39 +94,19 @@ class SharedStringsHelper
|
|||||||
$xmlReader->readUntilNodeFound('si');
|
$xmlReader->readUntilNodeFound('si');
|
||||||
|
|
||||||
while ($xmlReader->name === 'si') {
|
while ($xmlReader->name === 'si') {
|
||||||
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
|
$this->processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper);
|
||||||
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
|
|
||||||
|
|
||||||
// removes nodes that should not be read, like the pronunciation of the Kanji characters
|
|
||||||
$cleanNode = $this->removeSuperfluousTextNodes($node);
|
|
||||||
|
|
||||||
// find all text nodes 't'; there can be multiple if the cell contains formatting
|
|
||||||
$textNodes = $cleanNode->xpath('//ns:t');
|
|
||||||
|
|
||||||
$textValue = '';
|
|
||||||
foreach ($textNodes as $textNode) {
|
|
||||||
if ($this->shouldPreserveWhitespace($textNode)) {
|
|
||||||
$textValue .= $textNode->__toString();
|
|
||||||
} else {
|
|
||||||
$textValue .= trim($textNode->__toString());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
$unescapedTextValue = $escaper->unescape($textValue);
|
|
||||||
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
|
|
||||||
|
|
||||||
$sharedStringIndex++;
|
$sharedStringIndex++;
|
||||||
|
|
||||||
// jump to the next 'si' tag
|
// jump to the next 'si' tag
|
||||||
$xmlReader->next('si');
|
$xmlReader->next('si');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$this->cachingStrategy->closeCache();
|
||||||
|
|
||||||
} catch (XMLProcessingException $exception) {
|
} catch (XMLProcessingException $exception) {
|
||||||
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
|
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->cachingStrategy->closeCache();
|
|
||||||
|
|
||||||
$xmlReader->close();
|
$xmlReader->close();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,7 +122,7 @@ class SharedStringsHelper
|
|||||||
* Returns the shared strings unique count, as specified in <sst> tag.
|
* Returns the shared strings unique count, as specified in <sst> tag.
|
||||||
*
|
*
|
||||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
|
||||||
* @return int Number of unique shared strings in the sharedStrings.xml file
|
* @return int|null Number of unique shared strings in the sharedStrings.xml file
|
||||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
|
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
|
||||||
*/
|
*/
|
||||||
protected function getSharedStringsUniqueCount($xmlReader)
|
protected function getSharedStringsUniqueCount($xmlReader)
|
||||||
@@ -154,13 +134,21 @@ class SharedStringsHelper
|
|||||||
$xmlReader->read();
|
$xmlReader->read();
|
||||||
}
|
}
|
||||||
|
|
||||||
return intval($xmlReader->getAttribute('uniqueCount'));
|
$uniqueCount = $xmlReader->getAttribute('uniqueCount');
|
||||||
|
|
||||||
|
// some software do not add the "uniqueCount" attribute but only use the "count" one
|
||||||
|
// @see https://github.com/box/spout/issues/254
|
||||||
|
if ($uniqueCount === null) {
|
||||||
|
$uniqueCount = $xmlReader->getAttribute('count');
|
||||||
|
}
|
||||||
|
|
||||||
|
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the best shared strings caching strategy.
|
* Returns the best shared strings caching strategy.
|
||||||
*
|
*
|
||||||
* @param int $sharedStringsUniqueCount
|
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||||
* @return CachingStrategyInterface
|
* @return CachingStrategyInterface
|
||||||
*/
|
*/
|
||||||
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
|
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
|
||||||
@@ -169,6 +157,31 @@ class SharedStringsHelper
|
|||||||
->getBestCachingStrategy($sharedStringsUniqueCount, $this->tempFolder);
|
->getBestCachingStrategy($sharedStringsUniqueCount, $this->tempFolder);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes the shared strings item XML node which the given XML reader is positioned on.
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader
|
||||||
|
* @param int $sharedStringIndex Index of the processed shared strings item
|
||||||
|
* @param \Box\Spout\Common\Escaper\XLSX $escaper Helper to escape values
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper)
|
||||||
|
{
|
||||||
|
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
|
||||||
|
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
|
||||||
|
|
||||||
|
// removes nodes that should not be read, like the pronunciation of the Kanji characters
|
||||||
|
$cleanNode = $this->removeSuperfluousTextNodes($node);
|
||||||
|
|
||||||
|
// find all text nodes "t"; there can be multiple if the cell contains formatting
|
||||||
|
$textNodes = $cleanNode->xpath('//ns:t');
|
||||||
|
|
||||||
|
$textValue = $this->extractTextValueForNodes($textNodes);
|
||||||
|
$unescapedTextValue = $escaper->unescape($textValue);
|
||||||
|
|
||||||
|
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns a SimpleXMLElement node from the current node in the given XMLReader instance.
|
* Returns a SimpleXMLElement node from the current node in the given XMLReader instance.
|
||||||
* This is to simplify the parsing of the subtree.
|
* This is to simplify the parsing of the subtree.
|
||||||
@@ -200,6 +213,8 @@ class SharedStringsHelper
|
|||||||
{
|
{
|
||||||
$tagsToRemove = [
|
$tagsToRemove = [
|
||||||
'rPh', // Pronunciation of the text
|
'rPh', // Pronunciation of the text
|
||||||
|
'pPr', // Paragraph Properties / Previous Paragraph Properties
|
||||||
|
'rPr', // Run Properties for the Paragraph Mark / Previous Run Properties for the Paragraph Mark
|
||||||
];
|
];
|
||||||
|
|
||||||
foreach ($tagsToRemove as $tagToRemove) {
|
foreach ($tagsToRemove as $tagToRemove) {
|
||||||
@@ -210,6 +225,29 @@ class SharedStringsHelper
|
|||||||
return $parentNode;
|
return $parentNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $textNodes Text XML nodes ("<t>")
|
||||||
|
* @return string The value associated with the given text node(s)
|
||||||
|
*/
|
||||||
|
protected function extractTextValueForNodes($textNodes)
|
||||||
|
{
|
||||||
|
$textValue = '';
|
||||||
|
|
||||||
|
foreach ($textNodes as $nodeIndex => $textNode) {
|
||||||
|
if ($nodeIndex !== 0) {
|
||||||
|
// add a space between each "t" node
|
||||||
|
$textValue .= ' ';
|
||||||
|
}
|
||||||
|
|
||||||
|
$textNodeAsString = $textNode->__toString();
|
||||||
|
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
|
||||||
|
|
||||||
|
$textValue .= ($shouldPreserveWhitespace) ? $textNodeAsString : trim($textNodeAsString);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $textValue;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Reader\XLSX\Helper;
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
use Box\Spout\Reader\XLSX\Sheet;
|
use Box\Spout\Reader\XLSX\Sheet;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -13,45 +13,32 @@ use Box\Spout\Reader\XLSX\Sheet;
|
|||||||
*/
|
*/
|
||||||
class SheetHelper
|
class SheetHelper
|
||||||
{
|
{
|
||||||
/** Extension for XML files */
|
|
||||||
const XML_EXTENSION = '.xml';
|
|
||||||
|
|
||||||
/** Paths of XML files relative to the XLSX file root */
|
/** Paths of XML files relative to the XLSX file root */
|
||||||
const CONTENT_TYPES_XML_FILE_PATH = '[Content_Types].xml';
|
|
||||||
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
|
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
|
||||||
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
|
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
|
||||||
|
|
||||||
/** Namespaces for the XML files */
|
|
||||||
const MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML = 'http://schemas.openxmlformats.org/package/2006/content-types';
|
|
||||||
const MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
|
|
||||||
const MAIN_NAMESPACE_FOR_WORKBOOK_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
|
||||||
|
|
||||||
/** Value of the Override attribute used in [Content_Types].xml to define sheets */
|
|
||||||
const OVERRIDE_CONTENT_TYPES_ATTRIBUTE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml';
|
|
||||||
|
|
||||||
/** @var string Path of the XLSX file being read */
|
/** @var string Path of the XLSX file being read */
|
||||||
protected $filePath;
|
protected $filePath;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\XLSX\ReaderOptions Reader's current options */
|
||||||
|
protected $options;
|
||||||
|
|
||||||
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
|
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
|
||||||
protected $sharedStringsHelper;
|
protected $sharedStringsHelper;
|
||||||
|
|
||||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||||
protected $globalFunctionsHelper;
|
protected $globalFunctionsHelper;
|
||||||
|
|
||||||
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml.rels file */
|
|
||||||
protected $workbookXMLRelsAsXMLElement;
|
|
||||||
|
|
||||||
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml file */
|
|
||||||
protected $workbookXMLAsXMLElement;
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the XLSX file being read
|
* @param string $filePath Path of the XLSX file being read
|
||||||
|
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||||
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings
|
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper)
|
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||||
{
|
{
|
||||||
$this->filePath = $filePath;
|
$this->filePath = $filePath;
|
||||||
|
$this->options = $options;
|
||||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||||
}
|
}
|
||||||
@@ -65,136 +52,81 @@ class SheetHelper
|
|||||||
public function getSheets()
|
public function getSheets()
|
||||||
{
|
{
|
||||||
$sheets = [];
|
$sheets = [];
|
||||||
|
$sheetIndex = 0;
|
||||||
|
|
||||||
$contentTypesAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
$xmlReader = new XMLReader();
|
||||||
self::CONTENT_TYPES_XML_FILE_PATH,
|
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
|
||||||
self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML
|
while ($xmlReader->read()) {
|
||||||
);
|
if ($xmlReader->isPositionedOnStartingNode('sheet')) {
|
||||||
|
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex);
|
||||||
|
$sheetIndex++;
|
||||||
|
} else if ($xmlReader->isPositionedOnEndingNode('sheets')) {
|
||||||
|
// stop reading once all sheets have been read
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// find all nodes defining a sheet
|
$xmlReader->close();
|
||||||
$sheetNodes = $contentTypesAsXMLElement->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]');
|
|
||||||
$numSheetNodes = count($sheetNodes);
|
|
||||||
|
|
||||||
for ($i = 0; $i < $numSheetNodes; $i++) {
|
|
||||||
$sheetNode = $sheetNodes[$i];
|
|
||||||
$sheetDataXMLFilePath = $sheetNode->getAttribute('PartName');
|
|
||||||
|
|
||||||
$sheets[] = $this->getSheetFromXML($sheetDataXMLFilePath, $i);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return $sheets;
|
return $sheets;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns an instance of a sheet, given the path of its data XML file.
|
* Returns an instance of a sheet, given the XML node describing the sheet - from "workbook.xml".
|
||||||
* We first look at "xl/_rels/workbook.xml.rels" to find the relationship ID of the sheet.
|
* We can find the XML file path describing the sheet inside "workbook.xml.res", by mapping with the sheet ID
|
||||||
* Then we look at "xl/worbook.xml" to find the sheet entry associated to the found ID.
|
* ("r:id" in "workbook.xml", "Id" in "workbook.xml.res").
|
||||||
* The entry contains the ID and name of the sheet.
|
|
||||||
*
|
*
|
||||||
* If this piece of data can't be found by parsing the different XML files, the ID will default
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReaderOnSheetNode XML Reader instance, pointing on the node describing the sheet, as defined in "workbook.xml"
|
||||||
* to the sheet index, based on order in [Content_Types].xml. Similarly, the sheet's name will
|
* @param int $sheetIndexZeroBased Index of the sheet, based on order of appearance in the workbook (zero-based)
|
||||||
* default to the data sheet XML file name ("xl/worksheets/sheet2.xml" => "sheet2").
|
|
||||||
*
|
|
||||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
|
||||||
* @param int $sheetIndexZeroBased Index of the sheet, based on order in [Content_Types].xml (zero-based)
|
|
||||||
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
|
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
|
||||||
*/
|
*/
|
||||||
protected function getSheetFromXML($sheetDataXMLFilePath, $sheetIndexZeroBased)
|
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased)
|
||||||
{
|
{
|
||||||
$sheetName = $this->getDefaultSheetName($sheetDataXMLFilePath);
|
$sheetId = $xmlReaderOnSheetNode->getAttribute('r:id');
|
||||||
|
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute('name');
|
||||||
/*
|
|
||||||
* In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml"
|
|
||||||
* In workbook.xml.rels, it is only "worksheets/sheet1.xml"
|
|
||||||
*/
|
|
||||||
$sheetDataXMLFilePathInWorkbookXMLRels = ltrim($sheetDataXMLFilePath, '/xl/');
|
|
||||||
|
|
||||||
// find the node associated to the given file path
|
|
||||||
$workbookXMLResElement = $this->getWorkbookXMLRelsAsXMLElement();
|
|
||||||
$relationshipNodes = $workbookXMLResElement->xpath('//ns:Relationship[@Target="' . $sheetDataXMLFilePathInWorkbookXMLRels . '"]');
|
|
||||||
|
|
||||||
if (count($relationshipNodes) === 1) {
|
|
||||||
$relationshipNode = $relationshipNodes[0];
|
|
||||||
$sheetId = $relationshipNode->getAttribute('Id');
|
|
||||||
|
|
||||||
$workbookXMLElement = $this->getWorkbookXMLAsXMLElement();
|
|
||||||
$sheetNodes = $workbookXMLElement->xpath('//ns:sheet[@r:id="' . $sheetId . '"]');
|
|
||||||
|
|
||||||
if (count($sheetNodes) === 1) {
|
|
||||||
$sheetNode = $sheetNodes[0];
|
|
||||||
$escapedSheetName = $sheetNode->getAttribute('name');
|
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
$sheetName = $escaper->unescape($escapedSheetName);
|
$sheetName = $escaper->unescape($escapedSheetName);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $sheetIndexZeroBased, $sheetName);
|
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
|
||||||
|
|
||||||
|
return new Sheet($this->filePath, $sheetDataXMLFilePath, $sheetIndexZeroBased, $sheetName, $this->options, $this->sharedStringsHelper);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the default name of the sheet whose data is located
|
* @param string $sheetId The sheet ID, as defined in "workbook.xml"
|
||||||
* at the given path.
|
* @return string The XML file path describing the sheet inside "workbook.xml.res", for the given sheet ID
|
||||||
*
|
|
||||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file
|
|
||||||
* @return string The default sheet name
|
|
||||||
*/
|
*/
|
||||||
protected function getDefaultSheetName($sheetDataXMLFilePath)
|
protected function getSheetDataXMLFilePathForSheetId($sheetId)
|
||||||
{
|
{
|
||||||
return $this->globalFunctionsHelper->basename($sheetDataXMLFilePath, self::XML_EXTENSION);
|
$sheetDataXMLFilePath = '';
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
// find the file path of the sheet, by looking at the "workbook.xml.res" file
|
||||||
* Returns a representation of the workbook.xml.rels file, ready to be parsed.
|
$xmlReader = new XMLReader();
|
||||||
* The returned value is cached.
|
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_RELS_FILE_PATH)) {
|
||||||
*
|
while ($xmlReader->read()) {
|
||||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
|
if ($xmlReader->isPositionedOnStartingNode('Relationship')) {
|
||||||
*/
|
$relationshipSheetId = $xmlReader->getAttribute('Id');
|
||||||
protected function getWorkbookXMLRelsAsXMLElement()
|
|
||||||
{
|
|
||||||
if (!$this->workbookXMLRelsAsXMLElement) {
|
|
||||||
$this->workbookXMLRelsAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
|
||||||
self::WORKBOOK_XML_RELS_FILE_PATH,
|
|
||||||
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workbookXMLRelsAsXMLElement;
|
if ($relationshipSheetId === $sheetId) {
|
||||||
}
|
// In workbook.xml.rels, it is only "worksheets/sheet1.xml"
|
||||||
|
// In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml"
|
||||||
|
$sheetDataXMLFilePath = $xmlReader->getAttribute('Target');
|
||||||
|
|
||||||
/**
|
// sometimes, the sheet data file path already contains "/xl/"...
|
||||||
* Returns a representation of the workbook.xml file, ready to be parsed.
|
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
|
||||||
* The returned value is cached.
|
$sheetDataXMLFilePath = '/xl/' . $sheetDataXMLFilePath;
|
||||||
*
|
break;
|
||||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
|
}
|
||||||
*/
|
}
|
||||||
protected function getWorkbookXMLAsXMLElement()
|
}
|
||||||
{
|
}
|
||||||
if (!$this->workbookXMLAsXMLElement) {
|
|
||||||
$this->workbookXMLAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
$xmlReader->close();
|
||||||
self::WORKBOOK_XML_FILE_PATH,
|
}
|
||||||
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML
|
|
||||||
);
|
return $sheetDataXMLFilePath;
|
||||||
}
|
|
||||||
|
|
||||||
return $this->workbookXMLAsXMLElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Loads the contents of the given file in an XML parser and register the given XPath namespace.
|
|
||||||
*
|
|
||||||
* @param string $xmlFilePath The path of the XML file inside the XLSX file
|
|
||||||
* @param string $mainNamespace The main XPath namespace to register
|
|
||||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement The XML element representing the file
|
|
||||||
*/
|
|
||||||
protected function getFileAsXMLElementWithNamespace($xmlFilePath, $mainNamespace)
|
|
||||||
{
|
|
||||||
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $xmlFilePath);
|
|
||||||
|
|
||||||
$xmlElement = new SimpleXMLElement($xmlContents);
|
|
||||||
$xmlElement->registerXPathNamespace('ns', $mainNamespace);
|
|
||||||
|
|
||||||
return $xmlElement;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,330 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StyleHelper
|
||||||
|
* This class provides helper functions related to XLSX styles
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX\Helper
|
||||||
|
*/
|
||||||
|
class StyleHelper
|
||||||
|
{
|
||||||
|
/** Paths of XML files relative to the XLSX file root */
|
||||||
|
const STYLES_XML_FILE_PATH = 'xl/styles.xml';
|
||||||
|
|
||||||
|
/** Nodes used to find relevant information in the styles XML file */
|
||||||
|
const XML_NODE_NUM_FMTS = 'numFmts';
|
||||||
|
const XML_NODE_NUM_FMT = 'numFmt';
|
||||||
|
const XML_NODE_CELL_XFS = 'cellXfs';
|
||||||
|
const XML_NODE_XF = 'xf';
|
||||||
|
|
||||||
|
/** Attributes used to find relevant information in the styles XML file */
|
||||||
|
const XML_ATTRIBUTE_NUM_FMT_ID = 'numFmtId';
|
||||||
|
const XML_ATTRIBUTE_FORMAT_CODE = 'formatCode';
|
||||||
|
const XML_ATTRIBUTE_APPLY_NUMBER_FORMAT = 'applyNumberFormat';
|
||||||
|
|
||||||
|
/** By convention, default style ID is 0 */
|
||||||
|
const DEFAULT_STYLE_ID = 0;
|
||||||
|
|
||||||
|
const NUMBER_FORMAT_GENERAL = 'General';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @see https://msdn.microsoft.com/en-us/library/ff529597(v=office.12).aspx
|
||||||
|
* @var array Mapping between built-in numFmtId and the associated format - for dates only
|
||||||
|
*/
|
||||||
|
protected static $builtinNumFmtIdToNumFormatMapping = [
|
||||||
|
14 => 'm/d/yyyy', // @NOTE: ECMA spec is 'mm-dd-yy'
|
||||||
|
15 => 'd-mmm-yy',
|
||||||
|
16 => 'd-mmm',
|
||||||
|
17 => 'mmm-yy',
|
||||||
|
18 => 'h:mm AM/PM',
|
||||||
|
19 => 'h:mm:ss AM/PM',
|
||||||
|
20 => 'h:mm',
|
||||||
|
21 => 'h:mm:ss',
|
||||||
|
22 => 'm/d/yyyy h:mm', // @NOTE: ECMA spec is 'm/d/yy h:mm',
|
||||||
|
45 => 'mm:ss',
|
||||||
|
46 => '[h]:mm:ss',
|
||||||
|
47 => 'mm:ss.0', // @NOTE: ECMA spec is 'mmss.0',
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var string Path of the XLSX file being read */
|
||||||
|
protected $filePath;
|
||||||
|
|
||||||
|
/** @var array Array containing the IDs of built-in number formats indicating a date */
|
||||||
|
protected $builtinNumFmtIdIndicatingDates;
|
||||||
|
|
||||||
|
/** @var array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */
|
||||||
|
protected $customNumberFormats;
|
||||||
|
|
||||||
|
/** @var array Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */
|
||||||
|
protected $stylesAttributes;
|
||||||
|
|
||||||
|
/** @var array Cache containing a mapping NUM_FMT_ID => IS_DATE_FORMAT. Used to avoid lots of recalculations */
|
||||||
|
protected $numFmtIdToIsDateFormatCache = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $filePath Path of the XLSX file being read
|
||||||
|
*/
|
||||||
|
public function __construct($filePath)
|
||||||
|
{
|
||||||
|
$this->filePath = $filePath;
|
||||||
|
$this->builtinNumFmtIdIndicatingDates = array_keys(self::$builtinNumFmtIdToNumFormatMapping);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the style with the given ID should consider
|
||||||
|
* numeric values as timestamps and format the cell as a date.
|
||||||
|
*
|
||||||
|
* @param int $styleId Zero-based style ID
|
||||||
|
* @return bool Whether the cell with the given cell should display a date instead of a numeric value
|
||||||
|
*/
|
||||||
|
public function shouldFormatNumericValueAsDate($styleId)
|
||||||
|
{
|
||||||
|
$stylesAttributes = $this->getStylesAttributes();
|
||||||
|
|
||||||
|
// Default style (0) does not format numeric values as timestamps. Only custom styles do.
|
||||||
|
// Also if the style ID does not exist in the styles.xml file, format as numeric value.
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
if ($styleId === self::DEFAULT_STYLE_ID || !isset($stylesAttributes[$styleId])) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$styleAttributes = $stylesAttributes[$styleId];
|
||||||
|
|
||||||
|
return $this->doesStyleIndicateDate($styleAttributes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads the styles.xml file and extract the relevant information from the file.
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function extractRelevantInfo()
|
||||||
|
{
|
||||||
|
$this->customNumberFormats = [];
|
||||||
|
$this->stylesAttributes = [];
|
||||||
|
|
||||||
|
$xmlReader = new XMLReader();
|
||||||
|
|
||||||
|
if ($xmlReader->openFileInZip($this->filePath, self::STYLES_XML_FILE_PATH)) {
|
||||||
|
while ($xmlReader->read()) {
|
||||||
|
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
|
||||||
|
$this->extractNumberFormats($xmlReader);
|
||||||
|
|
||||||
|
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
|
||||||
|
$this->extractStyleAttributes($xmlReader);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts number formats from the "numFmt" nodes.
|
||||||
|
* For simplicity, the styles attributes are kept in memory. This is possible thanks
|
||||||
|
* to the reuse of formats. So 1 million cells should not use 1 million formats.
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "numFmts" node
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function extractNumberFormats($xmlReader)
|
||||||
|
{
|
||||||
|
while ($xmlReader->read()) {
|
||||||
|
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMT)) {
|
||||||
|
$numFmtId = intval($xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
|
||||||
|
$formatCode = $xmlReader->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
|
||||||
|
$this->customNumberFormats[$numFmtId] = $formatCode;
|
||||||
|
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_NUM_FMTS)) {
|
||||||
|
// Once done reading "numFmts" node's children
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extracts style attributes from the "xf" nodes, inside the "cellXfs" section.
|
||||||
|
* For simplicity, the styles attributes are kept in memory. This is possible thanks
|
||||||
|
* to the reuse of styles. So 1 million cells should not use 1 million styles.
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "cellXfs" node
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function extractStyleAttributes($xmlReader)
|
||||||
|
{
|
||||||
|
while ($xmlReader->read()) {
|
||||||
|
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_XF)) {
|
||||||
|
$numFmtId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID);
|
||||||
|
$normalizedNumFmtId = ($numFmtId !== null) ? intval($numFmtId) : null;
|
||||||
|
|
||||||
|
$applyNumberFormat = $xmlReader->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT);
|
||||||
|
$normalizedApplyNumberFormat = ($applyNumberFormat !== null) ? !!$applyNumberFormat : null;
|
||||||
|
|
||||||
|
$this->stylesAttributes[] = [
|
||||||
|
self::XML_ATTRIBUTE_NUM_FMT_ID => $normalizedNumFmtId,
|
||||||
|
self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => $normalizedApplyNumberFormat,
|
||||||
|
];
|
||||||
|
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_CELL_XFS)) {
|
||||||
|
// Once done reading "cellXfs" node's children
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array The custom number formats
|
||||||
|
*/
|
||||||
|
protected function getCustomNumberFormats()
|
||||||
|
{
|
||||||
|
if (!isset($this->customNumberFormats)) {
|
||||||
|
$this->extractRelevantInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->customNumberFormats;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array The styles attributes
|
||||||
|
*/
|
||||||
|
protected function getStylesAttributes()
|
||||||
|
{
|
||||||
|
if (!isset($this->stylesAttributes)) {
|
||||||
|
$this->extractRelevantInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->stylesAttributes;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $styleAttributes Array containing the style attributes (2 keys: "applyNumberFormat" and "numFmtId")
|
||||||
|
* @return bool Whether the style with the given attributes indicates that the number is a date
|
||||||
|
*/
|
||||||
|
protected function doesStyleIndicateDate($styleAttributes)
|
||||||
|
{
|
||||||
|
$applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
|
||||||
|
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
|
||||||
|
|
||||||
|
// A style may apply a date format if it has:
|
||||||
|
// - "applyNumberFormat" attribute not set to "false"
|
||||||
|
// - "numFmtId" attribute set
|
||||||
|
// This is a preliminary check, as having "numFmtId" set just means the style should apply a specific number format,
|
||||||
|
// but this is not necessarily a date.
|
||||||
|
if ($applyNumberFormat === false || $numFmtId === null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->doesNumFmtIdIndicateDate($numFmtId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the number format ID indicates that the number is a date.
|
||||||
|
* The result is cached to avoid recomputing the same thing over and over, as
|
||||||
|
* "numFmtId" attributes can be shared between multiple styles.
|
||||||
|
*
|
||||||
|
* @param int $numFmtId
|
||||||
|
* @return bool Whether the number format ID indicates that the number is a date
|
||||||
|
*/
|
||||||
|
protected function doesNumFmtIdIndicateDate($numFmtId)
|
||||||
|
{
|
||||||
|
if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) {
|
||||||
|
$formatCode = $this->getFormatCodeForNumFmtId($numFmtId);
|
||||||
|
|
||||||
|
$this->numFmtIdToIsDateFormatCache[$numFmtId] = (
|
||||||
|
$this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
|
||||||
|
$this->isFormatCodeCustomDateFormat($formatCode)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->numFmtIdToIsDateFormatCache[$numFmtId];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $numFmtId
|
||||||
|
* @return string|null The custom number format or NULL if none defined for the given numFmtId
|
||||||
|
*/
|
||||||
|
protected function getFormatCodeForNumFmtId($numFmtId)
|
||||||
|
{
|
||||||
|
$customNumberFormats = $this->getCustomNumberFormats();
|
||||||
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
return (isset($customNumberFormats[$numFmtId])) ? $customNumberFormats[$numFmtId] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $numFmtId
|
||||||
|
* @return bool Whether the number format ID indicates that the number is a date
|
||||||
|
*/
|
||||||
|
protected function isNumFmtIdBuiltInDateFormat($numFmtId)
|
||||||
|
{
|
||||||
|
return in_array($numFmtId, $this->builtinNumFmtIdIndicatingDates);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $formatCode
|
||||||
|
* @return bool Whether the given format code indicates that the number is a date
|
||||||
|
*/
|
||||||
|
protected function isFormatCodeCustomDateFormat($formatCode)
|
||||||
|
{
|
||||||
|
// if no associated format code or if using the default "General" format
|
||||||
|
if ($formatCode === null || strcasecmp($formatCode, self::NUMBER_FORMAT_GENERAL) === 0) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->isFormatCodeMatchingDateFormatPattern($formatCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $formatCode
|
||||||
|
* @return bool Whether the given format code matches a date format pattern
|
||||||
|
*/
|
||||||
|
protected function isFormatCodeMatchingDateFormatPattern($formatCode)
|
||||||
|
{
|
||||||
|
// Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\")
|
||||||
|
$pattern = '((?<!\\\)\[.+?(?<!\\\)\])';
|
||||||
|
$formatCode = preg_replace($pattern, '', $formatCode);
|
||||||
|
|
||||||
|
// custom date formats contain specific characters to represent the date:
|
||||||
|
// e - yy - m - d - h - s
|
||||||
|
// and all of their variants (yyyy - mm - dd...)
|
||||||
|
$dateFormatCharacters = ['e', 'yy', 'm', 'd', 'h', 's'];
|
||||||
|
|
||||||
|
$hasFoundDateFormatCharacter = false;
|
||||||
|
foreach ($dateFormatCharacters as $dateFormatCharacter) {
|
||||||
|
// character not preceded by "\" (case insensitive)
|
||||||
|
$pattern = '/(?<!\\\)' . $dateFormatCharacter . '/i';
|
||||||
|
|
||||||
|
if (preg_match($pattern, $formatCode)) {
|
||||||
|
$hasFoundDateFormatCharacter = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $hasFoundDateFormatCharacter;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the format as defined in "styles.xml" of the given style.
|
||||||
|
* NOTE: It is assumed that the style DOES have a number format associated to it.
|
||||||
|
*
|
||||||
|
* @param int $styleId Zero-based style ID
|
||||||
|
* @return string The number format code associated with the given style
|
||||||
|
*/
|
||||||
|
public function getNumberFormatCode($styleId)
|
||||||
|
{
|
||||||
|
$stylesAttributes = $this->getStylesAttributes();
|
||||||
|
$styleAttributes = $stylesAttributes[$styleId];
|
||||||
|
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
|
||||||
|
|
||||||
|
if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
|
||||||
|
$numberFormatCode = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
|
||||||
|
} else {
|
||||||
|
$customNumberFormats = $this->getCustomNumberFormats();
|
||||||
|
$numberFormatCode = $customNumberFormats[$numFmtId];
|
||||||
|
}
|
||||||
|
|
||||||
|
return $numberFormatCode;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,9 +14,6 @@ use Box\Spout\Reader\XLSX\Helper\SharedStringsHelper;
|
|||||||
*/
|
*/
|
||||||
class Reader extends AbstractReader
|
class Reader extends AbstractReader
|
||||||
{
|
{
|
||||||
/** @var string Temporary folder where the temporary files will be created */
|
|
||||||
protected $tempFolder;
|
|
||||||
|
|
||||||
/** @var \ZipArchive */
|
/** @var \ZipArchive */
|
||||||
protected $zip;
|
protected $zip;
|
||||||
|
|
||||||
@@ -27,16 +24,39 @@ class Reader extends AbstractReader
|
|||||||
protected $sheetIterator;
|
protected $sheetIterator;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the reader's current options
|
||||||
|
*
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
protected function getOptions()
|
||||||
|
{
|
||||||
|
if (!isset($this->options)) {
|
||||||
|
$this->options = new ReaderOptions();
|
||||||
|
}
|
||||||
|
return $this->options;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $tempFolder Temporary folder where the temporary files will be created
|
* @param string $tempFolder Temporary folder where the temporary files will be created
|
||||||
* @return Reader
|
* @return Reader
|
||||||
*/
|
*/
|
||||||
public function setTempFolder($tempFolder)
|
public function setTempFolder($tempFolder)
|
||||||
{
|
{
|
||||||
$this->tempFolder = $tempFolder;
|
$this->getOptions()->setTempFolder($tempFolder);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether stream wrappers are supported
|
||||||
|
*
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
protected function doesSupportStreamWrapper()
|
||||||
|
{
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the file at the given file path to make it ready to be read.
|
* Opens the file at the given file path to make it ready to be read.
|
||||||
* It also parses the sharedStrings.xml file to get all the shared strings available in memory
|
* It also parses the sharedStrings.xml file to get all the shared strings available in memory
|
||||||
@@ -52,14 +72,14 @@ class Reader extends AbstractReader
|
|||||||
$this->zip = new \ZipArchive();
|
$this->zip = new \ZipArchive();
|
||||||
|
|
||||||
if ($this->zip->open($filePath) === true) {
|
if ($this->zip->open($filePath) === true) {
|
||||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
|
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->getOptions()->getTempFolder());
|
||||||
|
|
||||||
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
||||||
// Extracts all the strings from the sheets for easy access in the future
|
// Extracts all the strings from the sheets for easy access in the future
|
||||||
$this->sharedStringsHelper->extractSharedStrings();
|
$this->sharedStringsHelper->extractSharedStrings();
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper);
|
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions(), $this->sharedStringsHelper, $this->globalFunctionsHelper);
|
||||||
} else {
|
} else {
|
||||||
throw new IOException("Could not open $filePath for reading.");
|
throw new IOException("Could not open $filePath for reading.");
|
||||||
}
|
}
|
||||||
@@ -70,7 +90,7 @@ class Reader extends AbstractReader
|
|||||||
*
|
*
|
||||||
* @return SheetIterator To iterate over sheets
|
* @return SheetIterator To iterate over sheets
|
||||||
*/
|
*/
|
||||||
public function getConcreteSheetIterator()
|
protected function getConcreteSheetIterator()
|
||||||
{
|
{
|
||||||
return $this->sheetIterator;
|
return $this->sheetIterator;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ReaderOptions
|
||||||
|
* This class is used to customize the reader's behavior
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX
|
||||||
|
*/
|
||||||
|
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
|
||||||
|
{
|
||||||
|
/** @var string|null Temporary folder where the temporary files will be created */
|
||||||
|
protected $tempFolder = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string|null Temporary folder where the temporary files will be created
|
||||||
|
*/
|
||||||
|
public function getTempFolder()
|
||||||
|
{
|
||||||
|
return $this->tempFolder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|null $tempFolder Temporary folder where the temporary files will be created
|
||||||
|
* @return ReaderOptions
|
||||||
|
*/
|
||||||
|
public function setTempFolder($tempFolder)
|
||||||
|
{
|
||||||
|
$this->tempFolder = $tempFolder;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,8 @@ use Box\Spout\Reader\IteratorInterface;
|
|||||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
use Box\Spout\Reader\XLSX\Helper\CellHelper;
|
use Box\Spout\Reader\XLSX\Helper\CellHelper;
|
||||||
use Box\Spout\Reader\XLSX\Helper\CellValueFormatter;
|
use Box\Spout\Reader\XLSX\Helper\CellValueFormatter;
|
||||||
|
use Box\Spout\Reader\XLSX\Helper\StyleHelper;
|
||||||
|
use Box\Spout\Reader\Common\XMLProcessor;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class RowIterator
|
* Class RowIterator
|
||||||
@@ -25,6 +27,7 @@ class RowIterator implements IteratorInterface
|
|||||||
/** Definition of XML attributes used to parse data */
|
/** Definition of XML attributes used to parse data */
|
||||||
const XML_ATTRIBUTE_REF = 'ref';
|
const XML_ATTRIBUTE_REF = 'ref';
|
||||||
const XML_ATTRIBUTE_SPANS = 'spans';
|
const XML_ATTRIBUTE_SPANS = 'spans';
|
||||||
|
const XML_ATTRIBUTE_ROW_INDEX = 'r';
|
||||||
const XML_ATTRIBUTE_CELL_INDEX = 'r';
|
const XML_ATTRIBUTE_CELL_INDEX = 'r';
|
||||||
|
|
||||||
/** @var string Path of the XLSX file being read */
|
/** @var string Path of the XLSX file being read */
|
||||||
@@ -36,12 +39,24 @@ class RowIterator implements IteratorInterface
|
|||||||
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||||
protected $xmlReader;
|
protected $xmlReader;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
|
||||||
|
protected $xmlProcessor;
|
||||||
|
|
||||||
/** @var Helper\CellValueFormatter Helper to format cell values */
|
/** @var Helper\CellValueFormatter Helper to format cell values */
|
||||||
protected $cellValueFormatter;
|
protected $cellValueFormatter;
|
||||||
|
|
||||||
/** @var int Number of read rows */
|
/** @var Helper\StyleHelper $styleHelper Helper to work with styles */
|
||||||
|
protected $styleHelper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* TODO: This variable can be deleted when row indices get preserved
|
||||||
|
* @var int Number of read rows
|
||||||
|
*/
|
||||||
protected $numReadRows = 0;
|
protected $numReadRows = 0;
|
||||||
|
|
||||||
|
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
|
||||||
|
protected $currentlyProcessedRowData = [];
|
||||||
|
|
||||||
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
|
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
|
||||||
protected $rowDataBuffer = null;
|
protected $rowDataBuffer = null;
|
||||||
|
|
||||||
@@ -51,18 +66,43 @@ class RowIterator implements IteratorInterface
|
|||||||
/** @var int The number of columns the sheet has (0 meaning undefined) */
|
/** @var int The number of columns the sheet has (0 meaning undefined) */
|
||||||
protected $numColumns = 0;
|
protected $numColumns = 0;
|
||||||
|
|
||||||
|
/** @var bool Whether empty rows should be returned or skipped */
|
||||||
|
protected $shouldPreserveEmptyRows;
|
||||||
|
|
||||||
|
/** @var int Last row index processed (one-based) */
|
||||||
|
protected $lastRowIndexProcessed = 0;
|
||||||
|
|
||||||
|
/** @var int Row index to be processed next (one-based) */
|
||||||
|
protected $nextRowIndexToBeProcessed = 0;
|
||||||
|
|
||||||
|
/** @var int Last column index processed (zero-based) */
|
||||||
|
protected $lastColumnIndexProcessed = -1;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the XLSX file being read
|
* @param string $filePath Path of the XLSX file being read
|
||||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
||||||
|
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||||
* @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
|
* @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper)
|
public function __construct($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper)
|
||||||
{
|
{
|
||||||
$this->filePath = $filePath;
|
$this->filePath = $filePath;
|
||||||
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
|
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
|
||||||
|
|
||||||
$this->xmlReader = new XMLReader();
|
$this->xmlReader = new XMLReader();
|
||||||
$this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper);
|
|
||||||
|
$this->styleHelper = new StyleHelper($filePath);
|
||||||
|
$this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $options->shouldFormatDates());
|
||||||
|
|
||||||
|
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
|
||||||
|
|
||||||
|
// Register all callbacks to process different nodes when reading the XML file
|
||||||
|
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_DIMENSION, XMLProcessor::NODE_TYPE_START, [$this, 'processDimensionStartingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
|
||||||
|
$this->xmlProcessor->registerCallback(self::XML_NODE_WORKSHEET, XMLProcessor::NODE_TYPE_END, [$this, 'processWorksheetEndingNode']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -94,6 +134,8 @@ class RowIterator implements IteratorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
$this->numReadRows = 0;
|
$this->numReadRows = 0;
|
||||||
|
$this->lastRowIndexProcessed = 0;
|
||||||
|
$this->nextRowIndexToBeProcessed = 0;
|
||||||
$this->rowDataBuffer = null;
|
$this->rowDataBuffer = null;
|
||||||
$this->hasReachedEndOfFile = false;
|
$this->hasReachedEndOfFile = false;
|
||||||
$this->numColumns = 0;
|
$this->numColumns = 0;
|
||||||
@@ -105,7 +147,7 @@ class RowIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -113,7 +155,7 @@ class RowIterator implements IteratorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Move forward to next element. Empty rows will be skipped.
|
* Move forward to next element. Reads data describing the next unprocessed row.
|
||||||
* @link http://php.net/manual/en/iterator.next.php
|
* @link http://php.net/manual/en/iterator.next.php
|
||||||
*
|
*
|
||||||
* @return void
|
* @return void
|
||||||
@@ -122,57 +164,172 @@ class RowIterator implements IteratorInterface
|
|||||||
*/
|
*/
|
||||||
public function next()
|
public function next()
|
||||||
{
|
{
|
||||||
$rowData = [];
|
$this->nextRowIndexToBeProcessed++;
|
||||||
|
|
||||||
|
if ($this->doesNeedDataForNextRowToBeProcessed()) {
|
||||||
|
$this->readDataForNextRow();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether we need data for the next row to be processed.
|
||||||
|
* We don't need to read data if:
|
||||||
|
* we have already read at least one row
|
||||||
|
* AND
|
||||||
|
* we need to preserve empty rows
|
||||||
|
* AND
|
||||||
|
* the last row that was read is not the row that need to be processed
|
||||||
|
* (i.e. if we need to return empty rows)
|
||||||
|
*
|
||||||
|
* @return bool Whether we need data for the next row to be processed.
|
||||||
|
*/
|
||||||
|
protected function doesNeedDataForNextRowToBeProcessed()
|
||||||
|
{
|
||||||
|
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
!$hasReadAtLeastOneRow ||
|
||||||
|
!$this->shouldPreserveEmptyRows ||
|
||||||
|
$this->lastRowIndexProcessed < $this->nextRowIndexToBeProcessed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
|
||||||
|
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
|
||||||
|
*/
|
||||||
|
protected function readDataForNextRow()
|
||||||
|
{
|
||||||
|
$this->currentlyProcessedRowData = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while ($this->xmlReader->read()) {
|
$this->xmlProcessor->readUntilStopped();
|
||||||
if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_DIMENSION)) {
|
} catch (XMLProcessingException $exception) {
|
||||||
|
throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->rowDataBuffer = $this->currentlyProcessedRowData;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<dimension>" starting node
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processDimensionStartingNode($xmlReader)
|
||||||
|
{
|
||||||
// Read dimensions of the sheet
|
// Read dimensions of the sheet
|
||||||
$dimensionRef = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); // returns 'A1:M13' for instance (or 'A1' for empty sheet)
|
$dimensionRef = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); // returns 'A1:M13' for instance (or 'A1' for empty sheet)
|
||||||
if (preg_match('/[A-Z\d]+:([A-Z\d]+)/', $dimensionRef, $matches)) {
|
if (preg_match('/[A-Z\d]+:([A-Z\d]+)/', $dimensionRef, $matches)) {
|
||||||
$lastCellIndex = $matches[1];
|
$lastCellIndex = $matches[1];
|
||||||
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
|
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) {
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
// Start of the row description
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" starting node
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processRowStartingNode($xmlReader)
|
||||||
|
{
|
||||||
|
// Reset index of the last processed column
|
||||||
|
$this->lastColumnIndexProcessed = -1;
|
||||||
|
|
||||||
|
// Mark the last processed row as the one currently being read
|
||||||
|
$this->lastRowIndexProcessed = $this->getRowIndex($xmlReader);
|
||||||
|
|
||||||
// Read spans info if present
|
// Read spans info if present
|
||||||
$numberOfColumnsForRow = $this->numColumns;
|
$numberOfColumnsForRow = $this->numColumns;
|
||||||
$spans = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
|
$spans = $xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
|
||||||
if ($spans) {
|
if ($spans) {
|
||||||
list(, $numberOfColumnsForRow) = explode(':', $spans);
|
list(, $numberOfColumnsForRow) = explode(':', $spans);
|
||||||
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
||||||
}
|
}
|
||||||
$rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) {
|
$this->currentlyProcessedRowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
||||||
// Start of a cell description
|
|
||||||
$currentCellIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
|
|
||||||
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
|
|
||||||
|
|
||||||
$node = $this->xmlReader->expand();
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
$rowData[$currentColumnIndex] = $this->getCellValue($node);
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<cell>" starting node
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processCellStartingNode($xmlReader)
|
||||||
|
{
|
||||||
|
$currentColumnIndex = $this->getColumnIndex($xmlReader);
|
||||||
|
|
||||||
|
$node = $xmlReader->expand();
|
||||||
|
$this->currentlyProcessedRowData[$currentColumnIndex] = $this->getCellValue($node);
|
||||||
|
$this->lastColumnIndexProcessed = $currentColumnIndex;
|
||||||
|
|
||||||
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processRowEndingNode()
|
||||||
|
{
|
||||||
|
// if the fetched row is empty and we don't want to preserve it..,
|
||||||
|
if (!$this->shouldPreserveEmptyRows && $this->isEmptyRow($this->currentlyProcessedRowData)) {
|
||||||
|
// ... skip it
|
||||||
|
return XMLProcessor::PROCESSING_CONTINUE;
|
||||||
|
}
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
|
|
||||||
// End of the row description
|
|
||||||
// If needed, we fill the empty cells
|
|
||||||
$rowData = ($this->numColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData);
|
|
||||||
$this->numReadRows++;
|
$this->numReadRows++;
|
||||||
break;
|
|
||||||
|
|
||||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_WORKSHEET)) {
|
// If needed, we fill the empty cells
|
||||||
|
if ($this->numColumns === 0) {
|
||||||
|
$this->currentlyProcessedRowData = CellHelper::fillMissingArrayIndexes($this->currentlyProcessedRowData);
|
||||||
|
}
|
||||||
|
|
||||||
|
// at this point, we have all the data we need for the row
|
||||||
|
// so that we can populate the buffer
|
||||||
|
return XMLProcessor::PROCESSING_STOP;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int A return code that indicates what action should the processor take next
|
||||||
|
*/
|
||||||
|
protected function processWorksheetEndingNode()
|
||||||
|
{
|
||||||
// The closing "</worksheet>" marks the end of the file
|
// The closing "</worksheet>" marks the end of the file
|
||||||
$this->hasReachedEndOfFile = true;
|
$this->hasReachedEndOfFile = true;
|
||||||
break;
|
|
||||||
}
|
return XMLProcessor::PROCESSING_STOP;
|
||||||
}
|
}
|
||||||
|
|
||||||
} catch (XMLProcessingException $exception) {
|
/**
|
||||||
throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" node
|
||||||
|
* @return int Row index
|
||||||
|
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
|
||||||
|
*/
|
||||||
|
protected function getRowIndex($xmlReader)
|
||||||
|
{
|
||||||
|
// Get "r" attribute if present (from something like <row r="3"...>
|
||||||
|
$currentRowIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ROW_INDEX);
|
||||||
|
|
||||||
|
return ($currentRowIndex !== null) ?
|
||||||
|
intval($currentRowIndex) :
|
||||||
|
$this->lastRowIndexProcessed + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->rowDataBuffer = $rowData;
|
/**
|
||||||
|
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<c>" node
|
||||||
|
* @return int Column index
|
||||||
|
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
|
||||||
|
*/
|
||||||
|
protected function getColumnIndex($xmlReader)
|
||||||
|
{
|
||||||
|
// Get "r" attribute if present (from something like <c r="A1"...>
|
||||||
|
$currentCellIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
|
||||||
|
|
||||||
|
return ($currentCellIndex !== null) ?
|
||||||
|
CellHelper::getColumnIndexFromCellIndex($currentCellIndex) :
|
||||||
|
$this->lastColumnIndexProcessed + 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -187,25 +344,53 @@ class RowIterator implements IteratorInterface
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the current element, from the buffer.
|
* @param array $rowData
|
||||||
|
* @return bool Whether the given row is empty
|
||||||
|
*/
|
||||||
|
protected function isEmptyRow($rowData)
|
||||||
|
{
|
||||||
|
return (count($rowData) === 1 && $rowData[0] === '');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the current element, either an empty row or from the buffer.
|
||||||
* @link http://php.net/manual/en/iterator.current.php
|
* @link http://php.net/manual/en/iterator.current.php
|
||||||
*
|
*
|
||||||
* @return array|null
|
* @return array|null
|
||||||
*/
|
*/
|
||||||
public function current()
|
public function current()
|
||||||
{
|
{
|
||||||
return $this->rowDataBuffer;
|
$rowDataForRowToBeProcessed = $this->rowDataBuffer;
|
||||||
|
|
||||||
|
if ($this->shouldPreserveEmptyRows) {
|
||||||
|
// when we need to preserve empty rows, we will either return
|
||||||
|
// an empty row or the last row read. This depends whether the
|
||||||
|
// index of last row that was read matches the index of the last
|
||||||
|
// row whose value should be returned.
|
||||||
|
if ($this->lastRowIndexProcessed !== $this->nextRowIndexToBeProcessed) {
|
||||||
|
// return empty row if mismatch between last processed row
|
||||||
|
// and the row that needs to be returned
|
||||||
|
$rowDataForRowToBeProcessed = [''];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return $rowDataForRowToBeProcessed;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the key of the current element
|
* Return the key of the current element. Here, the row index.
|
||||||
* @link http://php.net/manual/en/iterator.key.php
|
* @link http://php.net/manual/en/iterator.key.php
|
||||||
*
|
*
|
||||||
* @return int
|
* @return int
|
||||||
*/
|
*/
|
||||||
public function key()
|
public function key()
|
||||||
{
|
{
|
||||||
return $this->numReadRows;
|
// TODO: This should return $this->nextRowIndexToBeProcessed
|
||||||
|
// but to avoid a breaking change, the return value for
|
||||||
|
// this function has been kept as the number of rows read.
|
||||||
|
return $this->shouldPreserveEmptyRows ?
|
||||||
|
$this->nextRowIndexToBeProcessed :
|
||||||
|
$this->numReadRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -12,10 +12,10 @@ use Box\Spout\Reader\SheetInterface;
|
|||||||
*/
|
*/
|
||||||
class Sheet implements SheetInterface
|
class Sheet implements SheetInterface
|
||||||
{
|
{
|
||||||
/** @var RowIterator To iterate over sheet's rows */
|
/** @var \Box\Spout\Reader\XLSX\RowIterator To iterate over sheet's rows */
|
||||||
protected $rowIterator;
|
protected $rowIterator;
|
||||||
|
|
||||||
/** @var int Index of the sheet, based on order of creation (zero-based) */
|
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||||
protected $index;
|
protected $index;
|
||||||
|
|
||||||
/** @var string Name of the sheet */
|
/** @var string Name of the sheet */
|
||||||
@@ -24,20 +24,21 @@ class Sheet implements SheetInterface
|
|||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the XLSX file being read
|
* @param string $filePath Path of the XLSX file being read
|
||||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
||||||
* @param Helper\SharedStringsHelper Helper to work with shared strings
|
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||||
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
|
|
||||||
* @param string $sheetName Name of the sheet
|
* @param string $sheetName Name of the sheet
|
||||||
|
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||||
|
* @param Helper\SharedStringsHelper Helper to work with shared strings
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $sheetIndex, $sheetName)
|
public function __construct($filePath, $sheetDataXMLFilePath, $sheetIndex, $sheetName, $options, $sharedStringsHelper)
|
||||||
{
|
{
|
||||||
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper);
|
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
|
||||||
$this->index = $sheetIndex;
|
$this->index = $sheetIndex;
|
||||||
$this->name = $sheetName;
|
$this->name = $sheetName;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return RowIterator
|
* @return \Box\Spout\Reader\XLSX\RowIterator
|
||||||
*/
|
*/
|
||||||
public function getRowIterator()
|
public function getRowIterator()
|
||||||
{
|
{
|
||||||
@@ -46,7 +47,7 @@ class Sheet implements SheetInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return int Index of the sheet, based on order of creation (zero-based)
|
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||||
*/
|
*/
|
||||||
public function getIndex()
|
public function getIndex()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ use Box\Spout\Reader\Exception\NoSheetsFoundException;
|
|||||||
*/
|
*/
|
||||||
class SheetIterator implements IteratorInterface
|
class SheetIterator implements IteratorInterface
|
||||||
{
|
{
|
||||||
/** @var Sheet[] The list of sheet present in the file */
|
/** @var \Box\Spout\Reader\XLSX\Sheet[] The list of sheet present in the file */
|
||||||
protected $sheets;
|
protected $sheets;
|
||||||
|
|
||||||
/** @var int The index of the sheet being read (zero-based) */
|
/** @var int The index of the sheet being read (zero-based) */
|
||||||
@@ -22,14 +22,15 @@ class SheetIterator implements IteratorInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $filePath Path of the file to be read
|
* @param string $filePath Path of the file to be read
|
||||||
|
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||||
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper
|
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
||||||
*/
|
*/
|
||||||
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper)
|
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||||
{
|
{
|
||||||
// Fetch all available sheets
|
// Fetch all available sheets
|
||||||
$sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper);
|
$sheetHelper = new SheetHelper($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper);
|
||||||
$this->sheets = $sheetHelper->getSheets();
|
$this->sheets = $sheetHelper->getSheets();
|
||||||
|
|
||||||
if (count($this->sheets) === 0) {
|
if (count($this->sheets) === 0) {
|
||||||
@@ -52,7 +53,7 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Checks if current position is valid
|
* Checks if current position is valid
|
||||||
* @link http://php.net/manual/en/iterator.valid.php
|
* @link http://php.net/manual/en/iterator.valid.php
|
||||||
*
|
*
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function valid()
|
public function valid()
|
||||||
{
|
{
|
||||||
@@ -67,7 +68,8 @@ class SheetIterator implements IteratorInterface
|
|||||||
*/
|
*/
|
||||||
public function next()
|
public function next()
|
||||||
{
|
{
|
||||||
if (array_key_exists($this->currentSheetIndex, $this->sheets)) {
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
if (isset($this->sheets[$this->currentSheetIndex])) {
|
||||||
$currentSheet = $this->sheets[$this->currentSheetIndex];
|
$currentSheet = $this->sheets[$this->currentSheetIndex];
|
||||||
$currentSheet->getRowIterator()->end();
|
$currentSheet->getRowIterator()->end();
|
||||||
|
|
||||||
@@ -79,7 +81,7 @@ class SheetIterator implements IteratorInterface
|
|||||||
* Return the current element
|
* Return the current element
|
||||||
* @link http://php.net/manual/en/iterator.current.php
|
* @link http://php.net/manual/en/iterator.current.php
|
||||||
*
|
*
|
||||||
* @return Sheet
|
* @return \Box\Spout\Reader\XLSX\Sheet
|
||||||
*/
|
*/
|
||||||
public function current()
|
public function current()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ namespace Box\Spout\Writer;
|
|||||||
|
|
||||||
use Box\Spout\Common\Exception\IOException;
|
use Box\Spout\Common\Exception\IOException;
|
||||||
use Box\Spout\Common\Exception\InvalidArgumentException;
|
use Box\Spout\Common\Exception\InvalidArgumentException;
|
||||||
|
use Box\Spout\Common\Exception\SpoutException;
|
||||||
|
use Box\Spout\Common\Helper\FileSystemHelper;
|
||||||
use Box\Spout\Writer\Exception\WriterAlreadyOpenedException;
|
use Box\Spout\Writer\Exception\WriterAlreadyOpenedException;
|
||||||
use Box\Spout\Writer\Exception\WriterNotOpenedException;
|
use Box\Spout\Writer\Exception\WriterNotOpenedException;
|
||||||
use Box\Spout\Writer\Style\StyleBuilder;
|
use Box\Spout\Writer\Style\StyleBuilder;
|
||||||
@@ -71,6 +73,21 @@ abstract class AbstractWriter implements WriterInterface
|
|||||||
$this->resetRowStyleToDefault();
|
$this->resetRowStyleToDefault();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the default styles for all rows added with "addRow".
|
||||||
|
* Overriding the default style instead of using "addRowWithStyle" improves performance by 20%.
|
||||||
|
* @see https://github.com/box/spout/issues/272
|
||||||
|
*
|
||||||
|
* @param Style\Style $defaultStyle
|
||||||
|
* @return AbstractWriter
|
||||||
|
*/
|
||||||
|
public function setDefaultRowStyle($defaultStyle)
|
||||||
|
{
|
||||||
|
$this->defaultRowStyle = $defaultStyle;
|
||||||
|
$this->resetRowStyleToDefault();
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||||
* @return AbstractWriter
|
* @return AbstractWriter
|
||||||
@@ -121,6 +138,10 @@ abstract class AbstractWriter implements WriterInterface
|
|||||||
$this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
|
$this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
|
||||||
$this->throwIfFilePointerIsNotAvailable();
|
$this->throwIfFilePointerIsNotAvailable();
|
||||||
|
|
||||||
|
// Clear any previous output (otherwise the generated file will be corrupted)
|
||||||
|
// @see https://github.com/box/spout/issues/241
|
||||||
|
$this->globalFunctionsHelper->ob_end_clean();
|
||||||
|
|
||||||
// Set headers
|
// Set headers
|
||||||
$this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType);
|
$this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType);
|
||||||
$this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"');
|
$this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"');
|
||||||
@@ -180,13 +201,23 @@ abstract class AbstractWriter implements WriterInterface
|
|||||||
* @return AbstractWriter
|
* @return AbstractWriter
|
||||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
|
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
|
||||||
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
|
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
|
||||||
|
* @throws \Box\Spout\Common\Exception\SpoutException If anything else goes wrong while writing data
|
||||||
*/
|
*/
|
||||||
public function addRow(array $dataRow)
|
public function addRow(array $dataRow)
|
||||||
{
|
{
|
||||||
if ($this->isWriterOpened) {
|
if ($this->isWriterOpened) {
|
||||||
// empty $dataRow should not add an empty line
|
// empty $dataRow should not add an empty line
|
||||||
if (!empty($dataRow)) {
|
if (!empty($dataRow)) {
|
||||||
|
try {
|
||||||
$this->addRowToWriter($dataRow, $this->rowStyle);
|
$this->addRowToWriter($dataRow, $this->rowStyle);
|
||||||
|
} catch (SpoutException $e) {
|
||||||
|
// if an exception occurs while writing data,
|
||||||
|
// close the writer and remove all files created so far.
|
||||||
|
$this->closeAndAttemptToCleanupAllFiles();
|
||||||
|
|
||||||
|
// re-throw the exception to alert developers of the error
|
||||||
|
throw $e;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new WriterNotOpenedException('The writer needs to be opened before adding row.');
|
throw new WriterNotOpenedException('The writer needs to be opened before adding row.');
|
||||||
@@ -238,7 +269,8 @@ abstract class AbstractWriter implements WriterInterface
|
|||||||
public function addRows(array $dataRows)
|
public function addRows(array $dataRows)
|
||||||
{
|
{
|
||||||
if (!empty($dataRows)) {
|
if (!empty($dataRows)) {
|
||||||
if (!is_array($dataRows[0])) {
|
$firstRow = reset($dataRows);
|
||||||
|
if (!is_array($firstRow)) {
|
||||||
throw new InvalidArgumentException('The input should be an array of arrays');
|
throw new InvalidArgumentException('The input should be an array of arrays');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,7 +351,31 @@ abstract class AbstractWriter implements WriterInterface
|
|||||||
public function close()
|
public function close()
|
||||||
{
|
{
|
||||||
$this->closeWriter();
|
$this->closeWriter();
|
||||||
|
|
||||||
|
if (is_resource($this->filePointer)) {
|
||||||
|
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||||
|
}
|
||||||
|
|
||||||
$this->isWriterOpened = false;
|
$this->isWriterOpened = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the writer and attempts to cleanup all files that were
|
||||||
|
* created during the writing process (temp files & final file).
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function closeAndAttemptToCleanupAllFiles()
|
||||||
|
{
|
||||||
|
// close the writer, which should remove all temp files
|
||||||
|
$this->close();
|
||||||
|
|
||||||
|
// remove output file if it was created
|
||||||
|
if ($this->globalFunctionsHelper->file_exists($this->outputFilePath)) {
|
||||||
|
$outputFolderPath = dirname($this->outputFilePath);
|
||||||
|
$fileSystemHelper = new FileSystemHelper($outputFolderPath);
|
||||||
|
$fileSystemHelper->deleteFile($this->outputFilePath);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,6 +29,9 @@ class Writer extends AbstractWriter
|
|||||||
/** @var int */
|
/** @var int */
|
||||||
protected $lastWrittenRowIndex = 0;
|
protected $lastWrittenRowIndex = 0;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $shouldAddBOM = true;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Sets the field delimiter for the CSV
|
* Sets the field delimiter for the CSV
|
||||||
*
|
*
|
||||||
@@ -55,6 +58,18 @@ class Writer extends AbstractWriter
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set if a BOM has to be added to the file
|
||||||
|
*
|
||||||
|
* @param bool $shouldAddBOM
|
||||||
|
* @return Writer
|
||||||
|
*/
|
||||||
|
public function setShouldAddBOM($shouldAddBOM)
|
||||||
|
{
|
||||||
|
$this->shouldAddBOM = (bool) $shouldAddBOM;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens the CSV streamer and makes it ready to accept data.
|
* Opens the CSV streamer and makes it ready to accept data.
|
||||||
*
|
*
|
||||||
@@ -62,9 +77,11 @@ class Writer extends AbstractWriter
|
|||||||
*/
|
*/
|
||||||
protected function openWriter()
|
protected function openWriter()
|
||||||
{
|
{
|
||||||
|
if ($this->shouldAddBOM) {
|
||||||
// Adds UTF-8 BOM for Unicode compatibility
|
// Adds UTF-8 BOM for Unicode compatibility
|
||||||
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
|
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Adds data to the currently opened writer.
|
* Adds data to the currently opened writer.
|
||||||
@@ -96,10 +113,6 @@ class Writer extends AbstractWriter
|
|||||||
*/
|
*/
|
||||||
protected function closeWriter()
|
protected function closeWriter()
|
||||||
{
|
{
|
||||||
if ($this->filePointer) {
|
|
||||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
|
||||||
}
|
|
||||||
|
|
||||||
$this->lastWrittenRowIndex = 0;
|
$this->lastWrittenRowIndex = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,9 @@ abstract class AbstractStyleHelper
|
|||||||
protected function hasStyleAlreadyBeenRegistered($style)
|
protected function hasStyleAlreadyBeenRegistered($style)
|
||||||
{
|
{
|
||||||
$serializedStyle = $style->serialize();
|
$serializedStyle = $style->serialize();
|
||||||
return array_key_exists($serializedStyle, $this->serializedStyleToStyleIdMappingTable);
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -120,7 +122,7 @@ abstract class AbstractStyleHelper
|
|||||||
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
|
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
|
||||||
{
|
{
|
||||||
// if the "wrap text" option is already set, no-op
|
// if the "wrap text" option is already set, no-op
|
||||||
if ($style->shouldWrapText()) {
|
if ($style->hasSetWrapText()) {
|
||||||
return $style;
|
return $style;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ namespace Box\Spout\Writer\Common\Helper;
|
|||||||
*/
|
*/
|
||||||
class CellHelper
|
class CellHelper
|
||||||
{
|
{
|
||||||
|
/** @var array Cache containing the mapping column index => cell index */
|
||||||
|
private static $columnIndexToCellIndexCache = [];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the cell index (base 26) associated to the base 10 column index.
|
* Returns the cell index (base 26) associated to the base 10 column index.
|
||||||
* Excel uses A to Z letters for column indexing, where A is the 1st column,
|
* Excel uses A to Z letters for column indexing, where A is the 1st column,
|
||||||
@@ -21,6 +24,10 @@ class CellHelper
|
|||||||
*/
|
*/
|
||||||
public static function getCellIndexFromColumnIndex($columnIndex)
|
public static function getCellIndexFromColumnIndex($columnIndex)
|
||||||
{
|
{
|
||||||
|
$originalColumnIndex = $columnIndex;
|
||||||
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
if (!isset(self::$columnIndexToCellIndexCache[$originalColumnIndex])) {
|
||||||
$cellIndex = '';
|
$cellIndex = '';
|
||||||
$capitalAAsciiValue = ord('A');
|
$capitalAAsciiValue = ord('A');
|
||||||
|
|
||||||
@@ -30,9 +37,22 @@ class CellHelper
|
|||||||
|
|
||||||
// substracting 1 because it's zero-based
|
// substracting 1 because it's zero-based
|
||||||
$columnIndex = intval($columnIndex / 26) - 1;
|
$columnIndex = intval($columnIndex / 26) - 1;
|
||||||
|
|
||||||
} while ($columnIndex >= 0);
|
} while ($columnIndex >= 0);
|
||||||
|
|
||||||
return $cellIndex;
|
self::$columnIndexToCellIndexCache[$originalColumnIndex] = $cellIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
return self::$columnIndexToCellIndexCache[$originalColumnIndex];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param $value
|
||||||
|
* @return bool Whether the given value is considered "empty"
|
||||||
|
*/
|
||||||
|
public static function isEmpty($value)
|
||||||
|
{
|
||||||
|
return ($value === null || $value === '');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -12,52 +12,142 @@ class ZipHelper
|
|||||||
{
|
{
|
||||||
const ZIP_EXTENSION = '.zip';
|
const ZIP_EXTENSION = '.zip';
|
||||||
|
|
||||||
|
/** Controls what to do when trying to add an existing file */
|
||||||
|
const EXISTING_FILES_SKIP = 'skip';
|
||||||
|
const EXISTING_FILES_OVERWRITE = 'overwrite';
|
||||||
|
|
||||||
|
/** @var string Path of the folder where the zip file will be created */
|
||||||
|
protected $tmpFolderPath;
|
||||||
|
|
||||||
|
/** @var \ZipArchive The ZipArchive instance */
|
||||||
|
protected $zip;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zips the root folder and streams the contents of the zip into the given stream
|
* @param string $tmpFolderPath Path of the temp folder where the zip file will be created
|
||||||
*
|
|
||||||
* @param string $folderPath Path to the folder to be zipped
|
|
||||||
* @param resource $streamPointer Pointer to the stream to copy the zip
|
|
||||||
* @return void
|
|
||||||
*/
|
*/
|
||||||
public function zipFolderAndCopyToStream($folderPath, $streamPointer)
|
public function __construct($tmpFolderPath)
|
||||||
{
|
{
|
||||||
$zipFilePath = $this->getZipFilePath($folderPath);
|
$this->tmpFolderPath = $tmpFolderPath;
|
||||||
$this->zipFolder($folderPath, $zipFilePath);
|
}
|
||||||
$this->copyZipToStream($zipFilePath, $streamPointer);
|
|
||||||
|
/**
|
||||||
|
* Returns the already created ZipArchive instance or
|
||||||
|
* creates one if none exists.
|
||||||
|
*
|
||||||
|
* @return \ZipArchive
|
||||||
|
*/
|
||||||
|
protected function createOrGetZip()
|
||||||
|
{
|
||||||
|
if (!isset($this->zip)) {
|
||||||
|
$this->zip = new \ZipArchive();
|
||||||
|
$zipFilePath = $this->getZipFilePath();
|
||||||
|
|
||||||
|
$this->zip->open($zipFilePath, \ZipArchive::CREATE|\ZipArchive::OVERWRITE);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->zip;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $folderPathToZip Path to the folder to be zipped
|
|
||||||
* @return string Path where the zip file of the given folder will be created
|
* @return string Path where the zip file of the given folder will be created
|
||||||
*/
|
*/
|
||||||
public function getZipFilePath($folderPathToZip)
|
public function getZipFilePath()
|
||||||
{
|
{
|
||||||
return $folderPathToZip . self::ZIP_EXTENSION;
|
return $this->tmpFolderPath . self::ZIP_EXTENSION;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Zips the given folder
|
* Adds the given file, located under the given root folder to the archive.
|
||||||
|
* The file will be compressed.
|
||||||
*
|
*
|
||||||
* @param string $folderPath Path of the folder to be zipped
|
* Example of use:
|
||||||
* @param string $destinationPath Path where the zip file will be created
|
* addFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
|
||||||
|
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
|
||||||
|
*
|
||||||
|
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
|
||||||
|
* @param string $localFilePath Path of the file to be added, under the root folder
|
||||||
|
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function zipFolder($folderPath, $destinationPath)
|
public function addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
|
||||||
{
|
{
|
||||||
$zip = new \ZipArchive();
|
$this->addFileToArchiveWithCompressionMethod(
|
||||||
if ($zip->open($destinationPath, \ZipArchive::CREATE)) {
|
$rootFolderPath,
|
||||||
$this->addFolderToZip($zip, $folderPath);
|
$localFilePath,
|
||||||
$zip->close();
|
$existingFileMode,
|
||||||
|
\ZipArchive::CM_DEFAULT
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given file, located under the given root folder to the archive.
|
||||||
|
* The file will NOT be compressed.
|
||||||
|
*
|
||||||
|
* Example of use:
|
||||||
|
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
|
||||||
|
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
|
||||||
|
*
|
||||||
|
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
|
||||||
|
* @param string $localFilePath Path of the file to be added, under the root folder
|
||||||
|
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function addUncompressedFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
|
||||||
|
{
|
||||||
|
$this->addFileToArchiveWithCompressionMethod(
|
||||||
|
$rootFolderPath,
|
||||||
|
$localFilePath,
|
||||||
|
$existingFileMode,
|
||||||
|
\ZipArchive::CM_STORE
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds the given file, located under the given root folder to the archive.
|
||||||
|
* The file will NOT be compressed.
|
||||||
|
*
|
||||||
|
* Example of use:
|
||||||
|
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
|
||||||
|
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
|
||||||
|
*
|
||||||
|
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
|
||||||
|
* @param string $localFilePath Path of the file to be added, under the root folder
|
||||||
|
* @param string $existingFileMode Controls what to do when trying to add an existing file
|
||||||
|
* @param int $compressionMethod The compression method
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function addFileToArchiveWithCompressionMethod($rootFolderPath, $localFilePath, $existingFileMode, $compressionMethod)
|
||||||
|
{
|
||||||
|
$zip = $this->createOrGetZip();
|
||||||
|
|
||||||
|
if (!$this->shouldSkipFile($zip, $localFilePath, $existingFileMode)) {
|
||||||
|
$normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath . '/' . $localFilePath);
|
||||||
|
$zip->addFile($normalizedFullFilePath, $localFilePath);
|
||||||
|
|
||||||
|
if (self::canChooseCompressionMethod()) {
|
||||||
|
$zip->setCompressionName($localFilePath, $compressionMethod);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \ZipArchive $zip
|
* @return bool Whether it is possible to choose the desired compression method to be used
|
||||||
* @param string $folderPath Path of the folder to add to the zip
|
*/
|
||||||
|
public static function canChooseCompressionMethod()
|
||||||
|
{
|
||||||
|
// setCompressionName() is a PHP7+ method...
|
||||||
|
return (method_exists(new \ZipArchive(), 'setCompressionName'));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $folderPath Path to the folder to be zipped
|
||||||
|
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
protected function addFolderToZip($zip, $folderPath)
|
public function addFolderToArchive($folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
|
||||||
{
|
{
|
||||||
|
$zip = $this->createOrGetZip();
|
||||||
|
|
||||||
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
|
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
|
||||||
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
|
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
|
||||||
|
|
||||||
@@ -65,14 +155,26 @@ class ZipHelper
|
|||||||
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
|
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
|
||||||
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
|
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
|
||||||
|
|
||||||
if ($itemInfo->isFile()) {
|
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
|
||||||
$zip->addFile($itemRealPath, $itemLocalPath);
|
$zip->addFile($itemRealPath, $itemLocalPath);
|
||||||
} else if ($itemInfo->isDir()) {
|
|
||||||
$zip->addEmptyDir($itemLocalPath);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param \ZipArchive $zip
|
||||||
|
* @param string $itemLocalPath
|
||||||
|
* @param string $existingFileMode
|
||||||
|
* @return bool Whether the file should be added to the archive or skipped
|
||||||
|
*/
|
||||||
|
protected function shouldSkipFile($zip, $itemLocalPath, $existingFileMode)
|
||||||
|
{
|
||||||
|
// Skip files if:
|
||||||
|
// - EXISTING_FILES_SKIP mode chosen
|
||||||
|
// - File already exists in the archive
|
||||||
|
return ($existingFileMode === self::EXISTING_FILES_SKIP && $zip->locateName($itemLocalPath) !== false);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns canonicalized absolute pathname, containing only forward slashes.
|
* Returns canonicalized absolute pathname, containing only forward slashes.
|
||||||
*
|
*
|
||||||
@@ -85,16 +187,30 @@ class ZipHelper
|
|||||||
return str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
|
return str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Closes the archive and copies it into the given stream
|
||||||
|
*
|
||||||
|
* @param resource $streamPointer Pointer to the stream to copy the zip
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function closeArchiveAndCopyToStream($streamPointer)
|
||||||
|
{
|
||||||
|
$zip = $this->createOrGetZip();
|
||||||
|
$zip->close();
|
||||||
|
unset($this->zip);
|
||||||
|
|
||||||
|
$this->copyZipToStream($streamPointer);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Streams the contents of the zip file into the given stream
|
* Streams the contents of the zip file into the given stream
|
||||||
*
|
*
|
||||||
* @param string $zipFilePath Path to the zip file
|
|
||||||
* @param resource $pointer Pointer to the stream to copy the zip
|
* @param resource $pointer Pointer to the stream to copy the zip
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
protected function copyZipToStream($zipFilePath, $pointer)
|
protected function copyZipToStream($pointer)
|
||||||
{
|
{
|
||||||
$zipFilePointer = fopen($zipFilePath, 'r');
|
$zipFilePointer = fopen($this->getZipFilePath(), 'r');
|
||||||
stream_copy_to_stream($zipFilePointer, $pointer);
|
stream_copy_to_stream($zipFilePointer, $pointer);
|
||||||
fclose($zipFilePointer);
|
fclose($zipFilePointer);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ class Sheet
|
|||||||
/** @var array Associative array [SHEET_INDEX] => [SHEET_NAME] keeping track of sheets' name to enforce uniqueness */
|
/** @var array Associative array [SHEET_INDEX] => [SHEET_NAME] keeping track of sheets' name to enforce uniqueness */
|
||||||
protected static $SHEETS_NAME_USED = [];
|
protected static $SHEETS_NAME_USED = [];
|
||||||
|
|
||||||
/** @var int Index of the sheet, based on order of creation (zero-based) */
|
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||||
protected $index;
|
protected $index;
|
||||||
|
|
||||||
/** @var string Name of the sheet */
|
/** @var string Name of the sheet */
|
||||||
@@ -34,7 +34,7 @@ class Sheet
|
|||||||
protected $stringHelper;
|
protected $stringHelper;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
|
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||||
*/
|
*/
|
||||||
public function __construct($sheetIndex)
|
public function __construct($sheetIndex)
|
||||||
{
|
{
|
||||||
@@ -45,7 +45,7 @@ class Sheet
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @api
|
* @api
|
||||||
* @return int Index of the sheet, based on order of creation (zero-based)
|
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||||
*/
|
*/
|
||||||
public function getIndex()
|
public function getIndex()
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Exception\Border;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Exception\WriterException;
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
|
||||||
|
class InvalidNameException extends WriterException
|
||||||
|
{
|
||||||
|
public function __construct($name)
|
||||||
|
{
|
||||||
|
$msg = '%s is not a valid name identifier for a border. Valid identifiers are: %s.';
|
||||||
|
|
||||||
|
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedNames())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Exception\Border;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Exception\WriterException;
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
|
||||||
|
class InvalidStyleException extends WriterException
|
||||||
|
{
|
||||||
|
public function __construct($name)
|
||||||
|
{
|
||||||
|
$msg = '%s is not a valid style identifier for a border. Valid identifiers are: %s.';
|
||||||
|
|
||||||
|
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedStyles())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Exception\Border;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Exception\WriterException;
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
|
||||||
|
class InvalidWidthException extends WriterException
|
||||||
|
{
|
||||||
|
public function __construct($name)
|
||||||
|
{
|
||||||
|
$msg = '%s is not a valid width identifier for a border. Valid identifiers are: %s.';
|
||||||
|
|
||||||
|
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedWidths())));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\ODS\Helper;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BorderHelper
|
||||||
|
*
|
||||||
|
* The fo:border, fo:border-top, fo:border-bottom, fo:border-left and fo:border-right attributes
|
||||||
|
* specify border properties
|
||||||
|
* http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1419780_253892949
|
||||||
|
*
|
||||||
|
* Example table-cell-properties
|
||||||
|
*
|
||||||
|
* <style:table-cell-properties
|
||||||
|
* fo:border-bottom="0.74pt solid #ffc000" style:diagonal-bl-tr="none"
|
||||||
|
* style:diagonal-tl-br="none" fo:border-left="none" fo:border-right="none"
|
||||||
|
* style:rotation-align="none" fo:border-top="none"/>
|
||||||
|
*/
|
||||||
|
class BorderHelper
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* Width mappings
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected static $widthMap = [
|
||||||
|
Border::WIDTH_THIN => '0.75pt',
|
||||||
|
Border::WIDTH_MEDIUM => '1.75pt',
|
||||||
|
Border::WIDTH_THICK => '2.5pt',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Style mapping
|
||||||
|
*
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected static $styleMap = [
|
||||||
|
Border::STYLE_SOLID => 'solid',
|
||||||
|
Border::STYLE_DASHED => 'dashed',
|
||||||
|
Border::STYLE_DOTTED => 'dotted',
|
||||||
|
Border::STYLE_DOUBLE => 'double',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param BorderPart $borderPart
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function serializeBorderPart(BorderPart $borderPart)
|
||||||
|
{
|
||||||
|
$definition = 'fo:border-%s="%s"';
|
||||||
|
|
||||||
|
if ($borderPart->getStyle() === Border::STYLE_NONE) {
|
||||||
|
$borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none');
|
||||||
|
} else {
|
||||||
|
$attributes = [
|
||||||
|
self::$widthMap[$borderPart->getWidth()],
|
||||||
|
self::$styleMap[$borderPart->getStyle()],
|
||||||
|
'#' . $borderPart->getColor(),
|
||||||
|
];
|
||||||
|
$borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
|
||||||
|
}
|
||||||
|
|
||||||
|
return $borderPartDefinition;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -75,7 +75,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
|||||||
*/
|
*/
|
||||||
protected function createRootFolder()
|
protected function createRootFolder()
|
||||||
{
|
{
|
||||||
$this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('ods'));
|
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,14 +103,12 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
|||||||
protected function createManifestFile()
|
protected function createManifestFile()
|
||||||
{
|
{
|
||||||
$manifestXmlFileContents = <<<EOD
|
$manifestXmlFileContents = <<<EOD
|
||||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
|
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
|
||||||
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
|
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
|
||||||
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
|
||||||
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
|
||||||
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
|
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
|
||||||
<manifest:file-entry manifest:full-path="mimetype" manifest:media-type="text/plain"/>
|
|
||||||
<manifest:file-entry manifest:full-path="META-INF/manifest.xml" manifest:media-type="text/xml"/>
|
|
||||||
</manifest:manifest>
|
</manifest:manifest>
|
||||||
EOD;
|
EOD;
|
||||||
|
|
||||||
@@ -265,10 +263,17 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
public function zipRootFolderAndCopyToStream($streamPointer)
|
public function zipRootFolderAndCopyToStream($streamPointer)
|
||||||
{
|
{
|
||||||
$zipHelper = new ZipHelper();
|
$zipHelper = new ZipHelper($this->rootFolder);
|
||||||
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
|
|
||||||
|
// In order to have the file's mime type detected properly, files need to be added
|
||||||
|
// to the zip file in a particular order.
|
||||||
|
// @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
|
||||||
|
$zipHelper->addUncompressedFileToArchive($this->rootFolder, self::MIMETYPE_FILE_NAME);
|
||||||
|
|
||||||
|
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
|
||||||
|
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
|
||||||
|
|
||||||
// once the zip is copied, remove it
|
// once the zip is copied, remove it
|
||||||
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder));
|
$this->deleteFile($zipHelper->getZipFilePath());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
namespace Box\Spout\Writer\ODS\Helper;
|
namespace Box\Spout\Writer\ODS\Helper;
|
||||||
|
|
||||||
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class StyleHelper
|
* Class StyleHelper
|
||||||
@@ -213,13 +214,46 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
protected function getStyleSectionContent($style)
|
protected function getStyleSectionContent($style)
|
||||||
{
|
{
|
||||||
$defaultStyle = $this->getDefaultStyle();
|
|
||||||
$styleIndex = $style->getId() + 1; // 1-based
|
$styleIndex = $style->getId() + 1; // 1-based
|
||||||
|
|
||||||
$content = '<style:style style:data-style-name="N0" style:family="table-cell" style:name="ce' . $styleIndex . '" style:parent-style-name="Default">';
|
$content = '<style:style style:data-style-name="N0" style:family="table-cell" style:name="ce' . $styleIndex . '" style:parent-style-name="Default">';
|
||||||
|
|
||||||
|
$content .= $this->getTextPropertiesSectionContent($style);
|
||||||
|
$content .= $this->getTableCellPropertiesSectionContent($style);
|
||||||
|
|
||||||
|
$content .= '</style:style>';
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getTextPropertiesSectionContent($style)
|
||||||
|
{
|
||||||
|
$content = '';
|
||||||
|
|
||||||
if ($style->shouldApplyFont()) {
|
if ($style->shouldApplyFont()) {
|
||||||
$content .= '<style:text-properties';
|
$content .= $this->getFontSectionContent($style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getFontSectionContent($style)
|
||||||
|
{
|
||||||
|
$defaultStyle = $this->getDefaultStyle();
|
||||||
|
|
||||||
|
$content = '<style:text-properties';
|
||||||
|
|
||||||
$fontColor = $style->getFontColor();
|
$fontColor = $style->getFontColor();
|
||||||
if ($fontColor !== $defaultStyle->getFontColor()) {
|
if ($fontColor !== $defaultStyle->getFontColor()) {
|
||||||
@@ -250,15 +284,73 @@ EOD;
|
|||||||
}
|
}
|
||||||
|
|
||||||
$content .= '/>';
|
$content .= '/>';
|
||||||
}
|
|
||||||
|
|
||||||
if ($style->shouldWrapText()) {
|
|
||||||
$content .= '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
|
|
||||||
}
|
|
||||||
|
|
||||||
$content .= '</style:style>';
|
|
||||||
|
|
||||||
return $content;
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the "<style:table-cell-properties>" section, inside "<style:style>" section
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getTableCellPropertiesSectionContent($style)
|
||||||
|
{
|
||||||
|
$content = '';
|
||||||
|
|
||||||
|
if ($style->shouldWrapText()) {
|
||||||
|
$content .= $this->getWrapTextXMLContent();
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($style->shouldApplyBorder()) {
|
||||||
|
$content .= $this->getBorderXMLContent($style);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($style->shouldApplyBackgroundColor()) {
|
||||||
|
$content .= $this->getBackgroundColorXMLContent($style);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the wrap text definition for the "<style:table-cell-properties>" section
|
||||||
|
*
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getWrapTextXMLContent()
|
||||||
|
{
|
||||||
|
return '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the borders definition for the "<style:table-cell-properties>" section
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getBorderXMLContent($style)
|
||||||
|
{
|
||||||
|
$borderProperty = '<style:table-cell-properties %s />';
|
||||||
|
|
||||||
|
$borders = array_map(function (BorderPart $borderPart) {
|
||||||
|
return BorderHelper::serializeBorderPart($borderPart);
|
||||||
|
}, $style->getBorder()->getParts());
|
||||||
|
|
||||||
|
return sprintf($borderProperty, implode(' ', $borders));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the contents of the background color definition for the "<style:table-cell-properties>" section
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getBackgroundColorXMLContent($style)
|
||||||
|
{
|
||||||
|
return sprintf(
|
||||||
|
'<style:table-cell-properties fo:background-color="#%s"/>',
|
||||||
|
$style->getBackgroundColor()
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -40,14 +40,14 @@ class Worksheet implements WorksheetInterface
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
||||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
* @param string $worksheetFilesFolder Temporary folder where the files to create the ODS will be stored
|
||||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||||
*/
|
*/
|
||||||
public function __construct($externalSheet, $worksheetFilesFolder)
|
public function __construct($externalSheet, $worksheetFilesFolder)
|
||||||
{
|
{
|
||||||
$this->externalSheet = $externalSheet;
|
$this->externalSheet = $externalSheet;
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\ODS();
|
$this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||||
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
|
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
|
||||||
|
|
||||||
$this->stringHelper = new StringHelper();
|
$this->stringHelper = new StringHelper();
|
||||||
@@ -134,6 +134,10 @@ class Worksheet implements WorksheetInterface
|
|||||||
*/
|
*/
|
||||||
public function addRow($dataRow, $style)
|
public function addRow($dataRow, $style)
|
||||||
{
|
{
|
||||||
|
// $dataRow can be an associative array. We need to transform
|
||||||
|
// it into a regular array, as we'll use the numeric indexes.
|
||||||
|
$dataRowWithNumericIndexes = array_values($dataRow);
|
||||||
|
|
||||||
$styleIndex = ($style->getId() + 1); // 1-based
|
$styleIndex = ($style->getId() + 1); // 1-based
|
||||||
$cellsCount = count($dataRow);
|
$cellsCount = count($dataRow);
|
||||||
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
|
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
|
||||||
@@ -144,11 +148,14 @@ class Worksheet implements WorksheetInterface
|
|||||||
$nextCellIndex = 1;
|
$nextCellIndex = 1;
|
||||||
|
|
||||||
for ($i = 0; $i < $cellsCount; $i++) {
|
for ($i = 0; $i < $cellsCount; $i++) {
|
||||||
$currentCellValue = $dataRow[$currentCellIndex];
|
$currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
|
||||||
|
|
||||||
|
// Using isset here because it is way faster than array_key_exists...
|
||||||
|
if (!isset($dataRowWithNumericIndexes[$nextCellIndex]) ||
|
||||||
|
$currentCellValue !== $dataRowWithNumericIndexes[$nextCellIndex]) {
|
||||||
|
|
||||||
if (!array_key_exists($nextCellIndex, $dataRow) || $currentCellValue !== $dataRow[$nextCellIndex]) {
|
|
||||||
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
|
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
|
||||||
$data .= $this->getCellContent($currentCellValue, $styleIndex, $numTimesValueRepeated);
|
$data .= $this->getCellXML($currentCellValue, $styleIndex, $numTimesValueRepeated);
|
||||||
|
|
||||||
$currentCellIndex = $nextCellIndex;
|
$currentCellIndex = $nextCellIndex;
|
||||||
}
|
}
|
||||||
@@ -176,7 +183,7 @@ class Worksheet implements WorksheetInterface
|
|||||||
* @return string The cell XML content
|
* @return string The cell XML content
|
||||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||||
*/
|
*/
|
||||||
protected function getCellContent($cellValue, $styleIndex, $numTimesValueRepeated)
|
protected function getCellXML($cellValue, $styleIndex, $numTimesValueRepeated)
|
||||||
{
|
{
|
||||||
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
|
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
|
||||||
|
|
||||||
@@ -217,6 +224,10 @@ class Worksheet implements WorksheetInterface
|
|||||||
*/
|
*/
|
||||||
public function close()
|
public function close()
|
||||||
{
|
{
|
||||||
|
if (!is_resource($this->sheetFilePointer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fclose($this->sheetFilePointer);
|
fclose($this->sheetFilePointer);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -20,7 +20,7 @@ class Writer extends AbstractMultiSheetsWriter
|
|||||||
/** @var string Temporary folder where the files to create the ODS will be stored */
|
/** @var string Temporary folder where the files to create the ODS will be stored */
|
||||||
protected $tempFolder;
|
protected $tempFolder;
|
||||||
|
|
||||||
/** @var Internal\Workbook The workbook for the XLSX file */
|
/** @var Internal\Workbook The workbook for the ODS file */
|
||||||
protected $book;
|
protected $book;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Style;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class Border
|
||||||
|
*/
|
||||||
|
class Border
|
||||||
|
{
|
||||||
|
const LEFT = 'left';
|
||||||
|
const RIGHT = 'right';
|
||||||
|
const TOP = 'top';
|
||||||
|
const BOTTOM = 'bottom';
|
||||||
|
|
||||||
|
const STYLE_NONE = 'none';
|
||||||
|
const STYLE_SOLID = 'solid';
|
||||||
|
const STYLE_DASHED = 'dashed';
|
||||||
|
const STYLE_DOTTED = 'dotted';
|
||||||
|
const STYLE_DOUBLE = 'double';
|
||||||
|
|
||||||
|
const WIDTH_THIN = 'thin';
|
||||||
|
const WIDTH_MEDIUM = 'medium';
|
||||||
|
const WIDTH_THICK = 'thick';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array A list of BorderPart objects for this border.
|
||||||
|
*/
|
||||||
|
protected $parts = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array|void $borderParts
|
||||||
|
*/
|
||||||
|
public function __construct(array $borderParts = [])
|
||||||
|
{
|
||||||
|
$this->setParts($borderParts);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param $name The name of the border part
|
||||||
|
* @return null|BorderPart
|
||||||
|
*/
|
||||||
|
public function getPart($name)
|
||||||
|
{
|
||||||
|
return $this->hasPart($name) ? $this->parts[$name] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param $name The name of the border part
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasPart($name)
|
||||||
|
{
|
||||||
|
return isset($this->parts[$name]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function getParts()
|
||||||
|
{
|
||||||
|
return $this->parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set BorderParts
|
||||||
|
* @param array $parts
|
||||||
|
*/
|
||||||
|
public function setParts($parts)
|
||||||
|
{
|
||||||
|
unset($this->parts);
|
||||||
|
foreach ($parts as $part) {
|
||||||
|
$this->addPart($part);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param BorderPart $borderPart
|
||||||
|
* @return self
|
||||||
|
*/
|
||||||
|
public function addPart(BorderPart $borderPart)
|
||||||
|
{
|
||||||
|
$this->parts[$borderPart->getName()] = $borderPart;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Style;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BorderBuilder
|
||||||
|
*/
|
||||||
|
class BorderBuilder
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var Border
|
||||||
|
*/
|
||||||
|
protected $border;
|
||||||
|
|
||||||
|
public function __construct()
|
||||||
|
{
|
||||||
|
$this->border = new Border();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|void $color Border A RGB color code
|
||||||
|
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||||
|
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||||
|
* @return BorderBuilder
|
||||||
|
*/
|
||||||
|
public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||||
|
{
|
||||||
|
$this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style));
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|void $color Border A RGB color code
|
||||||
|
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||||
|
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||||
|
* @return BorderBuilder
|
||||||
|
*/
|
||||||
|
public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||||
|
{
|
||||||
|
$this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style));
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|void $color Border A RGB color code
|
||||||
|
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||||
|
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||||
|
* @return BorderBuilder
|
||||||
|
*/
|
||||||
|
public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||||
|
{
|
||||||
|
$this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style));
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string|void $color Border A RGB color code
|
||||||
|
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||||
|
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||||
|
* @return BorderBuilder
|
||||||
|
*/
|
||||||
|
public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||||
|
{
|
||||||
|
$this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style));
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Border
|
||||||
|
*/
|
||||||
|
public function build()
|
||||||
|
{
|
||||||
|
return $this->border;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Style;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Exception\Border\InvalidNameException;
|
||||||
|
use Box\Spout\Writer\Exception\Border\InvalidStyleException;
|
||||||
|
use Box\Spout\Writer\Exception\Border\InvalidWidthException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class BorderPart
|
||||||
|
*/
|
||||||
|
class BorderPart
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @var string The style of this border part.
|
||||||
|
*/
|
||||||
|
protected $style;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string The name of this border part.
|
||||||
|
*/
|
||||||
|
protected $name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string The color of this border part.
|
||||||
|
*/
|
||||||
|
protected $color;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string The width of this border part.
|
||||||
|
*/
|
||||||
|
protected $width;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Allowed style constants for parts.
|
||||||
|
*/
|
||||||
|
protected static $allowedStyles = [
|
||||||
|
'none',
|
||||||
|
'solid',
|
||||||
|
'dashed',
|
||||||
|
'dotted',
|
||||||
|
'double'
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Allowed names constants for border parts.
|
||||||
|
*/
|
||||||
|
protected static $allowedNames = [
|
||||||
|
'left',
|
||||||
|
'right',
|
||||||
|
'top',
|
||||||
|
'bottom',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array Allowed width constants for border parts.
|
||||||
|
*/
|
||||||
|
protected static $allowedWidths = [
|
||||||
|
'thin',
|
||||||
|
'medium',
|
||||||
|
'thick',
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name @see BorderPart::$allowedNames
|
||||||
|
* @param string $color A RGB color code
|
||||||
|
* @param string $width @see BorderPart::$allowedWidths
|
||||||
|
* @param string $style @see BorderPart::$allowedStyles
|
||||||
|
* @throws InvalidNameException
|
||||||
|
* @throws InvalidStyleException
|
||||||
|
* @throws InvalidWidthException
|
||||||
|
*/
|
||||||
|
public function __construct($name, $color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||||
|
{
|
||||||
|
$this->setName($name);
|
||||||
|
$this->setColor($color);
|
||||||
|
$this->setWidth($width);
|
||||||
|
$this->setStyle($style);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getName()
|
||||||
|
{
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $name The name of the border part @see BorderPart::$allowedNames
|
||||||
|
* @throws InvalidNameException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setName($name)
|
||||||
|
{
|
||||||
|
if (!in_array($name, self::$allowedNames)) {
|
||||||
|
throw new InvalidNameException($name);
|
||||||
|
}
|
||||||
|
$this->name = $name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getStyle()
|
||||||
|
{
|
||||||
|
return $this->style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $style The style of the border part @see BorderPart::$allowedStyles
|
||||||
|
* @throws InvalidStyleException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setStyle($style)
|
||||||
|
{
|
||||||
|
if (!in_array($style, self::$allowedStyles)) {
|
||||||
|
throw new InvalidStyleException($style);
|
||||||
|
}
|
||||||
|
$this->style = $style;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getColor()
|
||||||
|
{
|
||||||
|
return $this->color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $color The color of the border part @see Color::rgb()
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setColor($color)
|
||||||
|
{
|
||||||
|
$this->color = $color;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getWidth()
|
||||||
|
{
|
||||||
|
return $this->width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $width The width of the border part @see BorderPart::$allowedWidths
|
||||||
|
* @throws InvalidWidthException
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function setWidth($width)
|
||||||
|
{
|
||||||
|
if (!in_array($width, self::$allowedWidths)) {
|
||||||
|
throw new InvalidWidthException($width);
|
||||||
|
}
|
||||||
|
$this->width = $width;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getAllowedStyles()
|
||||||
|
{
|
||||||
|
return self::$allowedStyles;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getAllowedNames()
|
||||||
|
{
|
||||||
|
return self::$allowedNames;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public static function getAllowedWidths()
|
||||||
|
{
|
||||||
|
return self::$allowedWidths;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -61,6 +61,23 @@ class Style
|
|||||||
/** @var bool Whether the wrap text property was set */
|
/** @var bool Whether the wrap text property was set */
|
||||||
protected $hasSetWrapText = false;
|
protected $hasSetWrapText = false;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var Border
|
||||||
|
*/
|
||||||
|
protected $border = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var bool Whether border properties should be applied
|
||||||
|
*/
|
||||||
|
protected $shouldApplyBorder = false;
|
||||||
|
|
||||||
|
/** @var string Background color */
|
||||||
|
protected $backgroundColor = null;
|
||||||
|
|
||||||
|
/** @var bool */
|
||||||
|
protected $hasSetBackgroundColor = false;
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return int|null
|
* @return int|null
|
||||||
*/
|
*/
|
||||||
@@ -80,7 +97,34 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return boolean
|
* @return Border
|
||||||
|
*/
|
||||||
|
public function getBorder()
|
||||||
|
{
|
||||||
|
return $this->border;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Border $border
|
||||||
|
* @return Style
|
||||||
|
*/
|
||||||
|
public function setBorder(Border $border)
|
||||||
|
{
|
||||||
|
$this->shouldApplyBorder = true;
|
||||||
|
$this->border = $border;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function shouldApplyBorder()
|
||||||
|
{
|
||||||
|
return $this->shouldApplyBorder;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function isFontBold()
|
public function isFontBold()
|
||||||
{
|
{
|
||||||
@@ -99,7 +143,7 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function isFontItalic()
|
public function isFontItalic()
|
||||||
{
|
{
|
||||||
@@ -118,7 +162,7 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function isFontUnderline()
|
public function isFontUnderline()
|
||||||
{
|
{
|
||||||
@@ -137,7 +181,7 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function isFontStrikethrough()
|
public function isFontStrikethrough()
|
||||||
{
|
{
|
||||||
@@ -218,7 +262,7 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return boolean
|
* @return bool
|
||||||
*/
|
*/
|
||||||
public function shouldWrapText()
|
public function shouldWrapText()
|
||||||
{
|
{
|
||||||
@@ -226,15 +270,24 @@ class Style
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
* @param bool|void $shouldWrap Should the text be wrapped
|
||||||
* @return Style
|
* @return Style
|
||||||
*/
|
*/
|
||||||
public function setShouldWrapText()
|
public function setShouldWrapText($shouldWrap = true)
|
||||||
{
|
{
|
||||||
$this->shouldWrapText = true;
|
$this->shouldWrapText = $shouldWrap;
|
||||||
$this->hasSetWrapText = true;
|
$this->hasSetWrapText = true;
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function hasSetWrapText()
|
||||||
|
{
|
||||||
|
return $this->hasSetWrapText;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return bool Whether specific font properties should be applied
|
* @return bool Whether specific font properties should be applied
|
||||||
*/
|
*/
|
||||||
@@ -243,6 +296,35 @@ class Style
|
|||||||
return $this->shouldApplyFont;
|
return $this->shouldApplyFont;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets the background color
|
||||||
|
* @param string $color ARGB color (@see Color)
|
||||||
|
* @return Style
|
||||||
|
*/
|
||||||
|
public function setBackgroundColor($color)
|
||||||
|
{
|
||||||
|
$this->hasSetBackgroundColor = true;
|
||||||
|
$this->backgroundColor = $color;
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function getBackgroundColor()
|
||||||
|
{
|
||||||
|
return $this->backgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
* @return bool Whether the background color should be applied
|
||||||
|
*/
|
||||||
|
public function shouldApplyBackgroundColor()
|
||||||
|
{
|
||||||
|
return $this->hasSetBackgroundColor;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Serializes the style for future comparison with other styles.
|
* Serializes the style for future comparison with other styles.
|
||||||
* The ID is excluded from the comparison, as we only care about
|
* The ID is excluded from the comparison, as we only care about
|
||||||
@@ -278,31 +360,67 @@ class Style
|
|||||||
{
|
{
|
||||||
$mergedStyle = clone $this;
|
$mergedStyle = clone $this;
|
||||||
|
|
||||||
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
|
$this->mergeFontStyles($mergedStyle, $baseStyle);
|
||||||
$mergedStyle->setFontBold();
|
$this->mergeOtherFontProperties($mergedStyle, $baseStyle);
|
||||||
}
|
$this->mergeCellProperties($mergedStyle, $baseStyle);
|
||||||
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
|
|
||||||
$mergedStyle->setFontItalic();
|
|
||||||
}
|
|
||||||
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
|
|
||||||
$mergedStyle->setFontUnderline();
|
|
||||||
}
|
|
||||||
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
|
|
||||||
$mergedStyle->setFontStrikethrough();
|
|
||||||
}
|
|
||||||
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
|
|
||||||
$mergedStyle->setFontSize($baseStyle->getFontSize());
|
|
||||||
}
|
|
||||||
if (!$this->hasSetFontSize && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
|
|
||||||
$mergedStyle->setFontColor($baseStyle->getFontColor());
|
|
||||||
}
|
|
||||||
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
|
|
||||||
$mergedStyle->setFontName($baseStyle->getFontName());
|
|
||||||
}
|
|
||||||
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
|
|
||||||
$mergedStyle->setShouldWrapText();
|
|
||||||
}
|
|
||||||
|
|
||||||
return $mergedStyle;
|
return $mergedStyle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Style $styleToUpdate (passed as reference)
|
||||||
|
* @param Style $baseStyle
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function mergeFontStyles($styleToUpdate, $baseStyle)
|
||||||
|
{
|
||||||
|
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
|
||||||
|
$styleToUpdate->setFontBold();
|
||||||
|
}
|
||||||
|
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
|
||||||
|
$styleToUpdate->setFontItalic();
|
||||||
|
}
|
||||||
|
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
|
||||||
|
$styleToUpdate->setFontUnderline();
|
||||||
|
}
|
||||||
|
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
|
||||||
|
$styleToUpdate->setFontStrikethrough();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Style $styleToUpdate Style to update (passed as reference)
|
||||||
|
* @param Style $baseStyle
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function mergeOtherFontProperties($styleToUpdate, $baseStyle)
|
||||||
|
{
|
||||||
|
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
|
||||||
|
$styleToUpdate->setFontSize($baseStyle->getFontSize());
|
||||||
|
}
|
||||||
|
if (!$this->hasSetFontColor && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
|
||||||
|
$styleToUpdate->setFontColor($baseStyle->getFontColor());
|
||||||
|
}
|
||||||
|
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
|
||||||
|
$styleToUpdate->setFontName($baseStyle->getFontName());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Style $styleToUpdate Style to update (passed as reference)
|
||||||
|
* @param Style $baseStyle
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function mergeCellProperties($styleToUpdate, $baseStyle)
|
||||||
|
{
|
||||||
|
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
|
||||||
|
$styleToUpdate->setShouldWrapText();
|
||||||
|
}
|
||||||
|
if (!$this->getBorder() && $baseStyle->shouldApplyBorder()) {
|
||||||
|
$styleToUpdate->setBorder($baseStyle->getBorder());
|
||||||
|
}
|
||||||
|
if (!$this->hasSetBackgroundColor && $baseStyle->shouldApplyBackgroundColor()) {
|
||||||
|
$styleToUpdate->setBackgroundColor($baseStyle->getBackgroundColor());
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -109,15 +109,40 @@ class StyleBuilder
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Makes the text wrap in the cell if it's too long or
|
* Makes the text wrap in the cell if requested
|
||||||
* on multiple lines.
|
|
||||||
*
|
*
|
||||||
* @api
|
* @api
|
||||||
|
* @param bool $shouldWrap Should the text be wrapped
|
||||||
* @return StyleBuilder
|
* @return StyleBuilder
|
||||||
*/
|
*/
|
||||||
public function setShouldWrapText()
|
public function setShouldWrapText($shouldWrap = true)
|
||||||
{
|
{
|
||||||
$this->style->setShouldWrapText();
|
$this->style->setShouldWrapText($shouldWrap);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set a border
|
||||||
|
*
|
||||||
|
* @param Border $border
|
||||||
|
* @return $this
|
||||||
|
*/
|
||||||
|
public function setBorder(Border $border)
|
||||||
|
{
|
||||||
|
$this->style->setBorder($border);
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Sets a background color
|
||||||
|
*
|
||||||
|
* @api
|
||||||
|
* @param string $color ARGB color (@see Color)
|
||||||
|
* @return StyleBuilder
|
||||||
|
*/
|
||||||
|
public function setBackgroundColor($color)
|
||||||
|
{
|
||||||
|
$this->style->setBackgroundColor($color);
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\XLSX\Helper;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
use Box\Spout\Writer\Style\BorderPart;
|
||||||
|
|
||||||
|
class BorderHelper
|
||||||
|
{
|
||||||
|
public static $xlsxStyleMap = [
|
||||||
|
Border::STYLE_SOLID => [
|
||||||
|
Border::WIDTH_THIN => 'thin',
|
||||||
|
Border::WIDTH_MEDIUM => 'medium',
|
||||||
|
Border::WIDTH_THICK => 'thick'
|
||||||
|
],
|
||||||
|
Border::STYLE_DOTTED => [
|
||||||
|
Border::WIDTH_THIN => 'dotted',
|
||||||
|
Border::WIDTH_MEDIUM => 'dotted',
|
||||||
|
Border::WIDTH_THICK => 'dotted',
|
||||||
|
],
|
||||||
|
Border::STYLE_DASHED => [
|
||||||
|
Border::WIDTH_THIN => 'dashed',
|
||||||
|
Border::WIDTH_MEDIUM => 'mediumDashed',
|
||||||
|
Border::WIDTH_THICK => 'mediumDashed',
|
||||||
|
],
|
||||||
|
Border::STYLE_DOUBLE => [
|
||||||
|
Border::WIDTH_THIN => 'double',
|
||||||
|
Border::WIDTH_MEDIUM => 'double',
|
||||||
|
Border::WIDTH_THICK => 'double',
|
||||||
|
],
|
||||||
|
Border::STYLE_NONE => [
|
||||||
|
Border::WIDTH_THIN => 'none',
|
||||||
|
Border::WIDTH_MEDIUM => 'none',
|
||||||
|
Border::WIDTH_THICK => 'none',
|
||||||
|
],
|
||||||
|
];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param BorderPart $borderPart
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public static function serializeBorderPart(BorderPart $borderPart)
|
||||||
|
{
|
||||||
|
$borderStyle = self::getBorderStyle($borderPart);
|
||||||
|
|
||||||
|
$colorEl = $borderPart->getColor() ? sprintf('<color rgb="%s"/>', $borderPart->getColor()) : '';
|
||||||
|
$partEl = sprintf(
|
||||||
|
'<%s style="%s">%s</%s>',
|
||||||
|
$borderPart->getName(),
|
||||||
|
$borderStyle,
|
||||||
|
$colorEl,
|
||||||
|
$borderPart->getName()
|
||||||
|
);
|
||||||
|
|
||||||
|
return $partEl . PHP_EOL;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the style definition from the style map
|
||||||
|
*
|
||||||
|
* @param BorderPart $borderPart
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
protected static function getBorderStyle(BorderPart $borderPart)
|
||||||
|
{
|
||||||
|
return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()];
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -94,7 +94,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
|||||||
*/
|
*/
|
||||||
protected function createRootFolder()
|
protected function createRootFolder()
|
||||||
{
|
{
|
||||||
$this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('xlsx'));
|
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
|
||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +284,7 @@ EOD;
|
|||||||
EOD;
|
EOD;
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
|
|
||||||
/** @var Worksheet $worksheet */
|
/** @var Worksheet $worksheet */
|
||||||
foreach ($worksheets as $worksheet) {
|
foreach ($worksheets as $worksheet) {
|
||||||
@@ -353,10 +353,19 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
public function zipRootFolderAndCopyToStream($streamPointer)
|
public function zipRootFolderAndCopyToStream($streamPointer)
|
||||||
{
|
{
|
||||||
$zipHelper = new ZipHelper();
|
$zipHelper = new ZipHelper($this->rootFolder);
|
||||||
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
|
|
||||||
|
// In order to have the file's mime type detected properly, files need to be added
|
||||||
|
// to the zip file in a particular order.
|
||||||
|
// "[Content_Types].xml" then at least 2 files located in "xl" folder should be zipped first.
|
||||||
|
$zipHelper->addFileToArchive($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME);
|
||||||
|
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::WORKBOOK_XML_FILE_NAME);
|
||||||
|
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::STYLES_XML_FILE_NAME);
|
||||||
|
|
||||||
|
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
|
||||||
|
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
|
||||||
|
|
||||||
// once the zip is copied, remove it
|
// once the zip is copied, remove it
|
||||||
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder));
|
$this->deleteFile($zipHelper->getZipFilePath());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -49,7 +49,7 @@ EOD;
|
|||||||
fwrite($this->sharedStringsFilePointer, $header);
|
fwrite($this->sharedStringsFilePointer, $header);
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
|
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -88,6 +88,10 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
public function close()
|
public function close()
|
||||||
{
|
{
|
||||||
|
if (!is_resource($this->sharedStringsFilePointer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fwrite($this->sharedStringsFilePointer, '</sst>');
|
fwrite($this->sharedStringsFilePointer, '</sst>');
|
||||||
|
|
||||||
// Replace the default strings count with the actual number of shared strings in the file header
|
// Replace the default strings count with the actual number of shared strings in the file header
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ namespace Box\Spout\Writer\XLSX\Helper;
|
|||||||
|
|
||||||
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
||||||
use Box\Spout\Writer\Style\Color;
|
use Box\Spout\Writer\Style\Color;
|
||||||
|
use Box\Spout\Writer\Style\Style;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Class StyleHelper
|
* Class StyleHelper
|
||||||
@@ -13,6 +14,131 @@ use Box\Spout\Writer\Style\Color;
|
|||||||
*/
|
*/
|
||||||
class StyleHelper extends AbstractStyleHelper
|
class StyleHelper extends AbstractStyleHelper
|
||||||
{
|
{
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $registeredFills = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration
|
||||||
|
*/
|
||||||
|
protected $styleIdToFillMappingTable = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Excel preserves two default fills with index 0 and 1
|
||||||
|
* Since Excel is the dominant vendor - we play along here
|
||||||
|
*
|
||||||
|
* @var int The fill index counter for custom fills.
|
||||||
|
*/
|
||||||
|
protected $fillIndex = 2;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array
|
||||||
|
*/
|
||||||
|
protected $registeredBorders = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var array [STYLE_ID] => [BORDER_ID] maps a style to a border declaration
|
||||||
|
*/
|
||||||
|
protected $styleIdToBorderMappingTable = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* XLSX specific operations on the registered styles
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
* @return \Box\Spout\Writer\Style\Style
|
||||||
|
*/
|
||||||
|
public function registerStyle($style)
|
||||||
|
{
|
||||||
|
$registeredStyle = parent::registerStyle($style);
|
||||||
|
$this->registerFill($registeredStyle);
|
||||||
|
$this->registerBorder($registeredStyle);
|
||||||
|
return $registeredStyle;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a fill definition
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
*/
|
||||||
|
protected function registerFill($style)
|
||||||
|
{
|
||||||
|
$styleId = $style->getId();
|
||||||
|
|
||||||
|
// Currently - only solid backgrounds are supported
|
||||||
|
// so $backgroundColor is a scalar value (RGB Color)
|
||||||
|
$backgroundColor = $style->getBackgroundColor();
|
||||||
|
|
||||||
|
if ($backgroundColor) {
|
||||||
|
$isBackgroundColorRegistered = isset($this->registeredFills[$backgroundColor]);
|
||||||
|
|
||||||
|
// We need to track the already registered background definitions
|
||||||
|
if ($isBackgroundColorRegistered) {
|
||||||
|
$registeredStyleId = $this->registeredFills[$backgroundColor];
|
||||||
|
$registeredFillId = $this->styleIdToFillMappingTable[$registeredStyleId];
|
||||||
|
$this->styleIdToFillMappingTable[$styleId] = $registeredFillId;
|
||||||
|
} else {
|
||||||
|
$this->registeredFills[$backgroundColor] = $styleId;
|
||||||
|
$this->styleIdToFillMappingTable[$styleId] = $this->fillIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// The fillId maps a style to a fill declaration
|
||||||
|
// When there is no background color definition - we default to 0
|
||||||
|
$this->styleIdToFillMappingTable[$styleId] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a border definition
|
||||||
|
*
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style
|
||||||
|
*/
|
||||||
|
protected function registerBorder($style)
|
||||||
|
{
|
||||||
|
$styleId = $style->getId();
|
||||||
|
|
||||||
|
if ($style->shouldApplyBorder()) {
|
||||||
|
$border = $style->getBorder();
|
||||||
|
$serializedBorder = serialize($border);
|
||||||
|
|
||||||
|
$isBorderAlreadyRegistered = isset($this->registeredBorders[$serializedBorder]);
|
||||||
|
|
||||||
|
if ($isBorderAlreadyRegistered) {
|
||||||
|
$registeredStyleId = $this->registeredBorders[$serializedBorder];
|
||||||
|
$registeredBorderId = $this->styleIdToBorderMappingTable[$registeredStyleId];
|
||||||
|
$this->styleIdToBorderMappingTable[$styleId] = $registeredBorderId;
|
||||||
|
} else {
|
||||||
|
$this->registeredBorders[$serializedBorder] = $styleId;
|
||||||
|
$this->styleIdToBorderMappingTable[$styleId] = count($this->registeredBorders);
|
||||||
|
}
|
||||||
|
|
||||||
|
} else {
|
||||||
|
// If no border should be applied - the mapping is the default border: 0
|
||||||
|
$this->styleIdToBorderMappingTable[$styleId] = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* For empty cells, we can specify a style or not. If no style are specified,
|
||||||
|
* then the software default will be applied. But sometimes, it may be useful
|
||||||
|
* to override this default style, for instance if the cell should have a
|
||||||
|
* background color different than the default one or some borders
|
||||||
|
* (fonts property don't really matter here).
|
||||||
|
*
|
||||||
|
* @param int $styleId
|
||||||
|
* @return bool Whether the cell should define a custom style
|
||||||
|
*/
|
||||||
|
public function shouldApplyStyleOnEmptyCell($styleId)
|
||||||
|
{
|
||||||
|
$hasStyleCustomFill = (isset($this->styleIdToFillMappingTable[$styleId]) && $this->styleIdToFillMappingTable[$styleId] !== 0);
|
||||||
|
$hasStyleCustomBorders = (isset($this->styleIdToBorderMappingTable[$styleId]) && $this->styleIdToBorderMappingTable[$styleId] !== 0);
|
||||||
|
|
||||||
|
return ($hasStyleCustomFill || $hasStyleCustomBorders);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns the content of the "styles.xml" file, given a list of styles.
|
* Returns the content of the "styles.xml" file, given a list of styles.
|
||||||
*
|
*
|
||||||
@@ -84,13 +210,28 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
protected function getFillsSectionContent()
|
protected function getFillsSectionContent()
|
||||||
{
|
{
|
||||||
return <<<EOD
|
// Excel reserves two default fills
|
||||||
<fills count="1">
|
$fillsCount = count($this->registeredFills) + 2;
|
||||||
<fill>
|
$content = sprintf('<fills count="%d">', $fillsCount);
|
||||||
<patternFill patternType="none"/>
|
|
||||||
</fill>
|
$content .= '<fill><patternFill patternType="none"/></fill>';
|
||||||
</fills>
|
$content .= '<fill><patternFill patternType="gray125"/></fill>';
|
||||||
EOD;
|
|
||||||
|
// The other fills are actually registered by setting a background color
|
||||||
|
foreach ($this->registeredFills as $styleId) {
|
||||||
|
/** @var Style $style */
|
||||||
|
$style = $this->styleIdToStyleMappingTable[$styleId];
|
||||||
|
|
||||||
|
$backgroundColor = $style->getBackgroundColor();
|
||||||
|
$content .= sprintf(
|
||||||
|
'<fill><patternFill patternType="solid"><fgColor rgb="%s"/></patternFill></fill>',
|
||||||
|
$backgroundColor
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$content .= '</fills>';
|
||||||
|
|
||||||
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -100,17 +241,39 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
protected function getBordersSectionContent()
|
protected function getBordersSectionContent()
|
||||||
{
|
{
|
||||||
return <<<EOD
|
|
||||||
<borders count="1">
|
// There is one default border with index 0
|
||||||
<border>
|
$borderCount = count($this->registeredBorders) + 1;
|
||||||
<left/>
|
|
||||||
<right/>
|
$content = '<borders count="' . $borderCount . '">';
|
||||||
<top/>
|
|
||||||
<bottom/>
|
// Default border starting at index 0
|
||||||
<diagonal/>
|
$content .= '<border><left/><right/><top/><bottom/></border>';
|
||||||
</border>
|
|
||||||
</borders>
|
foreach ($this->registeredBorders as $styleId) {
|
||||||
EOD;
|
/** @var \Box\Spout\Writer\Style\Style $style */
|
||||||
|
$style = $this->styleIdToStyleMappingTable[$styleId];
|
||||||
|
$border = $style->getBorder();
|
||||||
|
$content .= '<border>';
|
||||||
|
|
||||||
|
// @link https://github.com/box/spout/issues/271
|
||||||
|
$sortOrder = ['left', 'right', 'top', 'bottom'];
|
||||||
|
|
||||||
|
foreach ($sortOrder as $partName) {
|
||||||
|
if ($border->hasPart($partName)) {
|
||||||
|
/** @var $part \Box\Spout\Writer\Style\BorderPart */
|
||||||
|
$part = $border->getPart($partName);
|
||||||
|
$content .= BorderHelper::serializeBorderPart($part);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
$content .= '</border>';
|
||||||
|
}
|
||||||
|
|
||||||
|
$content .= '</borders>';
|
||||||
|
|
||||||
|
return $content;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -139,12 +302,18 @@ EOD;
|
|||||||
$content = '<cellXfs count="' . count($registeredStyles) . '">';
|
$content = '<cellXfs count="' . count($registeredStyles) . '">';
|
||||||
|
|
||||||
foreach ($registeredStyles as $style) {
|
foreach ($registeredStyles as $style) {
|
||||||
$content .= '<xf numFmtId="0" fontId="' . $style->getId() . '" fillId="0" borderId="0" xfId="0"';
|
$styleId = $style->getId();
|
||||||
|
$fillId = $this->styleIdToFillMappingTable[$styleId];
|
||||||
|
$borderId = $this->styleIdToBorderMappingTable[$styleId];
|
||||||
|
|
||||||
|
$content .= '<xf numFmtId="0" fontId="' . $styleId . '" fillId="' . $fillId . '" borderId="' . $borderId . '" xfId="0"';
|
||||||
|
|
||||||
if ($style->shouldApplyFont()) {
|
if ($style->shouldApplyFont()) {
|
||||||
$content .= ' applyFont="1"';
|
$content .= ' applyFont="1"';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0);
|
||||||
|
|
||||||
if ($style->shouldWrapText()) {
|
if ($style->shouldWrapText()) {
|
||||||
$content .= ' applyAlignment="1">';
|
$content .= ' applyAlignment="1">';
|
||||||
$content .= '<alignment wrapText="1"/>';
|
$content .= '<alignment wrapText="1"/>';
|
||||||
|
|||||||
@@ -86,7 +86,7 @@ class Workbook extends AbstractWorkbook
|
|||||||
$sheet = new Sheet($newSheetIndex);
|
$sheet = new Sheet($newSheetIndex);
|
||||||
|
|
||||||
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
|
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
|
||||||
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->shouldUseInlineStrings);
|
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->styleHelper, $this->shouldUseInlineStrings);
|
||||||
$this->worksheets[] = $worksheet;
|
$this->worksheets[] = $worksheet;
|
||||||
|
|
||||||
return $worksheet;
|
return $worksheet;
|
||||||
|
|||||||
@@ -30,6 +30,9 @@ EOD;
|
|||||||
/** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
|
/** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
|
||||||
protected $sharedStringsHelper;
|
protected $sharedStringsHelper;
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles */
|
||||||
|
protected $styleHelper;
|
||||||
|
|
||||||
/** @var bool Whether inline or shared strings should be used */
|
/** @var bool Whether inline or shared strings should be used */
|
||||||
protected $shouldUseInlineStrings;
|
protected $shouldUseInlineStrings;
|
||||||
|
|
||||||
@@ -46,17 +49,19 @@ EOD;
|
|||||||
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
||||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
||||||
* @param \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper $sharedStringsHelper Helper for shared strings
|
* @param \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper $sharedStringsHelper Helper for shared strings
|
||||||
|
* @param \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles
|
||||||
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
|
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
|
||||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||||
*/
|
*/
|
||||||
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $shouldUseInlineStrings)
|
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $styleHelper, $shouldUseInlineStrings)
|
||||||
{
|
{
|
||||||
$this->externalSheet = $externalSheet;
|
$this->externalSheet = $externalSheet;
|
||||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||||
|
$this->styleHelper = $styleHelper;
|
||||||
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
|
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
|
||||||
|
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
|
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
|
|
||||||
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
|
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
|
||||||
$this->startSheet();
|
$this->startSheet();
|
||||||
@@ -126,47 +131,99 @@ EOD;
|
|||||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||||
*/
|
*/
|
||||||
public function addRow($dataRow, $style)
|
public function addRow($dataRow, $style)
|
||||||
|
{
|
||||||
|
if (!$this->isEmptyRow($dataRow)) {
|
||||||
|
$this->addNonEmptyRow($dataRow, $style);
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->lastWrittenRowIndex++;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns whether the given row is empty
|
||||||
|
*
|
||||||
|
* @param array $dataRow Array containing data to be written. Cannot be empty.
|
||||||
|
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||||
|
* @return bool Whether the given row is empty
|
||||||
|
*/
|
||||||
|
private function isEmptyRow($dataRow)
|
||||||
|
{
|
||||||
|
$numCells = count($dataRow);
|
||||||
|
// using "reset()" instead of "$dataRow[0]" because $dataRow can be an associative array
|
||||||
|
return ($numCells === 1 && CellHelper::isEmpty(reset($dataRow)));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Adds non empty row to the worksheet.
|
||||||
|
*
|
||||||
|
* @param array $dataRow Array containing data to be written. Cannot be empty.
|
||||||
|
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||||
|
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
|
||||||
|
* @return void
|
||||||
|
* @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
|
||||||
|
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||||
|
*/
|
||||||
|
private function addNonEmptyRow($dataRow, $style)
|
||||||
{
|
{
|
||||||
$cellNumber = 0;
|
$cellNumber = 0;
|
||||||
$rowIndex = $this->lastWrittenRowIndex + 1;
|
$rowIndex = $this->lastWrittenRowIndex + 1;
|
||||||
$numCells = count($dataRow);
|
$numCells = count($dataRow);
|
||||||
|
|
||||||
$data = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
|
$rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
|
||||||
|
|
||||||
foreach($dataRow as $cellValue) {
|
foreach($dataRow as $cellValue) {
|
||||||
|
$rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style->getId());
|
||||||
|
$cellNumber++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$rowXML .= '</row>';
|
||||||
|
|
||||||
|
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $rowXML);
|
||||||
|
if ($wasWriteSuccessful === false) {
|
||||||
|
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build and return xml for a single cell.
|
||||||
|
*
|
||||||
|
* @param int $rowIndex
|
||||||
|
* @param int $cellNumber
|
||||||
|
* @param mixed $cellValue
|
||||||
|
* @param int $styleId
|
||||||
|
* @return string
|
||||||
|
* @throws InvalidArgumentException
|
||||||
|
*/
|
||||||
|
private function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
|
||||||
|
{
|
||||||
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
||||||
$data .= '<c r="' . $columnIndex . $rowIndex . '"';
|
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
|
||||||
$data .= ' s="' . $style->getId() . '"';
|
$cellXML .= ' s="' . $styleId . '"';
|
||||||
|
|
||||||
if (CellHelper::isNonEmptyString($cellValue)) {
|
if (CellHelper::isNonEmptyString($cellValue)) {
|
||||||
if ($this->shouldUseInlineStrings) {
|
if ($this->shouldUseInlineStrings) {
|
||||||
$data .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
|
$cellXML .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
|
||||||
} else {
|
} else {
|
||||||
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
|
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
|
||||||
$data .= ' t="s"><v>' . $sharedStringId . '</v></c>';
|
$cellXML .= ' t="s"><v>' . $sharedStringId . '</v></c>';
|
||||||
}
|
}
|
||||||
} else if (CellHelper::isBoolean($cellValue)) {
|
} else if (CellHelper::isBoolean($cellValue)) {
|
||||||
$data .= ' t="b"><v>' . $cellValue . '</v></c>';
|
$cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
|
||||||
} else if (CellHelper::isNumeric($cellValue)) {
|
} else if (CellHelper::isNumeric($cellValue)) {
|
||||||
$data .= '><v>' . $cellValue . '</v></c>';
|
$cellXML .= '><v>' . $cellValue . '</v></c>';
|
||||||
} else if (empty($cellValue)) {
|
} else if (empty($cellValue)) {
|
||||||
$data .= '/>';
|
if ($this->styleHelper->shouldApplyStyleOnEmptyCell($styleId)) {
|
||||||
|
$cellXML .= '/>';
|
||||||
|
} else {
|
||||||
|
// don't write empty cells that do no need styling
|
||||||
|
// NOTE: not appending to $cellXML is the right behavior!!
|
||||||
|
$cellXML = '';
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
||||||
}
|
}
|
||||||
|
|
||||||
$cellNumber++;
|
return $cellXML;
|
||||||
}
|
|
||||||
|
|
||||||
$data .= '</row>';
|
|
||||||
|
|
||||||
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $data);
|
|
||||||
if ($wasWriteSuccessful === false) {
|
|
||||||
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
|
|
||||||
}
|
|
||||||
|
|
||||||
// only update the count if the write worked
|
|
||||||
$this->lastWrittenRowIndex++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -176,6 +233,10 @@ EOD;
|
|||||||
*/
|
*/
|
||||||
public function close()
|
public function close()
|
||||||
{
|
{
|
||||||
|
if (!is_resource($this->sheetFilePointer)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
fwrite($this->sheetFilePointer, '</sheetData>');
|
fwrite($this->sheetFilePointer, '</sheetData>');
|
||||||
fwrite($this->sheetFilePointer, '</worksheet>');
|
fwrite($this->sheetFilePointer, '</worksheet>');
|
||||||
fclose($this->sheetFilePointer);
|
fclose($this->sheetFilePointer);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Common\Escaper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class ODSTest
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Common\Escaper
|
||||||
|
*/
|
||||||
|
class ODSTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestEscape()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['test', 'test'],
|
||||||
|
['carl\'s "pokemon"', 'carl's "pokemon"'],
|
||||||
|
["\n", "\n"],
|
||||||
|
["\r", "\r"],
|
||||||
|
["\t", "\t"],
|
||||||
|
["\v", "�"],
|
||||||
|
["\f", "�"],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestEscape
|
||||||
|
*
|
||||||
|
* @param string $stringToEscape
|
||||||
|
* @param string $expectedEscapedString
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testEscape($stringToEscape, $expectedEscapedString)
|
||||||
|
{
|
||||||
|
$escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||||
|
$escapedString = $escaper->escape($stringToEscape);
|
||||||
|
|
||||||
|
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
|||||||
return [
|
return [
|
||||||
['test', 'test'],
|
['test', 'test'],
|
||||||
['adam\'s "car"', 'adam's "car"'],
|
['adam\'s "car"', 'adam's "car"'],
|
||||||
|
["\n", "\n"],
|
||||||
|
["\r", "\r"],
|
||||||
|
["\t", "\t"],
|
||||||
[chr(0), '_x0000_'],
|
[chr(0), '_x0000_'],
|
||||||
|
[chr(4), '_x0004_'],
|
||||||
['_x0000_', '_x005F_x0000_'],
|
['_x0000_', '_x005F_x0000_'],
|
||||||
[chr(21), '_x0015_'],
|
[chr(21), '_x0015_'],
|
||||||
['control '.chr(21).' character', 'control _x0015_ character'],
|
['control '.chr(21).' character', 'control _x0015_ character'],
|
||||||
@@ -35,7 +39,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testEscape($stringToEscape, $expectedEscapedString)
|
public function testEscape($stringToEscape, $expectedEscapedString)
|
||||||
{
|
{
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
$escapedString = $escaper->escape($stringToEscape);
|
$escapedString = $escaper->escape($stringToEscape);
|
||||||
|
|
||||||
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
|
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
|
||||||
@@ -49,7 +53,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
|||||||
return [
|
return [
|
||||||
['test', 'test'],
|
['test', 'test'],
|
||||||
['adam's "car"', 'adam\'s "car"'],
|
['adam's "car"', 'adam\'s "car"'],
|
||||||
|
["\n", "\n"],
|
||||||
|
["\r", "\r"],
|
||||||
|
["\t", "\t"],
|
||||||
['_x0000_', chr(0)],
|
['_x0000_', chr(0)],
|
||||||
|
['_x0004_', chr(4)],
|
||||||
['_x005F_x0000_', '_x0000_'],
|
['_x005F_x0000_', '_x0000_'],
|
||||||
['_x0015_', chr(21)],
|
['_x0015_', chr(21)],
|
||||||
['control _x0015_ character', 'control '.chr(21).' character'],
|
['control _x0015_ character', 'control '.chr(21).' character'],
|
||||||
@@ -67,7 +75,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testUnescape($stringToUnescape, $expectedUnescapedString)
|
public function testUnescape($stringToUnescape, $expectedUnescapedString)
|
||||||
{
|
{
|
||||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||||
$unescapedString = $escaper->unescape($stringToUnescape);
|
$unescapedString = $escaper->unescape($stringToUnescape);
|
||||||
|
|
||||||
$this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string');
|
$this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string');
|
||||||
|
|||||||
@@ -115,29 +115,40 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function dataProviderForTestReadShouldSkipEmptyLines()
|
public function testReadShouldSkipEmptyLinesIfShouldPreserveEmptyRowsNotSet()
|
||||||
{
|
{
|
||||||
return [
|
$allRows = $this->getAllRowsForFile('csv_with_multiple_empty_lines.csv');
|
||||||
['csv_with_empty_line.csv'],
|
|
||||||
['csv_with_empty_last_line.csv'],
|
$expectedRows = [
|
||||||
|
// skipped row here
|
||||||
|
['csv--21', 'csv--22', 'csv--23'],
|
||||||
|
// skipped row here
|
||||||
|
['csv--41', 'csv--42', 'csv--43'],
|
||||||
|
// skipped row here
|
||||||
|
// last row empty
|
||||||
];
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider dataProviderForTestReadShouldSkipEmptyLines
|
|
||||||
*
|
|
||||||
* @param string $fileName
|
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function testReadShouldSkipEmptyLines($fileName)
|
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||||
{
|
{
|
||||||
$allRows = $this->getAllRowsForFile($fileName);
|
$allRows = $this->getAllRowsForFile(
|
||||||
|
'csv_with_multiple_empty_lines.csv',
|
||||||
|
',', '"', "\n", EncodingHelper::ENCODING_UTF8,
|
||||||
|
$shouldPreserveEmptyRows = true
|
||||||
|
);
|
||||||
|
|
||||||
$expectedRows = [
|
$expectedRows = [
|
||||||
['csv--11', 'csv--12', 'csv--13'],
|
[''],
|
||||||
['csv--31', 'csv--32', 'csv--33'],
|
['csv--21', 'csv--22', 'csv--23'],
|
||||||
|
[''],
|
||||||
|
['csv--41', 'csv--42', 'csv--43'],
|
||||||
|
[''],
|
||||||
];
|
];
|
||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
@@ -204,6 +215,30 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals('This is, a comma', $allRows[0][0]);
|
$this->assertEquals('This is, a comma', $allRows[0][0]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadCustomEOLs()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('csv_with_CR_EOL.csv', ',', '"', "\r");
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['csv--11', 'csv--12', 'csv--13'],
|
||||||
|
['csv--21', 'csv--22', 'csv--23'],
|
||||||
|
['csv--31', 'csv--32', 'csv--33'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldNotTruncateLineBreak()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('csv_with_line_breaks.csv', ',');
|
||||||
|
$this->assertEquals("This is,\na comma", $allRows[0][0]);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
@@ -227,7 +262,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
*/
|
*/
|
||||||
public function testReadShouldSkipBom($fileName, $fileEncoding)
|
public function testReadShouldSkipBom($fileName, $fileEncoding)
|
||||||
{
|
{
|
||||||
$allRows = $this->getAllRowsForFile($fileName, ',', '"', $fileEncoding);
|
$allRows = $this->getAllRowsForFile($fileName, ',', '"', "\n", $fileEncoding);
|
||||||
|
|
||||||
$expectedRows = [
|
$expectedRows = [
|
||||||
['csv--11', 'csv--12', 'csv--13'],
|
['csv--11', 'csv--12', 'csv--13'],
|
||||||
@@ -266,6 +301,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$allRows = [];
|
$allRows = [];
|
||||||
$resourcePath = $this->getResourcePath($fileName);
|
$resourcePath = $this->getResourcePath($fileName);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper|\PHPUnit_Framework_MockObject_MockObject $helperStub */
|
||||||
$helperStub = $this->getMockBuilder('\Box\Spout\Common\Helper\GlobalFunctionsHelper')
|
$helperStub = $this->getMockBuilder('\Box\Spout\Common\Helper\GlobalFunctionsHelper')
|
||||||
->setMethods(['function_exists'])
|
->setMethods(['function_exists'])
|
||||||
->getMock();
|
->getMock();
|
||||||
@@ -343,18 +379,71 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldInludeRowsWithZerosOnly()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.csv');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['1', '2', '3'],
|
||||||
|
['0', '0', '0']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.csv');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['0', '', ''],
|
||||||
|
['1', '1', '']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/195
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReaderShouldNotTrimCellValues()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.csv');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A'],
|
||||||
|
[' A '],
|
||||||
|
["\n\tA\n\t"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
* @param string|void $fieldDelimiter
|
* @param string|void $fieldDelimiter
|
||||||
* @param string|void $fieldEnclosure
|
* @param string|void $fieldEnclosure
|
||||||
|
* @param string|void $endOfLineCharacter
|
||||||
* @param string|void $encoding
|
* @param string|void $encoding
|
||||||
|
* @param bool|void $shouldPreserveEmptyRows
|
||||||
* @return array All the read rows the given file
|
* @return array All the read rows the given file
|
||||||
*/
|
*/
|
||||||
private function getAllRowsForFile(
|
private function getAllRowsForFile(
|
||||||
$fileName,
|
$fileName,
|
||||||
$fieldDelimiter = ',',
|
$fieldDelimiter = ',',
|
||||||
$fieldEnclosure = '"',
|
$fieldEnclosure = '"',
|
||||||
$encoding = EncodingHelper::ENCODING_UTF8)
|
$endOfLineCharacter = "\n",
|
||||||
|
$encoding = EncodingHelper::ENCODING_UTF8,
|
||||||
|
$shouldPreserveEmptyRows = false)
|
||||||
{
|
{
|
||||||
$allRows = [];
|
$allRows = [];
|
||||||
$resourcePath = $this->getResourcePath($fileName);
|
$resourcePath = $this->getResourcePath($fileName);
|
||||||
@@ -364,7 +453,9 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$reader
|
$reader
|
||||||
->setFieldDelimiter($fieldDelimiter)
|
->setFieldDelimiter($fieldDelimiter)
|
||||||
->setFieldEnclosure($fieldEnclosure)
|
->setFieldEnclosure($fieldEnclosure)
|
||||||
|
->setEndOfLineCharacter($endOfLineCharacter)
|
||||||
->setEncoding($encoding)
|
->setEncoding($encoding)
|
||||||
|
->setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
|
||||||
->open($resourcePath);
|
->open($resourcePath);
|
||||||
|
|
||||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||||
@@ -377,4 +468,51 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
|
|
||||||
return $allRows;
|
return $allRows;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
$allRows = [];
|
||||||
|
$resourcePath = 'spout://csv_standard';
|
||||||
|
|
||||||
|
// register stream wrapper
|
||||||
|
stream_wrapper_register('spout', SpoutTestStream::CLASS_NAME);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\CSV\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::CSV);
|
||||||
|
$reader->open($resourcePath);
|
||||||
|
|
||||||
|
foreach ($reader->getSheetIterator() as $sheet) {
|
||||||
|
foreach ($sheet->getRowIterator() as $row) {
|
||||||
|
$allRows[] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$reader->close();
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['csv--11', 'csv--12', 'csv--13'],
|
||||||
|
['csv--21', 'csv--22', 'csv--23'],
|
||||||
|
['csv--31', 'csv--32', 'csv--33'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
|
||||||
|
// cleanup
|
||||||
|
stream_wrapper_unregister('spout');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
/** @var \Box\Spout\Reader\CSV\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::CSV);
|
||||||
|
$reader->open('unsupported://foobar');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\CSV;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class SpoutTestStream
|
||||||
|
* Custom stream that reads CSV files located in the tests/resources/csv folder.
|
||||||
|
* For example: spout://foobar will point to tests/resources/csv/foobar.csv
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\CSV
|
||||||
|
*/
|
||||||
|
class SpoutTestStream
|
||||||
|
{
|
||||||
|
const CLASS_NAME = __CLASS__;
|
||||||
|
|
||||||
|
const PATH_TO_CSV_RESOURCES = 'tests/resources/csv/';
|
||||||
|
const CSV_EXTENSION = '.csv';
|
||||||
|
|
||||||
|
/** @var int $position */
|
||||||
|
private $position;
|
||||||
|
|
||||||
|
/** @var resource $fileHandle */
|
||||||
|
private $fileHandle;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $path
|
||||||
|
* @param int $flag
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function url_stat($path, $flag)
|
||||||
|
{
|
||||||
|
$filePath = $this->getFilePathFromStreamPath($path);
|
||||||
|
return stat($filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $streamPath
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
private function getFilePathFromStreamPath($streamPath)
|
||||||
|
{
|
||||||
|
$fileName = parse_url($streamPath, PHP_URL_HOST);
|
||||||
|
return self::PATH_TO_CSV_RESOURCES . $fileName . self::CSV_EXTENSION;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $path
|
||||||
|
* @param string $mode
|
||||||
|
* @param int $options
|
||||||
|
* @param string $opened_path
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function stream_open($path, $mode, $options, &$opened_path)
|
||||||
|
{
|
||||||
|
$this->position = 0;
|
||||||
|
|
||||||
|
// the path is like "spout://csv_name" so the actual file name correspond the name of the host.
|
||||||
|
$filePath = $this->getFilePathFromStreamPath($path);
|
||||||
|
$this->fileHandle = fopen($filePath, $mode);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $numBytes
|
||||||
|
* @return string
|
||||||
|
*/
|
||||||
|
public function stream_read($numBytes)
|
||||||
|
{
|
||||||
|
$this->position += $numBytes;
|
||||||
|
return fread($this->fileHandle, $numBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return int
|
||||||
|
*/
|
||||||
|
public function stream_tell()
|
||||||
|
{
|
||||||
|
return $this->position;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $offset
|
||||||
|
* @param int|void $whence
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function stream_seek($offset, $whence = SEEK_SET)
|
||||||
|
{
|
||||||
|
$result = fseek($this->fileHandle, $offset, $whence);
|
||||||
|
if ($result === -1) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($whence === SEEK_SET) {
|
||||||
|
$this->position = $offset;
|
||||||
|
} else if ($whence === SEEK_CUR) {
|
||||||
|
$this->position += $offset;
|
||||||
|
} else {
|
||||||
|
// not implemented
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function stream_close()
|
||||||
|
{
|
||||||
|
return fclose($this->fileHandle);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool
|
||||||
|
*/
|
||||||
|
public function stream_eof()
|
||||||
|
{
|
||||||
|
return feof($this->fileHandle);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -78,6 +78,19 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals([['foo']], $allRows);
|
$this->assertEquals([['foo']], $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportNumberRowsRepeated()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_number_rows_repeated.ods');
|
||||||
|
$expectedRows = [
|
||||||
|
['foo', 10.43],
|
||||||
|
['foo', 10.43],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -164,6 +177,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportFormatDatesAndTimesIfSpecified()
|
||||||
|
{
|
||||||
|
$shouldFormatDates = true;
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $shouldFormatDates);
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['05/19/2016', '5/19/16', '05/19/2016 16:39:00', '05/19/16 04:39 PM', '5/19/2016'],
|
||||||
|
['11:29', '13:23:45', '01:23:45', '01:23:45 AM', '01:23:45 PM'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -196,15 +224,39 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function testReadShouldSkipEmptyRow()
|
public function testReadShouldSkipEmptyRowsIfShouldPreserveEmptyRowsNotSet()
|
||||||
{
|
{
|
||||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_row.ods');
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods');
|
||||||
$this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped');
|
|
||||||
|
$this->assertEquals(3, count($allRows), 'There should be only 3 rows, because the empty rows are skipped');
|
||||||
|
|
||||||
$expectedRows = [
|
$expectedRows = [
|
||||||
['ods--11', 'ods--12', 'ods--13'],
|
// skipped row here
|
||||||
// row skipped here
|
|
||||||
['ods--21', 'ods--22', 'ods--23'],
|
['ods--21', 'ods--22', 'ods--23'],
|
||||||
|
// skipped row here
|
||||||
|
// skipped row here
|
||||||
|
['ods--51', 'ods--52', 'ods--53'],
|
||||||
|
['ods--61', 'ods--62', 'ods--63'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods', false, true);
|
||||||
|
|
||||||
|
$this->assertEquals(6, count($allRows), 'There should be 6 rows');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
[''],
|
||||||
|
['ods--21', 'ods--22', 'ods--23'],
|
||||||
|
[''],
|
||||||
|
[''],
|
||||||
|
['ods--51', 'ods--52', 'ods--53'],
|
||||||
|
['ods--61', 'ods--62', 'ods--63'],
|
||||||
];
|
];
|
||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
@@ -363,16 +415,125 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::ODS);
|
||||||
|
$reader->open('unsupported://foobar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadWithSupportedCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::ODS);
|
||||||
|
$reader->open('php://memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldInludeRowsWithZerosOnly()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.ods');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['1', '2', '3'],
|
||||||
|
['0', '0', '0']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.ods');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['0', '', ''],
|
||||||
|
['1', '1', '']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/195
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReaderShouldNotTrimCellValues()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.ods');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A'],
|
||||||
|
[' A '],
|
||||||
|
["\n\tA\n\t"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/218
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReaderShouldReadTextInHyperlinks()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_hyperlinks.ods');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['email', 'text'],
|
||||||
|
['1@example.com', 'text'],
|
||||||
|
['2@example.com', 'text and https://github.com/box/spout/issues/218 and text'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'Text in hyperlinks should be read');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReaderShouldReadInlineFontFormattingAsText()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_inline_font_formatting.ods');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['I am a yellow bird']
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'Text formatted inline should be read');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
|
* @param bool|void $shouldFormatDates
|
||||||
|
* @param bool|void $shouldPreserveEmptyRows
|
||||||
* @return array All the read rows the given file
|
* @return array All the read rows the given file
|
||||||
*/
|
*/
|
||||||
private function getAllRowsForFile($fileName)
|
private function getAllRowsForFile($fileName, $shouldFormatDates = false, $shouldPreserveEmptyRows = false)
|
||||||
{
|
{
|
||||||
$allRows = [];
|
$allRows = [];
|
||||||
$resourcePath = $this->getResourcePath($fileName);
|
$resourcePath = $this->getResourcePath($fileName);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||||
$reader = ReaderFactory::create(Type::ODS);
|
$reader = ReaderFactory::create(Type::ODS);
|
||||||
|
$reader->setShouldFormatDates($shouldFormatDates);
|
||||||
|
$reader->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
|
||||||
$reader->open($resourcePath);
|
$reader->open($resourcePath);
|
||||||
|
|
||||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||||
|
|||||||
@@ -20,12 +20,11 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testOpenShouldFailIfFileInsideZipDoesNotExist()
|
public function testOpenShouldFailIfFileInsideZipDoesNotExist()
|
||||||
{
|
{
|
||||||
$resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx');
|
$resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx');
|
||||||
$nonExistingXMLFilePath = 'zip://' . $resourcePath . '#path/to/fake/file.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
|
|
||||||
// using "@" to prevent errors/warning to be displayed
|
// using "@" to prevent errors/warning to be displayed
|
||||||
$wasOpenSuccessful = @$xmlReader->open($nonExistingXMLFilePath);
|
$wasOpenSuccessful = @$xmlReader->openFileInZip($resourcePath, 'path/to/fake/file.xml');
|
||||||
|
|
||||||
$this->assertTrue($wasOpenSuccessful === false);
|
$this->assertTrue($wasOpenSuccessful === false);
|
||||||
}
|
}
|
||||||
@@ -72,10 +71,9 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testReadShouldThrowExceptionOnError()
|
public function testReadShouldThrowExceptionOnError()
|
||||||
{
|
{
|
||||||
$resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx');
|
$resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx');
|
||||||
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/worksheets/sheet1.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
if ($xmlReader->open($sheetDataXMLFilePath) === false) {
|
if ($xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml') === false) {
|
||||||
$this->fail();
|
$this->fail();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,43 +93,13 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
// The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains
|
// The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains
|
||||||
// a doctype element that causes read errors
|
// a doctype element that causes read errors
|
||||||
$resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx');
|
$resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx');
|
||||||
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/sharedStrings.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
if ($xmlReader->open($sheetDataXMLFilePath) !== false) {
|
if ($xmlReader->openFileInZip($resourcePath, 'xl/sharedStrings.xml') !== false) {
|
||||||
@$xmlReader->next('sst');
|
@$xmlReader->next('sst');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* @return array
|
|
||||||
*/
|
|
||||||
public function dataProviderForTestIsZipStream()
|
|
||||||
{
|
|
||||||
return [
|
|
||||||
['/absolute/path/to/file.xlsx', false],
|
|
||||||
['relative/path/to/file.xlsx', false],
|
|
||||||
['php://temp', false],
|
|
||||||
['zip:///absolute/path/to/file.xlsx', true],
|
|
||||||
['zip://relative/path/to/file.xlsx', true],
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* @dataProvider dataProviderForTestIsZipStream
|
|
||||||
*
|
|
||||||
* @param string $URI
|
|
||||||
* @param bool $expectedResult
|
|
||||||
* @return void
|
|
||||||
*/
|
|
||||||
public function testIsZipStream($URI, $expectedResult)
|
|
||||||
{
|
|
||||||
$xmlReader = new XMLReader();
|
|
||||||
$isZipStream = \ReflectionHelper::callMethodOnObject($xmlReader, 'isZipStream', $URI);
|
|
||||||
|
|
||||||
$this->assertEquals($expectedResult, $isZipStream);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
@@ -167,35 +135,75 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
/**
|
/**
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
public function dataProviderForTestConvertURIToUseRealPath()
|
public function dataProviderForTestGetRealPathURIForFileInZip()
|
||||||
{
|
{
|
||||||
$tempFolder = realpath(sys_get_temp_dir());
|
$tempFolder = realpath(sys_get_temp_dir());
|
||||||
|
$tempFolderName = basename($tempFolder);
|
||||||
|
$expectedRealPathURI = 'zip://' . $tempFolder . '/test.xlsx#test.xml';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
['/../../../' . $tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'],
|
[$tempFolder, "$tempFolder/test.xlsx", 'test.xml', $expectedRealPathURI],
|
||||||
[$tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'],
|
[$tempFolder, "$tempFolder/../$tempFolderName/test.xlsx", 'test.xml', $expectedRealPathURI],
|
||||||
['zip://' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
|
|
||||||
['zip:///../../../' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
|
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @dataProvider dataProviderForTestConvertURIToUseRealPath
|
* @dataProvider dataProviderForTestGetRealPathURIForFileInZip
|
||||||
*
|
*
|
||||||
* @param string $URI
|
* @param string $tempFolder
|
||||||
* @param string $expectedConvertedURI
|
* @param string $zipFilePath
|
||||||
|
* @param string $fileInsideZipPath
|
||||||
|
* @param string $expectedRealPathURI
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function testConvertURIToUseRealPath($URI, $expectedConvertedURI)
|
public function testGetRealPathURIForFileInZip($tempFolder, $zipFilePath, $fileInsideZipPath, $expectedRealPathURI)
|
||||||
{
|
{
|
||||||
$tempFolder = sys_get_temp_dir();
|
|
||||||
touch($tempFolder . '/test.xlsx');
|
touch($tempFolder . '/test.xlsx');
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$convertedURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'convertURIToUseRealPath', $URI);
|
$realPathURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'getRealPathURIForFileInZip', $zipFilePath, $fileInsideZipPath);
|
||||||
|
|
||||||
$this->assertEquals($expectedConvertedURI, $convertedURI);
|
// Normalizing path separators for Windows support
|
||||||
|
$normalizedRealPathURI = str_replace('\\', '/', $realPathURI);
|
||||||
|
$normalizedExpectedRealPathURI = str_replace('\\', '/', $expectedRealPathURI);
|
||||||
|
|
||||||
|
$this->assertEquals($normalizedExpectedRealPathURI, $normalizedRealPathURI);
|
||||||
|
|
||||||
unlink($tempFolder . '/test.xlsx');
|
unlink($tempFolder . '/test.xlsx');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestIsPositionedOnStartingAndEndingNode()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['<test></test>'], // not prefixed
|
||||||
|
['<x:test xmlns:x="foo"></x:test>'], // prefixed
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestIsPositionedOnStartingAndEndingNode
|
||||||
|
*
|
||||||
|
* @param string $testXML
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testIsPositionedOnStartingAndEndingNode($testXML)
|
||||||
|
{
|
||||||
|
$xmlReader = new XMLReader();
|
||||||
|
$xmlReader->XML($testXML);
|
||||||
|
|
||||||
|
// the first read moves the pointer to "<test>"
|
||||||
|
$xmlReader->read();
|
||||||
|
$this->assertTrue($xmlReader->isPositionedOnStartingNode('test'));
|
||||||
|
$this->assertFalse($xmlReader->isPositionedOnEndingNode('test'));
|
||||||
|
|
||||||
|
// the seconds read moves the pointer to "</test>"
|
||||||
|
$xmlReader->read();
|
||||||
|
$this->assertFalse($xmlReader->isPositionedOnStartingNode('test'));
|
||||||
|
$this->assertTrue($xmlReader->isPositionedOnEndingNode('test'));
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class CellValueFormatterTest
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX\Helper
|
||||||
|
*/
|
||||||
|
class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestExcelDate()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 42429, '2016-02-29 00:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, -700, null],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 0, '1900-01-01 00:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.25, '1900-01-01 06:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, '1900-01-01 12:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.75, '1900-01-01 18:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.99999, '1900-01-01 23:59:59'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 1, '1900-01-01 00:00:00'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59'],
|
||||||
|
[CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestExcelDate
|
||||||
|
*
|
||||||
|
* @param string $cellType
|
||||||
|
* @param int|float|string $nodeValue
|
||||||
|
* @param string|null $expectedDateAsString
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testExcelDate($cellType, $nodeValue, $expectedDateAsString)
|
||||||
|
{
|
||||||
|
$nodeListMock = $this->getMockBuilder('DOMNodeList')->disableOriginalConstructor()->getMock();
|
||||||
|
|
||||||
|
$nodeListMock
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('item')
|
||||||
|
->with(0)
|
||||||
|
->will($this->returnValue((object)['nodeValue' => $nodeValue]));
|
||||||
|
|
||||||
|
$nodeMock = $this->getMockBuilder('DOMElement')->disableOriginalConstructor()->getMock();
|
||||||
|
|
||||||
|
$nodeMock
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('getAttribute')
|
||||||
|
->will($this->returnValueMap([
|
||||||
|
[CellValueFormatter::XML_ATTRIBUTE_TYPE, $cellType],
|
||||||
|
[CellValueFormatter::XML_ATTRIBUTE_STYLE_ID, 123],
|
||||||
|
]));
|
||||||
|
|
||||||
|
$nodeMock
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('getElementsByTagName')
|
||||||
|
->with(CellValueFormatter::XML_NODE_VALUE)
|
||||||
|
->will($this->returnValue($nodeListMock));
|
||||||
|
|
||||||
|
$styleHelperMock = $this->getMockBuilder('Box\Spout\Reader\XLSX\Helper\StyleHelper')->disableOriginalConstructor()->getMock();
|
||||||
|
|
||||||
|
$styleHelperMock
|
||||||
|
->expects($this->once())
|
||||||
|
->method('shouldFormatNumericValueAsDate')
|
||||||
|
->with(123)
|
||||||
|
->will($this->returnValue(true));
|
||||||
|
|
||||||
|
$formatter = new CellValueFormatter(null, $styleHelperMock, false);
|
||||||
|
$result = $formatter->extractAndFormatNodeValue($nodeMock);
|
||||||
|
|
||||||
|
if ($expectedDateAsString === null) {
|
||||||
|
$this->assertNull($result);
|
||||||
|
} else {
|
||||||
|
$this->assertInstanceOf('DateTime', $result);
|
||||||
|
$this->assertSame($expectedDateAsString, $result->format('Y-m-d H:i:s'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestFormatNumericCellValueWithNumbers()
|
||||||
|
{
|
||||||
|
// Some test values exceed PHP_INT_MAX on 32-bit PHP. They are
|
||||||
|
// therefore converted to as doubles automatically by PHP.
|
||||||
|
$expectedBigNumberType = (PHP_INT_SIZE < 8 ? 'double' : 'integer');
|
||||||
|
|
||||||
|
return [
|
||||||
|
[42, 42, 'integer'],
|
||||||
|
[42.5, 42.5, 'double'],
|
||||||
|
[-42, -42, 'integer'],
|
||||||
|
[-42.5, -42.5, 'double'],
|
||||||
|
['42', 42, 'integer'],
|
||||||
|
['42.5', 42.5, 'double'],
|
||||||
|
[865640023012945, 865640023012945, $expectedBigNumberType],
|
||||||
|
['865640023012945', 865640023012945, $expectedBigNumberType],
|
||||||
|
[865640023012945.5, 865640023012945.5, 'double'],
|
||||||
|
['865640023012945.5', 865640023012945.5, 'double'],
|
||||||
|
[PHP_INT_MAX, PHP_INT_MAX, 'integer'],
|
||||||
|
[~PHP_INT_MAX + 1, ~PHP_INT_MAX + 1, 'integer'], // ~PHP_INT_MAX === PHP_INT_MIN, PHP_INT_MIN being PHP7+
|
||||||
|
[PHP_INT_MAX + 1, PHP_INT_MAX + 1, 'double'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestFormatNumericCellValueWithNumbers
|
||||||
|
*
|
||||||
|
* @param int|float|string $value
|
||||||
|
* @param int|float $expectedFormattedValue
|
||||||
|
* @param string $expectedType
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testFormatNumericCellValueWithNumbers($value, $expectedFormattedValue, $expectedType)
|
||||||
|
{
|
||||||
|
$styleHelperMock = $this->getMockBuilder('Box\Spout\Reader\XLSX\Helper\StyleHelper')->disableOriginalConstructor()->getMock();
|
||||||
|
$styleHelperMock
|
||||||
|
->expects($this->once())
|
||||||
|
->method('shouldFormatNumericValueAsDate')
|
||||||
|
->will($this->returnValue(false));
|
||||||
|
|
||||||
|
$formatter = new CellValueFormatter(null, $styleHelperMock, false);
|
||||||
|
$formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0);
|
||||||
|
|
||||||
|
$this->assertEquals($expectedFormattedValue, $formattedValue);
|
||||||
|
$this->assertEquals($expectedType, gettype($formattedValue));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestFormatStringCellValue()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
['A', 'A'],
|
||||||
|
[' A ', ' A '],
|
||||||
|
["\n\tA\n\t", "\n\tA\n\t"],
|
||||||
|
[' ', ' '],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestFormatStringCellValue
|
||||||
|
*
|
||||||
|
* @param string $value
|
||||||
|
* @param string $expectedFormattedValue
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testFormatInlineStringCellValue($value, $expectedFormattedValue)
|
||||||
|
{
|
||||||
|
$nodeListMock = $this->getMockBuilder('DOMNodeList')->disableOriginalConstructor()->getMock();
|
||||||
|
$nodeListMock
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('item')
|
||||||
|
->with(0)
|
||||||
|
->will($this->returnValue((object)['nodeValue' => $value]));
|
||||||
|
|
||||||
|
$nodeMock = $this->getMockBuilder('DOMElement')->disableOriginalConstructor()->getMock();
|
||||||
|
$nodeMock
|
||||||
|
->expects($this->atLeastOnce())
|
||||||
|
->method('getElementsByTagName')
|
||||||
|
->with(CellValueFormatter::XML_NODE_INLINE_STRING_VALUE)
|
||||||
|
->will($this->returnValue($nodeListMock));
|
||||||
|
|
||||||
|
$formatter = new CellValueFormatter(null, null, false);
|
||||||
|
$formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatInlineStringCellValue', $nodeMock);
|
||||||
|
|
||||||
|
$this->assertEquals($expectedFormattedValue, $formattedValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class DateFormatHelperTest
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX\Helper
|
||||||
|
*/
|
||||||
|
class DateFormatHelperTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForTestToPHPDateFormat()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// Excel date format, expected PHP date format
|
||||||
|
['m/d/yy hh:mm', 'n/j/y H:i'],
|
||||||
|
['mmm-yy', 'M-y'],
|
||||||
|
['d-mmm-yy', 'j-M-y'],
|
||||||
|
['m/dd/yyyy', 'n/d/Y'],
|
||||||
|
['e mmmmm dddd', 'Y M l'],
|
||||||
|
['MMMM DDD', 'F D'],
|
||||||
|
['hh:mm:ss.s', 'H:i:s'],
|
||||||
|
['h:mm:ss AM/PM', 'g:i:s A'],
|
||||||
|
['hh:mm AM/PM', 'h:i A'],
|
||||||
|
['[$-409]hh:mm AM/PM', 'h:i A'],
|
||||||
|
['[$USD-F480]hh:mm AM/PM', 'h:i A'],
|
||||||
|
['"Day " d', '\\D\\a\\y\\ j'],
|
||||||
|
['yy "Year" m "Month"', 'y \\Y\\e\\a\\r n \\M\\o\\n\\t\\h'],
|
||||||
|
['mmm-yy;@', 'M-y'],
|
||||||
|
['[$-409]hh:mm AM/PM;"foo"@', 'h:i A'],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForTestToPHPDateFormat
|
||||||
|
*
|
||||||
|
* @param string $excelDateFormat
|
||||||
|
* @param string $expectedPHPDateFormat
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testToPHPDateFormat($excelDateFormat, $expectedPHPDateFormat)
|
||||||
|
{
|
||||||
|
$phpDateFormat = DateFormatHelper::toPHPDateFormat($excelDateFormat);
|
||||||
|
$this->assertEquals($expectedPHPDateFormat, $phpDateFormat);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function dataProviderForTestGetBestCachingStrategy()
|
public function dataProviderForTestGetBestCachingStrategy()
|
||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
|
[null, -1, 'FileBasedStrategy'],
|
||||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE, -1, 'FileBasedStrategy'],
|
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE, -1, 'FileBasedStrategy'],
|
||||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE + 10, -1, 'FileBasedStrategy'],
|
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE + 10, -1, 'FileBasedStrategy'],
|
||||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE - 10, -1, 'InMemoryStrategy'],
|
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE - 10, -1, 'InMemoryStrategy'],
|
||||||
@@ -27,7 +28,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
|
|||||||
/**
|
/**
|
||||||
* @dataProvider dataProviderForTestGetBestCachingStrategy
|
* @dataProvider dataProviderForTestGetBestCachingStrategy
|
||||||
*
|
*
|
||||||
* @param int $sharedStringsUniqueCount
|
* @param int|null $sharedStringsUniqueCount
|
||||||
* @param int $memoryLimitInKB
|
* @param int $memoryLimitInKB
|
||||||
* @param string $expectedStrategyClassName
|
* @param string $expectedStrategyClassName
|
||||||
* @return void
|
* @return void
|
||||||
|
|||||||
@@ -82,6 +82,22 @@ class SharedStringsHelperTest extends \PHPUnit_Framework_TestCase
|
|||||||
$sharedStringsHelper->cleanup();
|
$sharedStringsHelper->cleanup();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testGetStringAtIndexShouldWorkWithStringsContainingTextAndHyperlinkInSameCell()
|
||||||
|
{
|
||||||
|
$resourcePath = $this->getResourcePath('one_sheet_with_shared_strings_containing_text_and_hyperlink_in_same_cell.xlsx');
|
||||||
|
$sharedStringsHelper = new SharedStringsHelper($resourcePath);
|
||||||
|
|
||||||
|
$sharedStringsHelper->extractSharedStrings();
|
||||||
|
|
||||||
|
$sharedString = $sharedStringsHelper->getStringAtIndex(0);
|
||||||
|
$this->assertEquals('go to https://github.com please', $sharedString);
|
||||||
|
|
||||||
|
$sharedStringsHelper->cleanup();
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -0,0 +1,179 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Reader\XLSX\Helper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Class StyleHelperTest
|
||||||
|
*
|
||||||
|
* @package Box\Spout\Reader\XLSX\Helper
|
||||||
|
*/
|
||||||
|
class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array|void $styleAttributes
|
||||||
|
* @param array|void $customNumberFormats
|
||||||
|
* @return StyleHelper
|
||||||
|
*/
|
||||||
|
private function getStyleHelperMock($styleAttributes = [], $customNumberFormats = [])
|
||||||
|
{
|
||||||
|
/** @var StyleHelper $styleHelper */
|
||||||
|
$styleHelper = $this->getMockBuilder('\Box\Spout\Reader\XLSX\Helper\StyleHelper')
|
||||||
|
->setConstructorArgs(['/path/to/file.xlsx'])
|
||||||
|
->setMethods(['getCustomNumberFormats', 'getStylesAttributes'])
|
||||||
|
->getMock();
|
||||||
|
|
||||||
|
$styleHelper->method('getStylesAttributes')->willReturn($styleAttributes);
|
||||||
|
$styleHelper->method('getCustomNumberFormats')->willReturn($customNumberFormats);
|
||||||
|
|
||||||
|
return $styleHelper;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithDefaultStyle()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock();
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(0);
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWhenShouldNotApplyNumberFormat()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => false, 'numFmtId' => 14]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithGeneralFormat()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 0]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithNonDateBuiltinFormat()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 9]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithNoNumFmtId()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => null]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithBuiltinDateFormats()
|
||||||
|
{
|
||||||
|
$builtinNumFmtIdsForDate = [14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47];
|
||||||
|
|
||||||
|
foreach ($builtinNumFmtIdsForDate as $builtinNumFmtIdForDate) {
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => $builtinNumFmtIdForDate]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
|
||||||
|
$this->assertTrue($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWhenApplyNumberFormatNotSetAndUsingBuiltinDateFormat()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => null, 'numFmtId' => 14]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
|
||||||
|
$this->assertTrue($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWhenApplyNumberFormatNotSetAndUsingBuiltinNonDateFormat()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => null, 'numFmtId' => 9]]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWhenCustomNumberFormatNotFound()
|
||||||
|
{
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 165]], [166 => []]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
|
||||||
|
$this->assertFalse($shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function dataProviderForCustomDateFormats()
|
||||||
|
{
|
||||||
|
return [
|
||||||
|
// number format, expectedResult
|
||||||
|
['[$-409]dddd\,\ mmmm\ d\,\ yy', true],
|
||||||
|
['[$-409]d\-mmm\-yy;@', true],
|
||||||
|
['[$-409]d\-mmm\-yyyy;@', true],
|
||||||
|
['mm/dd/yy;@', true],
|
||||||
|
['MM/DD/YY;@', true],
|
||||||
|
['[$-F800]dddd\,\ mmmm\ dd\,\ yyyy', true],
|
||||||
|
['m/d;@', true],
|
||||||
|
['m/d/yy;@', true],
|
||||||
|
['[$-409]d\-mmm;@', true],
|
||||||
|
['[$-409]dd\-mmm\-yy;@', true],
|
||||||
|
['[$-409]mmm\-yy;@', true],
|
||||||
|
['[$-409]mmmm\-yy;@', true],
|
||||||
|
['[$-409]mmmm\ d\,\ yyyy;@', true],
|
||||||
|
['[$-409]m/d/yy\ h:mm\ AM/PM;@', true],
|
||||||
|
['m/d/yy\ h:mm;@', true],
|
||||||
|
['[$-409]mmmmm;@', true],
|
||||||
|
['[$-409]MMmmM;@', true],
|
||||||
|
['[$-409]mmmmm\-yy;@', true],
|
||||||
|
['m/d/yyyy;@', true],
|
||||||
|
['[$-409]m/d/yy\--h:mm;@', true],
|
||||||
|
['General', false],
|
||||||
|
['GENERAL', false],
|
||||||
|
['\ma\yb\e', false],
|
||||||
|
['[Red]foo;', false],
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @dataProvider dataProviderForCustomDateFormats
|
||||||
|
*
|
||||||
|
* @param string $numberFormat
|
||||||
|
* @param bool $expectedResult
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldFormatNumericValueAsDateWithCustomDateFormats($numberFormat, $expectedResult)
|
||||||
|
{
|
||||||
|
$numFmtId = 165;
|
||||||
|
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => $numFmtId]], [$numFmtId => $numberFormat]);
|
||||||
|
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||||
|
|
||||||
|
$this->assertEquals($expectedResult, $shouldFormatAsDate);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -23,7 +23,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
{
|
{
|
||||||
return [
|
return [
|
||||||
['/path/to/fake/file.xlsx'],
|
['/path/to/fake/file.xlsx'],
|
||||||
['file_with_no_sheets_in_content_types.xlsx'],
|
['file_with_no_sheets_in_workbook_xml.xlsx'],
|
||||||
['file_with_sheet_xml_not_matching_content_types.xlsx'],
|
['file_with_sheet_xml_not_matching_content_types.xlsx'],
|
||||||
['file_corrupted.xlsx'],
|
['file_corrupted.xlsx'],
|
||||||
];
|
];
|
||||||
@@ -73,6 +73,73 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportSheetsDefinitionInRandomOrder()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('two_sheets_with_sheets_definition_in_reverse_order.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['s1 - A1', 's1 - B1', 's1 - C1', 's1 - D1', 's1 - E1'],
|
||||||
|
['s1 - A2', 's1 - B2', 's1 - C2', 's1 - D2', 's1 - E2'],
|
||||||
|
['s1 - A3', 's1 - B3', 's1 - C3', 's1 - D3', 's1 - E3'],
|
||||||
|
['s1 - A4', 's1 - B4', 's1 - C4', 's1 - D4', 's1 - E4'],
|
||||||
|
['s1 - A5', 's1 - B5', 's1 - C5', 's1 - D5', 's1 - E5'],
|
||||||
|
['s2 - A1', 's2 - B1', 's2 - C1', 's2 - D1', 's2 - E1'],
|
||||||
|
['s2 - A2', 's2 - B2', 's2 - C2', 's2 - D2', 's2 - E2'],
|
||||||
|
['s2 - A3', 's2 - B3', 's2 - C3', 's2 - D3', 's2 - E3'],
|
||||||
|
['s2 - A4', 's2 - B4', 's2 - C4', 's2 - D4', 's2 - E4'],
|
||||||
|
['s2 - A5', 's2 - B5', 's2 - C5', 's2 - D5', 's2 - E5'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportPrefixedXMLFiles()
|
||||||
|
{
|
||||||
|
// The XML files of this spreadsheet are prefixed.
|
||||||
|
// For instance, they use "<x:sheet>" instead of "<sheet>", etc.
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_prefixed_xml_files.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['s1 - A1', 's1 - B1', 's1 - C1'],
|
||||||
|
['s1 - A2', 's1 - B2', 's1 - C2'],
|
||||||
|
['s1 - A3', 's1 - B3', 's1 - C3'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAttribute()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['s1--A1', 's1--B1'],
|
||||||
|
['s1--A2', 's1--B2'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAndCountAttributes()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count_and_count.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['s1--A1', 's1--B1'],
|
||||||
|
['s1--A2', 's1--B2'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -87,6 +154,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportFilesWithoutCellReference()
|
||||||
|
{
|
||||||
|
// file where the cell definition does not have a "r" attribute
|
||||||
|
// as in <c r="A1">...</c>
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_missing_cell_reference.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['s1--A1'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -113,6 +195,105 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportNumericTimestampFormattedDifferentlyAsDate()
|
||||||
|
{
|
||||||
|
// make sure dates are always created with the same timezone
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_same_numeric_value_date_formatted_differently.xlsx');
|
||||||
|
|
||||||
|
$expectedDate = \DateTime::createFromFormat('Y-m-d H:i:s', '2015-01-01 00:00:00');
|
||||||
|
$expectedRows = [
|
||||||
|
array_fill(0, 10, $expectedDate),
|
||||||
|
array_fill(0, 10, $expectedDate),
|
||||||
|
array_fill(0, 10, $expectedDate),
|
||||||
|
array_merge(array_fill(0, 7, $expectedDate), ['', '', '']),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportDifferentDatesAsNumericTimestamp()
|
||||||
|
{
|
||||||
|
// make sure dates are always created with the same timezone
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_different_numeric_value_dates.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
[
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '2015-09-01 00:00:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '2015-09-02 00:00:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '2015-09-01 22:23:00'),
|
||||||
|
],
|
||||||
|
[
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-28 23:59:59'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-03-01 00:00:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-02-28 11:00:00'), // 1900-02-29 should be converted to 1900-02-28
|
||||||
|
]
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportDifferentTimesAsNumericTimestamp()
|
||||||
|
{
|
||||||
|
// make sure dates are always created with the same timezone
|
||||||
|
date_default_timezone_set('UTC');
|
||||||
|
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_different_numeric_value_times.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
[
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 00:00:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 11:29:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 23:29:00'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 01:42:25'),
|
||||||
|
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 13:42:25'),
|
||||||
|
]
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldSupportFormatDatesAndTimesIfSpecified()
|
||||||
|
{
|
||||||
|
$shouldFormatDates = true;
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.xlsx', $shouldFormatDates);
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['1/13/2016', '01/13/2016', '13-Jan-16', 'Wednesday January 13, 16', 'Today is 1/13/2016'],
|
||||||
|
['4:43:25', '04:43', '4:43', '4:43:25 AM', '4:43:25 PM'],
|
||||||
|
['1976-11-22T08:30:00.000', '1976-11-22T08:30', '1582-10-15', '08:30:00', '08:30'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldApplyCustomDateFormatNumberEvenIfApplyNumberFormatNotSpecified()
|
||||||
|
{
|
||||||
|
$shouldFormatDates = true;
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_custom_date_formats_and_no_apply_number_format.xlsx', $shouldFormatDates);
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
// "General", "GENERAL", "MM/DD/YYYY", "MM/dd/YYYY", "H:MM:SS"
|
||||||
|
['42382', '42382', '01/13/2016', '01/13/2016', '4:43:25'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -171,16 +352,39 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
public function testReadShouldSkipEmptyRows()
|
public function testReadShouldSkipEmptyRowsIfShouldPreserveEmptyRowsNotSet()
|
||||||
{
|
{
|
||||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_row.xlsx');
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows_and_missing_row_index.xlsx');
|
||||||
|
|
||||||
$this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped');
|
$this->assertEquals(3, count($allRows), 'There should be only 3 rows, because the empty rows are skipped');
|
||||||
|
|
||||||
$expectedRows = [
|
$expectedRows = [
|
||||||
['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'],
|
|
||||||
// skipped row here
|
// skipped row here
|
||||||
['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'],
|
['s1--A2', 's1--B2', 's1--C2'],
|
||||||
|
// skipped row here
|
||||||
|
// skipped row here
|
||||||
|
['s1--A5', 's1--B5', 's1--C5'],
|
||||||
|
['s1--A6', 's1--B6', 's1--C6'],
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows_and_missing_row_index.xlsx', false, true);
|
||||||
|
|
||||||
|
$this->assertEquals(6, count($allRows), 'There should be 6 rows');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
[''],
|
||||||
|
['s1--A2', 's1--B2', 's1--C2'],
|
||||||
|
[''],
|
||||||
|
[''],
|
||||||
|
['s1--A5', 's1--B5', 's1--C5'],
|
||||||
|
['s1--A6', 's1--B6', 's1--C6'],
|
||||||
];
|
];
|
||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
@@ -336,16 +540,96 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedRows, $allRows);
|
$this->assertEquals($expectedRows, $allRows);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::XLSX);
|
||||||
|
$reader->open('unsupported://foobar');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||||
|
*
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadWithSupportedCustomStreamWrapper()
|
||||||
|
{
|
||||||
|
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||||
|
$reader = ReaderFactory::create(Type::XLSX);
|
||||||
|
$reader->open('php://memory');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldInludeRowsWithZerosOnly()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['1', '2', '3'],
|
||||||
|
['0', '0', '0']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/184
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A', 'B', 'C'],
|
||||||
|
['0', '', ''],
|
||||||
|
['1', '1', '']
|
||||||
|
];
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* https://github.com/box/spout/issues/195
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReaderShouldNotTrimCellValues()
|
||||||
|
{
|
||||||
|
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_inline_strings.xlsx');
|
||||||
|
|
||||||
|
$expectedRows = [
|
||||||
|
['A'],
|
||||||
|
[' A '],
|
||||||
|
["\n\tA\n\t"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
|
* @param bool|void $shouldFormatDates
|
||||||
|
* @param bool|void $shouldPreserveEmptyRows
|
||||||
* @return array All the read rows the given file
|
* @return array All the read rows the given file
|
||||||
*/
|
*/
|
||||||
private function getAllRowsForFile($fileName)
|
private function getAllRowsForFile($fileName, $shouldFormatDates = false, $shouldPreserveEmptyRows = false)
|
||||||
{
|
{
|
||||||
$allRows = [];
|
$allRows = [];
|
||||||
$resourcePath = $this->getResourcePath($fileName);
|
$resourcePath = $this->getResourcePath($fileName);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||||
$reader = ReaderFactory::create(Type::XLSX);
|
$reader = ReaderFactory::create(Type::XLSX);
|
||||||
|
$reader->setShouldFormatDates($shouldFormatDates);
|
||||||
|
$reader->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
|
||||||
$reader->open($resourcePath);
|
$reader->open($resourcePath);
|
||||||
|
|
||||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ namespace Box\Spout;
|
|||||||
*
|
*
|
||||||
* @package Box\Spout
|
* @package Box\Spout
|
||||||
*/
|
*/
|
||||||
trait TestUsingResource {
|
trait TestUsingResource
|
||||||
|
{
|
||||||
/** @var string Path to the test resources folder */
|
/** @var string Path to the test resources folder */
|
||||||
private $resourcesPath = 'tests/resources';
|
private $resourcesPath = 'tests/resources';
|
||||||
|
|
||||||
@@ -18,6 +18,9 @@ trait TestUsingResource {
|
|||||||
/** @var string Path to the test resources folder, that does not have writing permissions */
|
/** @var string Path to the test resources folder, that does not have writing permissions */
|
||||||
private $generatedUnwritableResourcesPath = 'tests/resources/generated/unwritable';
|
private $generatedUnwritableResourcesPath = 'tests/resources/generated/unwritable';
|
||||||
|
|
||||||
|
/** @var string Path to the test temp folder */
|
||||||
|
private $tempFolderPath = 'tests/resources/generated/temp';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param string $resourceName
|
* @param string $resourceName
|
||||||
* @return string|null Path of the resource who matches the given name or null if resource not found
|
* @return string|null Path of the resource who matches the given name or null if resource not found
|
||||||
@@ -70,6 +73,11 @@ trait TestUsingResource {
|
|||||||
*/
|
*/
|
||||||
protected function createUnwritableFolderIfNeeded()
|
protected function createUnwritableFolderIfNeeded()
|
||||||
{
|
{
|
||||||
|
// On Windows, chmod() or the mkdir's mode is ignored
|
||||||
|
if ($this->isWindows()) {
|
||||||
|
$this->markTestSkipped('Skipping because Windows cannot create read-only folders through PHP');
|
||||||
|
}
|
||||||
|
|
||||||
if (!file_exists($this->generatedUnwritableResourcesPath)) {
|
if (!file_exists($this->generatedUnwritableResourcesPath)) {
|
||||||
// Make sure generated folder exists first
|
// Make sure generated folder exists first
|
||||||
if (!file_exists($this->generatedResourcesPath)) {
|
if (!file_exists($this->generatedResourcesPath)) {
|
||||||
@@ -80,4 +88,54 @@ trait TestUsingResource {
|
|||||||
mkdir($this->generatedUnwritableResourcesPath, 0444, true);
|
mkdir($this->generatedUnwritableResourcesPath, 0444, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return string Path of the temp folder
|
||||||
|
*/
|
||||||
|
protected function getTempFolderPath()
|
||||||
|
{
|
||||||
|
return realpath($this->tempFolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
protected function recreateTempFolder()
|
||||||
|
{
|
||||||
|
if (file_exists($this->tempFolderPath)) {
|
||||||
|
$this->deleteFolderRecursively($this->tempFolderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdir($this->tempFolderPath, 0777, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $folderPath
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
private function deleteFolderRecursively($folderPath)
|
||||||
|
{
|
||||||
|
$itemIterator = new \RecursiveIteratorIterator(
|
||||||
|
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||||
|
\RecursiveIteratorIterator::CHILD_FIRST
|
||||||
|
);
|
||||||
|
|
||||||
|
foreach ($itemIterator as $item) {
|
||||||
|
if ($item->isDir()) {
|
||||||
|
rmdir($item->getPathname());
|
||||||
|
} else {
|
||||||
|
unlink($item->getPathname());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rmdir($folderPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return bool Whether the OS on which PHP is installed is Windows
|
||||||
|
*/
|
||||||
|
protected function isWindows()
|
||||||
|
{
|
||||||
|
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -22,7 +22,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testWriteShouldThrowExceptionIfCannotOpenFileForWriting()
|
public function testWriteShouldThrowExceptionIfCannotOpenFileForWriting()
|
||||||
{
|
{
|
||||||
$fileName = 'file_that_wont_be_written.csv';
|
$fileName = 'file_that_wont_be_written.csv';
|
||||||
$this->createUnwritableFolderIfNeeded($fileName);
|
$this->createUnwritableFolderIfNeeded();
|
||||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||||
|
|
||||||
$writer = WriterFactory::create(Type::CSV);
|
$writer = WriterFactory::create(Type::CSV);
|
||||||
@@ -74,6 +74,33 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should contain a UTF-8 BOM');
|
$this->assertContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should contain a UTF-8 BOM');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testWriteShouldNotAddUtf8Bom()
|
||||||
|
{
|
||||||
|
$allRows = [
|
||||||
|
['csv--11', 'csv--12'],
|
||||||
|
];
|
||||||
|
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_no_bom.csv', ',', '"', false);
|
||||||
|
|
||||||
|
$this->assertNotContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should not contain a UTF-8 BOM');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testWriteShouldSupportAssociativeArrays()
|
||||||
|
{
|
||||||
|
$allRows = [
|
||||||
|
['foo' => 'csv--11', 'bar' => 'csv--12'],
|
||||||
|
];
|
||||||
|
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_from_associative_arrays.csv');
|
||||||
|
$writtenContent = $this->trimWrittenContent($writtenContent);
|
||||||
|
|
||||||
|
$this->assertEquals('csv--11,csv--12', $writtenContent, 'Values from associative arrays should be written');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -138,9 +165,10 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
* @param string $fieldDelimiter
|
* @param string $fieldDelimiter
|
||||||
* @param string $fieldEnclosure
|
* @param string $fieldEnclosure
|
||||||
|
* @param bool $shouldAddBOM
|
||||||
* @return null|string
|
* @return null|string
|
||||||
*/
|
*/
|
||||||
private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"')
|
private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"', $shouldAddBOM = true)
|
||||||
{
|
{
|
||||||
$this->createGeneratedFolderIfNeeded($fileName);
|
$this->createGeneratedFolderIfNeeded($fileName);
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
@@ -148,6 +176,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$writer = WriterFactory::create(Type::CSV);
|
$writer = WriterFactory::create(Type::CSV);
|
||||||
$writer->setFieldDelimiter($fieldDelimiter);
|
$writer->setFieldDelimiter($fieldDelimiter);
|
||||||
$writer->setFieldEnclosure($fieldEnclosure);
|
$writer->setFieldEnclosure($fieldEnclosure);
|
||||||
|
$writer->setShouldAddBOM($shouldAddBOM);
|
||||||
|
|
||||||
$writer->openToFile($resourcePath);
|
$writer->openToFile($resourcePath);
|
||||||
$writer->addRows($allRows);
|
$writer->addRows($allRows);
|
||||||
|
|||||||
@@ -35,6 +35,23 @@ class CellHelperTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals($expectedCellIndex, CellHelper::getCellIndexFromColumnIndex($columnIndex));
|
$this->assertEquals($expectedCellIndex, CellHelper::getCellIndexFromColumnIndex($columnIndex));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return array
|
||||||
|
*/
|
||||||
|
public function testIsEmpty()
|
||||||
|
{
|
||||||
|
$this->assertTrue(CellHelper::isEmpty(null));
|
||||||
|
$this->assertTrue(CellHelper::isEmpty(""));
|
||||||
|
|
||||||
|
$this->assertFalse(CellHelper::isEmpty("string"));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(0));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(1));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(true));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(false));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(["string"]));
|
||||||
|
$this->assertFalse(CellHelper::isEmpty(new \stdClass()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return array
|
* @return array
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,9 +2,11 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Writer\ODS;
|
namespace Box\Spout\Writer\ODS;
|
||||||
|
|
||||||
|
use Box\Spout\Common\Exception\SpoutException;
|
||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
use Box\Spout\TestUsingResource;
|
use Box\Spout\TestUsingResource;
|
||||||
|
use Box\Spout\Writer\Common\Helper\ZipHelper;
|
||||||
use Box\Spout\Writer\WriterFactory;
|
use Box\Spout\Writer\WriterFactory;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,7 +24,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
||||||
{
|
{
|
||||||
$fileName = 'file_that_wont_be_written.ods';
|
$fileName = 'file_that_wont_be_written.ods';
|
||||||
$this->createUnwritableFolderIfNeeded($fileName);
|
$this->createUnwritableFolderIfNeeded();
|
||||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||||
|
|
||||||
$writer = WriterFactory::create(Type::ODS);
|
$writer = WriterFactory::create(Type::ODS);
|
||||||
@@ -90,6 +92,39 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->writeToODSFile($dataRows, $fileName);
|
$this->writeToODSFile($dataRows, $fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowShouldCleanupAllFilesIfExceptionIsThrown()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_should_cleanup_all_files_if_exception_thrown.ods';
|
||||||
|
$dataRows = [
|
||||||
|
['wrong'],
|
||||||
|
[new \stdClass()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->createGeneratedFolderIfNeeded($fileName);
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
|
||||||
|
$this->recreateTempFolder();
|
||||||
|
$tempFolderPath = $this->getTempFolderPath();
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Writer\ODS\Writer $writer */
|
||||||
|
$writer = WriterFactory::create(Type::ODS);
|
||||||
|
$writer->setTempFolder($tempFolderPath);
|
||||||
|
$writer->openToFile($resourcePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$writer->addRows($dataRows);
|
||||||
|
$this->fail('Exception should have been thrown');
|
||||||
|
} catch (SpoutException $e) {
|
||||||
|
$this->assertFalse(file_exists($fileName), 'Output file should have been deleted');
|
||||||
|
|
||||||
|
$numFiles = iterator_count(new \FilesystemIterator($tempFolderPath, \FilesystemIterator::SKIP_DOTS));
|
||||||
|
$this->assertEquals(0, $numFiles, 'All temp files should have been deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -176,6 +211,25 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowShouldSupportAssociativeArrays()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_should_support_associative_arrays.ods';
|
||||||
|
$dataRows = [
|
||||||
|
['foo' => 'ods--11', 'bar' => 'ods--12'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToODSFile($dataRows, $fileName);
|
||||||
|
|
||||||
|
foreach ($dataRows as $dataRow) {
|
||||||
|
foreach ($dataRow as $cellValue) {
|
||||||
|
$this->assertValueWasWritten($fileName, $cellValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -368,6 +422,30 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertValueWasWrittenToSheet($fileName, 1, 'a dream');
|
$this->assertValueWasWrittenToSheet($fileName, 1, 'a dream');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testGeneratedFileShouldHaveTheCorrectMimeType()
|
||||||
|
{
|
||||||
|
// Only PHP7+ gives the correct mime type since it requires adding
|
||||||
|
// uncompressed files to the final archive (which support was added in PHP7)
|
||||||
|
if (!ZipHelper::canChooseCompressionMethod()) {
|
||||||
|
$this->markTestSkipped(
|
||||||
|
'The PHP version used does not support setting the compression method of archived files,
|
||||||
|
resulting in the mime type to be detected incorrectly.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fileName = 'test_mime_type.ods';
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
$dataRow = ['foo'];
|
||||||
|
|
||||||
|
$this->writeToODSFile([$dataRow], $fileName);
|
||||||
|
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$this->assertEquals('application/vnd.oasis.opendocument.spreadsheet', $finfo->file($resourcePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
@@ -494,10 +572,9 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
private function moveReaderToCorrectTableNode($fileName, $sheetIndex)
|
private function moveReaderToCorrectTableNode($fileName, $sheetIndex)
|
||||||
{
|
{
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToSheetFile = $resourcePath . '#content.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToSheetFile);
|
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||||
$xmlReader->readUntilNodeFound('table:table');
|
$xmlReader->readUntilNodeFound('table:table');
|
||||||
|
|
||||||
for ($i = 1; $i < $sheetIndex; $i++) {
|
for ($i = 1; $i < $sheetIndex; $i++) {
|
||||||
|
|||||||
@@ -5,6 +5,9 @@ namespace Box\Spout\Writer\ODS;
|
|||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
use Box\Spout\TestUsingResource;
|
use Box\Spout\TestUsingResource;
|
||||||
|
use Box\Spout\Writer\ODS\Helper\BorderHelper;
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
use Box\Spout\Writer\Style\BorderBuilder;
|
||||||
use Box\Spout\Writer\Style\Color;
|
use Box\Spout\Writer\Style\Color;
|
||||||
use Box\Spout\Writer\Style\Style;
|
use Box\Spout\Writer\Style\Style;
|
||||||
use Box\Spout\Writer\Style\StyleBuilder;
|
use Box\Spout\Writer\Style\StyleBuilder;
|
||||||
@@ -115,6 +118,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
->setFontSize(15)
|
->setFontSize(15)
|
||||||
->setFontColor(Color::RED)
|
->setFontColor(Color::RED)
|
||||||
->setFontName('Cambria')
|
->setFontName('Cambria')
|
||||||
|
->setBackgroundColor(Color::GREEN)
|
||||||
->build();
|
->build();
|
||||||
|
|
||||||
$this->writeToODSFileWithMultipleStyles($dataRows, $fileName, [$style, $style2]);
|
$this->writeToODSFileWithMultipleStyles($dataRows, $fileName, [$style, $style2]);
|
||||||
@@ -134,6 +138,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertFirstChildHasAttributeEquals('15pt', $customFont2Element, 'text-properties', 'fo:font-size');
|
$this->assertFirstChildHasAttributeEquals('15pt', $customFont2Element, 'text-properties', 'fo:font-size');
|
||||||
$this->assertFirstChildHasAttributeEquals('#' . Color::RED, $customFont2Element, 'text-properties', 'fo:color');
|
$this->assertFirstChildHasAttributeEquals('#' . Color::RED, $customFont2Element, 'text-properties', 'fo:color');
|
||||||
$this->assertFirstChildHasAttributeEquals('Cambria', $customFont2Element, 'text-properties', 'style:font-name');
|
$this->assertFirstChildHasAttributeEquals('Cambria', $customFont2Element, 'text-properties', 'style:font-name');
|
||||||
|
$this->assertFirstChildHasAttributeEquals('#' . Color::GREEN, $customFont2Element, 'table-cell-properties', 'fo:background-color');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -236,6 +241,108 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option');
|
$this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddBackgroundColor()
|
||||||
|
{
|
||||||
|
$fileName = 'test_default_background_style.ods';
|
||||||
|
$dataRows = [
|
||||||
|
['defaultBgColor'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$style = (new StyleBuilder())->setBackgroundColor(Color::WHITE)->build();
|
||||||
|
$this->writeToODSFile($dataRows, $fileName, $style);
|
||||||
|
|
||||||
|
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
|
||||||
|
$this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)');
|
||||||
|
|
||||||
|
$customStyleElement = $styleElements[1];
|
||||||
|
$this->assertFirstChildHasAttributeEquals('#' . Color::WHITE, $customStyleElement, 'table-cell-properties', 'fo:background-color');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testBorders()
|
||||||
|
{
|
||||||
|
$fileName = 'test_borders.ods';
|
||||||
|
|
||||||
|
$dataRows = [
|
||||||
|
['row-with-border-bottom-green-thick-solid'],
|
||||||
|
['row-without-border'],
|
||||||
|
['row-with-border-top-red-thin-dashed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$borderBottomGreenThickSolid = (new BorderBuilder())
|
||||||
|
->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build();
|
||||||
|
|
||||||
|
|
||||||
|
$borderTopRedThinDashed = (new BorderBuilder())
|
||||||
|
->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||||
|
|
||||||
|
$styles = [
|
||||||
|
(new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(),
|
||||||
|
(new StyleBuilder())->build(),
|
||||||
|
(new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToODSFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||||
|
|
||||||
|
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
|
||||||
|
|
||||||
|
$this->assertEquals(3, count($styleElements), 'There should be 3 styles)');
|
||||||
|
|
||||||
|
// Use reflection for protected members here
|
||||||
|
$widthMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'widthMap');
|
||||||
|
$styleMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'styleMap');
|
||||||
|
|
||||||
|
$expectedFirst = sprintf(
|
||||||
|
'%s %s #%s',
|
||||||
|
$widthMap[Border::WIDTH_THICK],
|
||||||
|
$styleMap[Border::STYLE_SOLID],
|
||||||
|
Color::GREEN
|
||||||
|
);
|
||||||
|
|
||||||
|
$actualFirst = $styleElements[1]
|
||||||
|
->getElementsByTagName('table-cell-properties')
|
||||||
|
->item(0)
|
||||||
|
->getAttribute('fo:border-bottom');
|
||||||
|
|
||||||
|
$this->assertEquals($expectedFirst, $actualFirst);
|
||||||
|
|
||||||
|
$expectedThird = sprintf(
|
||||||
|
'%s %s #%s',
|
||||||
|
$widthMap[Border::WIDTH_THIN],
|
||||||
|
$styleMap[Border::STYLE_DASHED],
|
||||||
|
Color::RED
|
||||||
|
);
|
||||||
|
|
||||||
|
$actualThird = $styleElements[2]
|
||||||
|
->getElementsByTagName('table-cell-properties')
|
||||||
|
->item(0)
|
||||||
|
->getAttribute('fo:border-top');
|
||||||
|
|
||||||
|
$this->assertEquals($expectedThird, $actualThird);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testSetDefaultRowStyle()
|
||||||
|
{
|
||||||
|
$fileName = 'test_set_default_row_style.ods';
|
||||||
|
$dataRows = [['ods--11']];
|
||||||
|
|
||||||
|
$defaultFontSize = 50;
|
||||||
|
$defaultStyle = (new StyleBuilder())->setFontSize($defaultFontSize)->build();
|
||||||
|
|
||||||
|
$this->writeToODSFileWithDefaultStyle($dataRows, $fileName, $defaultStyle);
|
||||||
|
|
||||||
|
$textPropertiesElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'style:text-properties');
|
||||||
|
$this->assertEquals($defaultFontSize . 'pt', $textPropertiesElement->getAttribute('fo:font-size'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
@@ -257,6 +364,28 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
return $writer;
|
return $writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $allRows
|
||||||
|
* @param string $fileName
|
||||||
|
* @param \Box\Spout\Writer\Style\Style|null $defaultStyle
|
||||||
|
* @return Writer
|
||||||
|
*/
|
||||||
|
private function writeToODSFileWithDefaultStyle($allRows, $fileName, $defaultStyle)
|
||||||
|
{
|
||||||
|
$this->createGeneratedFolderIfNeeded($fileName);
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||||
|
$writer = WriterFactory::create(Type::ODS);
|
||||||
|
$writer->setDefaultRowStyle($defaultStyle);
|
||||||
|
|
||||||
|
$writer->openToFile($resourcePath);
|
||||||
|
$writer->addRows($allRows);
|
||||||
|
$writer->close();
|
||||||
|
|
||||||
|
return $writer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
@@ -296,17 +425,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$cellElements = [];
|
$cellElements = [];
|
||||||
|
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToStylesXmlFile = $resourcePath . '#content.xml';
|
|
||||||
|
|
||||||
$xmlReader = new \XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||||
|
|
||||||
while ($xmlReader->read()) {
|
while ($xmlReader->read()) {
|
||||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'table:table-cell' && $xmlReader->getAttribute('office:value-type') !== null) {
|
if ($xmlReader->isPositionedOnStartingNode('table:table-cell') && $xmlReader->getAttribute('office:value-type') !== null) {
|
||||||
$cellElements[] = $xmlReader->expand();
|
$cellElements[] = $xmlReader->expand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
|
||||||
return $cellElements;
|
return $cellElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -319,17 +449,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$cellStyleElements = [];
|
$cellStyleElements = [];
|
||||||
|
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToStylesXmlFile = $resourcePath . '#content.xml';
|
|
||||||
|
|
||||||
$xmlReader = new \XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||||
|
|
||||||
while ($xmlReader->read()) {
|
while ($xmlReader->read()) {
|
||||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'style:style' && $xmlReader->getAttribute('style:family') === 'table-cell') {
|
if ($xmlReader->isPositionedOnStartingNode('style:style') && $xmlReader->getAttribute('style:family') === 'table-cell') {
|
||||||
$cellStyleElements[] = $xmlReader->expand();
|
$cellStyleElements[] = $xmlReader->expand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
|
||||||
return $cellStyleElements;
|
return $cellStyleElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -341,10 +472,9 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
||||||
{
|
{
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToStylesXmlFile = $resourcePath . '#styles.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
$xmlReader->openFileInZip($resourcePath, 'styles.xml');
|
||||||
$xmlReader->readUntilNodeFound($section);
|
$xmlReader->readUntilNodeFound($section);
|
||||||
|
|
||||||
return $xmlReader->expand();
|
return $xmlReader->expand();
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace Box\Spout\Writer\Style;
|
||||||
|
|
||||||
|
class BorderTest extends \PHPUnit_Framework_TestCase
|
||||||
|
{
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testValidInstance()
|
||||||
|
{
|
||||||
|
$noConstructorParams = new Border();
|
||||||
|
$withConstructorParams = new Border([
|
||||||
|
new BorderPart(Border::LEFT)
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidNameException
|
||||||
|
*/
|
||||||
|
public function testInvalidBorderPart()
|
||||||
|
{
|
||||||
|
$invalidBorderPart = new BorderPart('invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidStyleException
|
||||||
|
*/
|
||||||
|
public function testInvalidBorderPartStyle()
|
||||||
|
{
|
||||||
|
$invalidBorderPartStyle = new BorderPart(Border::LEFT, Color::BLACK, Border::WIDTH_THIN, 'invalid');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidWidthException
|
||||||
|
*/
|
||||||
|
public function testInvalidBorderPartWidth()
|
||||||
|
{
|
||||||
|
$invalidBorderPartStyle = new BorderPart(Border::LEFT, Color::BLACK, 'invalid', Border::STYLE_DASHED);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testNotMoreThanFourPartsPossible()
|
||||||
|
{
|
||||||
|
$border = new Border();
|
||||||
|
$border
|
||||||
|
->addPart(new BorderPart(Border::LEFT))
|
||||||
|
->addPart(new BorderPart(Border::RIGHT))
|
||||||
|
->addPart(new BorderPart(Border::TOP))
|
||||||
|
->addPart(new BorderPart(Border::BOTTOM))
|
||||||
|
->addPart(new BorderPart(Border::LEFT));
|
||||||
|
|
||||||
|
$this->assertEquals(4, count($border->getParts()), 'There should never be more than 4 border parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testSetParts()
|
||||||
|
{
|
||||||
|
$border = new Border();
|
||||||
|
$border->setParts([
|
||||||
|
new BorderPart(Border::LEFT)
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertEquals(1, count($border->getParts()), 'It should be possible to set the border parts');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testBorderBuilderFluent()
|
||||||
|
{
|
||||||
|
$border = (new BorderBuilder())
|
||||||
|
->setBorderBottom()
|
||||||
|
->setBorderTop()
|
||||||
|
->setBorderLeft()
|
||||||
|
->setBorderRight()
|
||||||
|
->build();
|
||||||
|
$this->assertEquals(4, count($border->getParts()), 'The border builder exposes a fluent interface');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* :D :S
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAnyCombinationOfAllowedBorderPartsParams()
|
||||||
|
{
|
||||||
|
$color = Color::BLACK;
|
||||||
|
foreach (BorderPart::getAllowedNames() as $allowedName) {
|
||||||
|
foreach (BorderPart::getAllowedStyles() as $allowedStyle) {
|
||||||
|
foreach (BorderPart::getAllowedWidths() as $allowedWidth) {
|
||||||
|
$borderPart = new BorderPart($allowedName, $color, $allowedWidth, $allowedStyle);
|
||||||
|
$border = new Border();
|
||||||
|
$border->addPart($borderPart);
|
||||||
|
$this->assertEquals(1, count($border->getParts()));
|
||||||
|
|
||||||
|
/** @var $part BorderPart */
|
||||||
|
$part = $border->getParts()[$allowedName];
|
||||||
|
|
||||||
|
$this->assertEquals($allowedStyle, $part->getStyle());
|
||||||
|
$this->assertEquals($allowedWidth, $part->getWidth());
|
||||||
|
$this->assertEquals($color, $part->getColor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -129,4 +129,32 @@ class StyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
|
|
||||||
$this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize());
|
$this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testStyleBuilderShouldApplyBorders()
|
||||||
|
{
|
||||||
|
$border = (new BorderBuilder())
|
||||||
|
->setBorderBottom()
|
||||||
|
->build();
|
||||||
|
$style = (new StyleBuilder())->setBorder($border)->build();
|
||||||
|
$this->assertTrue($style->shouldApplyBorder());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testStyleBuilderShouldMergeBorders()
|
||||||
|
{
|
||||||
|
$border = (new BorderBuilder())->setBorderBottom(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||||
|
|
||||||
|
$baseStyle = (new StyleBuilder())->setBorder($border)->build();
|
||||||
|
$currentStyle = (new StyleBuilder())->build();
|
||||||
|
$mergedStyle = $currentStyle->mergeWith($baseStyle);
|
||||||
|
|
||||||
|
$this->assertEquals(null, $currentStyle->getBorder(), 'Current style has no border');
|
||||||
|
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $baseStyle->getBorder(), 'Base style has a border');
|
||||||
|
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $mergedStyle->getBorder(), 'Merged style has a border');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,9 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Writer\XLSX\Helper;
|
namespace Box\Spout\Writer\XLSX\Helper;
|
||||||
|
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
use Box\Spout\Writer\Style\BorderBuilder;
|
||||||
|
use Box\Spout\Writer\Style\Color;
|
||||||
use Box\Spout\Writer\Style\StyleBuilder;
|
use Box\Spout\Writer\Style\StyleBuilder;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,6 +60,27 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals(1, $registeredStyle2->getId());
|
$this->assertEquals(1, $registeredStyle2->getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testShouldApplyStyleOnEmptyCell()
|
||||||
|
{
|
||||||
|
$styleWithFont = (new StyleBuilder())->setFontBold()->build();
|
||||||
|
$styleWithBackground = (new StyleBuilder())->setBackgroundColor(Color::BLUE)->build();
|
||||||
|
|
||||||
|
$border = (new BorderBuilder())->setBorderBottom(Color::GREEN)->build();
|
||||||
|
$styleWithBorder = (new StyleBuilder())->setBorder($border)->build();
|
||||||
|
|
||||||
|
$styleHelper = new StyleHelper($this->defaultStyle);
|
||||||
|
$styleHelper->registerStyle($styleWithFont);
|
||||||
|
$styleHelper->registerStyle($styleWithBackground);
|
||||||
|
$styleHelper->registerStyle($styleWithBorder);
|
||||||
|
|
||||||
|
$this->assertFalse($styleHelper->shouldApplyStyleOnEmptyCell($styleWithFont->getId()));
|
||||||
|
$this->assertTrue($styleHelper->shouldApplyStyleOnEmptyCell($styleWithBackground->getId()));
|
||||||
|
$this->assertTrue($styleHelper->shouldApplyStyleOnEmptyCell($styleWithBorder->getId()));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
namespace Box\Spout\Writer\XLSX;
|
namespace Box\Spout\Writer\XLSX;
|
||||||
|
|
||||||
|
use Box\Spout\Common\Exception\SpoutException;
|
||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
use Box\Spout\TestUsingResource;
|
use Box\Spout\TestUsingResource;
|
||||||
use Box\Spout\Writer\WriterFactory;
|
use Box\Spout\Writer\WriterFactory;
|
||||||
@@ -21,7 +22,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
||||||
{
|
{
|
||||||
$fileName = 'file_that_wont_be_written.xlsx';
|
$fileName = 'file_that_wont_be_written.xlsx';
|
||||||
$this->createUnwritableFolderIfNeeded($fileName);
|
$this->createUnwritableFolderIfNeeded();
|
||||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||||
|
|
||||||
$writer = WriterFactory::create(Type::XLSX);
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
@@ -104,6 +105,39 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->writeToXLSXFile($dataRows, $fileName);
|
$this->writeToXLSXFile($dataRows, $fileName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowShouldCleanupAllFilesIfExceptionIsThrown()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_should_cleanup_all_files_if_exception_thrown.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
['wrong'],
|
||||||
|
[new \stdClass()],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->createGeneratedFolderIfNeeded($fileName);
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
|
||||||
|
$this->recreateTempFolder();
|
||||||
|
$tempFolderPath = $this->getTempFolderPath();
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||||
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
|
$writer->setTempFolder($tempFolderPath);
|
||||||
|
$writer->openToFile($resourcePath);
|
||||||
|
|
||||||
|
try {
|
||||||
|
$writer->addRows($dataRows);
|
||||||
|
$this->fail('Exception should have been thrown');
|
||||||
|
} catch (SpoutException $e) {
|
||||||
|
$this->assertFalse(file_exists($fileName), 'Output file should have been deleted');
|
||||||
|
|
||||||
|
$numFiles = iterator_count(new \FilesystemIterator($tempFolderPath, \FilesystemIterator::SKIP_DOTS));
|
||||||
|
$this->assertEquals(0, $numFiles, 'All temp files should have been deleted');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +266,48 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowShouldSupportAssociativeArrays()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_should_support_associative_arrays.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
['foo' => 'xlsx--11', 'bar' => 'xlsx--12'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToXLSXFile($dataRows, $fileName);
|
||||||
|
|
||||||
|
foreach ($dataRows as $dataRow) {
|
||||||
|
foreach ($dataRow as $cellValue) {
|
||||||
|
$this->assertInlineDataWasWrittenToSheet($fileName, 1, $cellValue);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowShouldNotWriteEmptyRows()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_should_not_write_empty_rows.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
[''],
|
||||||
|
['xlsx--21', 'xlsx--22'],
|
||||||
|
['key' => ''],
|
||||||
|
[''],
|
||||||
|
['xlsx--51', 'xlsx--52'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToXLSXFile($dataRows, $fileName);
|
||||||
|
|
||||||
|
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'row r="2"');
|
||||||
|
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'row r="5"');
|
||||||
|
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="1"');
|
||||||
|
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="3"');
|
||||||
|
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="4"');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -385,6 +461,21 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control's _x0015_ "character"');
|
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control's _x0015_ "character"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testGeneratedFileShouldHaveTheCorrectMimeType()
|
||||||
|
{
|
||||||
|
$fileName = 'test_mime_type.xlsx';
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
$dataRows = [['foo']];
|
||||||
|
|
||||||
|
$this->writeToXLSXFile($dataRows, $fileName);
|
||||||
|
|
||||||
|
$finfo = new \finfo(FILEINFO_MIME_TYPE);
|
||||||
|
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $finfo->file($resourcePath));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ namespace Box\Spout\Writer\XLSX;
|
|||||||
use Box\Spout\Common\Type;
|
use Box\Spout\Common\Type;
|
||||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||||
use Box\Spout\TestUsingResource;
|
use Box\Spout\TestUsingResource;
|
||||||
|
use Box\Spout\Writer\Style\Border;
|
||||||
|
use Box\Spout\Writer\Style\BorderBuilder;
|
||||||
use Box\Spout\Writer\Style\Color;
|
use Box\Spout\Writer\Style\Color;
|
||||||
use Box\Spout\Writer\Style\Style;
|
use Box\Spout\Writer\Style\Style;
|
||||||
use Box\Spout\Writer\Style\StyleBuilder;
|
use Box\Spout\Writer\Style\StyleBuilder;
|
||||||
@@ -124,7 +126,6 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
|
|
||||||
$fontElements = $fontsDomElement->getElementsByTagName('font');
|
$fontElements = $fontsDomElement->getElementsByTagName('font');
|
||||||
$this->assertEquals(3, $fontElements->length, 'There should be 3 associated "font" elements, including the default one.');
|
$this->assertEquals(3, $fontElements->length, 'There should be 3 associated "font" elements, including the default one.');
|
||||||
|
|
||||||
// First font should be the default one
|
// First font should be the default one
|
||||||
$defaultFontElement = $fontElements->item(0);
|
$defaultFontElement = $fontElements->item(0);
|
||||||
$this->assertChildrenNumEquals(3, $defaultFontElement, 'The default font should only have 3 properties.');
|
$this->assertChildrenNumEquals(3, $defaultFontElement, 'The default font should only have 3 properties.');
|
||||||
@@ -175,6 +176,53 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertEquals('0', $cellDomElements[2]->getAttribute('s'));
|
$this->assertEquals('0', $cellDomElements[2]->getAttribute('s'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddRowWithStyleShouldApplyStyleToEmptyCellsIfNeeded()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_row_with_style_should_apply_style_to_empty_cells_if_needed.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
['xlsx--11', '', 'xlsx--13'],
|
||||||
|
['xlsx--21', '', 'xlsx--23'],
|
||||||
|
['xlsx--31', '', 'xlsx--33'],
|
||||||
|
['xlsx--41', '', 'xlsx--43'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$styleWithFont = (new StyleBuilder())->setFontBold()->build();
|
||||||
|
$styleWithBackground = (new StyleBuilder())->setBackgroundColor(Color::BLUE)->build();
|
||||||
|
|
||||||
|
$border = (new BorderBuilder())->setBorderBottom(Color::GREEN)->build();
|
||||||
|
$styleWithBorder = (new StyleBuilder())->setBorder($border)->build();
|
||||||
|
|
||||||
|
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, [null, $styleWithFont, $styleWithBackground, $styleWithBorder]);
|
||||||
|
|
||||||
|
$cellDomElements = $this->getCellElementsFromSheetXmlFile($fileName);
|
||||||
|
|
||||||
|
// The first and second rows should not have a reference to the empty cell
|
||||||
|
// The other rows should have the reference because style should be applied to them
|
||||||
|
// So that's: 2 + 2 + 3 + 3 = 10 cells
|
||||||
|
$this->assertEquals(10, count($cellDomElements));
|
||||||
|
|
||||||
|
// First row has 2 styled cells
|
||||||
|
$this->assertEquals('0', $cellDomElements[0]->getAttribute('s'));
|
||||||
|
$this->assertEquals('0', $cellDomElements[1]->getAttribute('s'));
|
||||||
|
|
||||||
|
// Second row has 2 styled cells
|
||||||
|
$this->assertEquals('1', $cellDomElements[2]->getAttribute('s'));
|
||||||
|
$this->assertEquals('1', $cellDomElements[3]->getAttribute('s'));
|
||||||
|
|
||||||
|
// Third row has 3 styled cells
|
||||||
|
$this->assertEquals('2', $cellDomElements[4]->getAttribute('s'));
|
||||||
|
$this->assertEquals('2', $cellDomElements[5]->getAttribute('s'));
|
||||||
|
$this->assertEquals('2', $cellDomElements[6]->getAttribute('s'));
|
||||||
|
|
||||||
|
// Third row has 3 styled cells
|
||||||
|
$this->assertEquals('3', $cellDomElements[7]->getAttribute('s'));
|
||||||
|
$this->assertEquals('3', $cellDomElements[8]->getAttribute('s'));
|
||||||
|
$this->assertEquals('3', $cellDomElements[9]->getAttribute('s'));
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return void
|
* @return void
|
||||||
*/
|
*/
|
||||||
@@ -232,6 +280,239 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText');
|
$this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testAddBackgroundColor()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_background_color.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
["BgColor"],
|
||||||
|
];
|
||||||
|
$style = (new StyleBuilder())->setBackgroundColor(Color::WHITE)->build();
|
||||||
|
$this->writeToXLSXFile($dataRows, $fileName, $style);
|
||||||
|
$fillsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fills');
|
||||||
|
$this->assertEquals(3, $fillsDomElement->getAttribute('count'), 'There should be 3 fills, including the 2 default ones');
|
||||||
|
|
||||||
|
$fillsElements = $fillsDomElement->getElementsByTagName('fill');
|
||||||
|
|
||||||
|
$thirdFillElement = $fillsElements->item(2); // Zero based
|
||||||
|
$fgColor = $thirdFillElement->getElementsByTagName('fgColor')->item(0)->getAttribute('rgb');
|
||||||
|
|
||||||
|
$this->assertEquals(Color::WHITE, $fgColor, 'The foreground color should equal white');
|
||||||
|
|
||||||
|
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||||
|
$this->assertEquals(2, $styleXfsElements->getAttribute('count'), '2 cell xfs present - a default one and a custom one');
|
||||||
|
|
||||||
|
$customFillId = $styleXfsElements->lastChild->getAttribute('fillId');
|
||||||
|
$this->assertEquals(2, (int)$customFillId, 'The custom fill id should have the index 2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReuseBackgroundColorSharedDefinition()
|
||||||
|
{
|
||||||
|
$fileName = 'test_add_background_color_shared_definition.xlsx';
|
||||||
|
$dataRows = [
|
||||||
|
["row-bold-background-red"],
|
||||||
|
["row-background-red"],
|
||||||
|
];
|
||||||
|
|
||||||
|
$styles = [
|
||||||
|
(new StyleBuilder())->setBackgroundColor(Color::RED)->setFontBold()->build(),
|
||||||
|
(new StyleBuilder())->setBackgroundColor(Color::RED)->build()
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||||
|
|
||||||
|
$fillsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fills');
|
||||||
|
$this->assertEquals(
|
||||||
|
3,
|
||||||
|
$fillsDomElement->getAttribute('count'),
|
||||||
|
'There should be 3 fills, including the 2 default ones'
|
||||||
|
);
|
||||||
|
|
||||||
|
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||||
|
$this->assertEquals(
|
||||||
|
3,
|
||||||
|
$styleXfsElements->getAttribute('count'),
|
||||||
|
'3 cell xfs present - a default one and two custom ones'
|
||||||
|
);
|
||||||
|
|
||||||
|
$firstCustomId = $styleXfsElements->childNodes->item(1)->getAttribute('fillId');
|
||||||
|
$this->assertEquals(2, (int)$firstCustomId, 'The first custom fill id should have the index 2');
|
||||||
|
|
||||||
|
$secondCustomId = $styleXfsElements->childNodes->item(2)->getAttribute('fillId');
|
||||||
|
$this->assertEquals(2, (int)$secondCustomId, 'The second custom fill id should have the index 2');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testBorders()
|
||||||
|
{
|
||||||
|
$fileName = 'test_borders.xlsx';
|
||||||
|
|
||||||
|
$dataRows = [
|
||||||
|
['row-with-border-bottom-green-thick-solid'],
|
||||||
|
['row-without-border'],
|
||||||
|
['row-with-border-top-red-thin-dashed'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$borderBottomGreenThickSolid = (new BorderBuilder())
|
||||||
|
->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build();
|
||||||
|
|
||||||
|
|
||||||
|
$borderTopRedThinDashed = (new BorderBuilder())
|
||||||
|
->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||||
|
|
||||||
|
$styles = [
|
||||||
|
(new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(),
|
||||||
|
(new StyleBuilder())->build(),
|
||||||
|
(new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(),
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||||
|
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||||
|
$this->assertEquals(3, $borderElements->getAttribute('count'), '3 borders present');
|
||||||
|
|
||||||
|
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||||
|
$this->assertEquals(3, $styleXfsElements->getAttribute('count'), '3 cell xfs present');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testBordersCorrectOrder()
|
||||||
|
{
|
||||||
|
// Border should be Left, Right, Top, Bottom
|
||||||
|
$fileName = 'test_borders_correct_order.xlsx';
|
||||||
|
|
||||||
|
$dataRows = [
|
||||||
|
['I am a teapot'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$borders = (new BorderBuilder())
|
||||||
|
->setBorderRight()
|
||||||
|
->setBorderTop()
|
||||||
|
->setBorderLeft()
|
||||||
|
->setBorderBottom()
|
||||||
|
->build();
|
||||||
|
|
||||||
|
$style = (new StyleBuilder())->setBorder($borders)->build();
|
||||||
|
$this->writeToXLSXFile($dataRows, $fileName, $style);
|
||||||
|
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||||
|
|
||||||
|
$correctOrdering = [
|
||||||
|
'left', 'right', 'top', 'bottom'
|
||||||
|
];
|
||||||
|
|
||||||
|
/** @var $borderNode \DOMElement */
|
||||||
|
foreach ($borderElements->childNodes as $borderNode) {
|
||||||
|
$borderParts = $borderNode->childNodes;
|
||||||
|
$ordering = [];
|
||||||
|
|
||||||
|
/** @var $part \DOMText */
|
||||||
|
foreach ($borderParts as $part) {
|
||||||
|
if ($part instanceof \DOMElement) {
|
||||||
|
$ordering[] = $part->nodeName;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals($correctOrdering, $ordering, 'The border parts are in correct ordering');
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testSetDefaultRowStyle()
|
||||||
|
{
|
||||||
|
$fileName = 'test_set_default_row_style.xlsx';
|
||||||
|
$dataRows = [['xlsx--11']];
|
||||||
|
|
||||||
|
$defaultFontSize = 50;
|
||||||
|
$defaultStyle = (new StyleBuilder())->setFontSize($defaultFontSize)->build();
|
||||||
|
|
||||||
|
$this->writeToXLSXFileWithDefaultStyle($dataRows, $fileName, $defaultStyle);
|
||||||
|
|
||||||
|
$fontsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fonts');
|
||||||
|
$fontElements = $fontsDomElement->getElementsByTagName('font');
|
||||||
|
$this->assertEquals(1, $fontElements->length, 'There should only be the default font.');
|
||||||
|
|
||||||
|
$defaultFontElement = $fontElements->item(0);
|
||||||
|
$this->assertFirstChildHasAttributeEquals((string) $defaultFontSize, $defaultFontElement, 'sz', 'val');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return void
|
||||||
|
*/
|
||||||
|
public function testReUseBorders()
|
||||||
|
{
|
||||||
|
$fileName = 'test_reuse_borders.xlsx';
|
||||||
|
|
||||||
|
$borderLeft = (new BorderBuilder())->setBorderLeft()->build();
|
||||||
|
$borderLeftStyle = (new StyleBuilder())->setBorder($borderLeft)->build();
|
||||||
|
|
||||||
|
$borderRight = (new BorderBuilder())->setBorderRight(Color::RED, Border::WIDTH_THICK)->build();
|
||||||
|
$borderRightStyle = (new StyleBuilder())->setBorder($borderRight)->build();
|
||||||
|
|
||||||
|
$fontStyle = (new StyleBuilder())->setFontBold()->build();
|
||||||
|
$emptyStyle = (new StyleBuilder())->build();
|
||||||
|
|
||||||
|
$borderRightFontBoldStyle = $borderRightStyle->mergeWith($fontStyle);
|
||||||
|
|
||||||
|
$dataRows = [
|
||||||
|
['Border-Left'],
|
||||||
|
['Empty'],
|
||||||
|
['Font-Bold'],
|
||||||
|
['Border-Right'],
|
||||||
|
['Border-Right-Font-Bold'],
|
||||||
|
];
|
||||||
|
|
||||||
|
$styles = [
|
||||||
|
$borderLeftStyle,
|
||||||
|
$emptyStyle,
|
||||||
|
$fontStyle,
|
||||||
|
$borderRightStyle,
|
||||||
|
$borderRightFontBoldStyle
|
||||||
|
];
|
||||||
|
|
||||||
|
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||||
|
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||||
|
|
||||||
|
$this->assertEquals(3, $borderElements->getAttribute('count'), '3 borders in count attribute');
|
||||||
|
$this->assertEquals(3, $borderElements->childNodes->length, '3 border childnodes present');
|
||||||
|
|
||||||
|
/** @var \DOMElement $firstBorder */
|
||||||
|
$firstBorder = $borderElements->childNodes->item(1); // 0 = default border
|
||||||
|
$leftStyle = $firstBorder->getElementsByTagName('left')->item(0)->getAttribute('style');
|
||||||
|
$this->assertEquals('medium', $leftStyle, 'Style is medium');
|
||||||
|
|
||||||
|
/** @var \DOMElement $secondBorder */
|
||||||
|
$secondBorder = $borderElements->childNodes->item(2);
|
||||||
|
$rightStyle = $secondBorder->getElementsByTagName('right')->item(0)->getAttribute('style');
|
||||||
|
$this->assertEquals('thick', $rightStyle, 'Style is thick');
|
||||||
|
|
||||||
|
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||||
|
|
||||||
|
// A rather relaxed test
|
||||||
|
// Where a border is applied - the borderId attribute has to be greater than 0
|
||||||
|
$bordersApplied = 0;
|
||||||
|
/** @var \DOMElement $node */
|
||||||
|
foreach ($styleXfsElements->childNodes as $node) {
|
||||||
|
if ($node->getAttribute('applyBorder') == 1) {
|
||||||
|
$bordersApplied++;
|
||||||
|
$this->assertTrue((int)$node->getAttribute('borderId') > 0, 'BorderId is greater than 0');
|
||||||
|
} else {
|
||||||
|
$this->assertTrue((int)$node->getAttribute('borderId') === 0, 'BorderId is 0');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertEquals(3, $bordersApplied, 'Three borders have been applied');
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
@@ -254,6 +535,29 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
return $writer;
|
return $writer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param array $allRows
|
||||||
|
* @param string $fileName
|
||||||
|
* @param \Box\Spout\Writer\Style\Style|null $defaultStyle
|
||||||
|
* @return Writer
|
||||||
|
*/
|
||||||
|
private function writeToXLSXFileWithDefaultStyle($allRows, $fileName, $defaultStyle)
|
||||||
|
{
|
||||||
|
$this->createGeneratedFolderIfNeeded($fileName);
|
||||||
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
|
|
||||||
|
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||||
|
$writer = WriterFactory::create(Type::XLSX);
|
||||||
|
$writer->setShouldUseInlineStrings(true);
|
||||||
|
$writer->setDefaultRowStyle($defaultStyle);
|
||||||
|
|
||||||
|
$writer->openToFile($resourcePath);
|
||||||
|
$writer->addRows($allRows);
|
||||||
|
$writer->close();
|
||||||
|
|
||||||
|
return $writer;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param array $allRows
|
* @param array $allRows
|
||||||
* @param string $fileName
|
* @param string $fileName
|
||||||
@@ -293,13 +597,16 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
||||||
{
|
{
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToStylesXmlFile = $resourcePath . '#xl/styles.xml';
|
|
||||||
|
|
||||||
$xmlReader = new XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
$xmlReader->openFileInZip($resourcePath, 'xl/styles.xml');
|
||||||
$xmlReader->readUntilNodeFound($section);
|
$xmlReader->readUntilNodeFound($section);
|
||||||
|
|
||||||
return $xmlReader->expand();
|
$xmlSection = $xmlReader->expand();
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
|
||||||
|
return $xmlSection;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -311,17 +618,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
|||||||
$cellElements = [];
|
$cellElements = [];
|
||||||
|
|
||||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||||
$pathToStylesXmlFile = $resourcePath . '#xl/worksheets/sheet1.xml';
|
|
||||||
|
|
||||||
$xmlReader = new \XMLReader();
|
$xmlReader = new XMLReader();
|
||||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
$xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml');
|
||||||
|
|
||||||
while ($xmlReader->read()) {
|
while ($xmlReader->read()) {
|
||||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'c') {
|
if ($xmlReader->isPositionedOnStartingNode('c')) {
|
||||||
$cellElements[] = $xmlReader->expand();
|
$cellElements[] = $xmlReader->expand();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$xmlReader->close();
|
||||||
|
|
||||||
return $cellElements;
|
return $cellElements;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ require_once(dirname(__DIR__) . '/vendor/autoload.php');
|
|||||||
|
|
||||||
require_once(dirname(__DIR__) . '/tests/Spout/TestUsingResource.php');
|
require_once(dirname(__DIR__) . '/tests/Spout/TestUsingResource.php');
|
||||||
require_once(dirname(__DIR__) . '/tests/Spout/ReflectionHelper.php');
|
require_once(dirname(__DIR__) . '/tests/Spout/ReflectionHelper.php');
|
||||||
|
require_once(dirname(__DIR__) . '/tests/Spout/Reader/CSV/SpoutTestStream.php');
|
||||||
|
|
||||||
// Make sure a timezone is set to be able to work with dates
|
// Make sure a timezone is set to be able to work with dates
|
||||||
date_default_timezone_set('UTC');
|
date_default_timezone_set('UTC');
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
csv--11,csv--12,csv--13
csv--21,csv--22,csv--23
csv--31,csv--32,csv--33
|
||||||
|
@@ -1,2 +0,0 @@
|
|||||||
csv--11,csv--12,csv--13
|
|
||||||
csv--31,csv--32,csv--33
|
|
||||||
|
@@ -1,3 +0,0 @@
|
|||||||
csv--11,csv--12,csv--13
|
|
||||||
|
|
||||||
csv--31,csv--32,csv--33
|
|
||||||
|
@@ -0,0 +1,2 @@
|
|||||||
|
"This is,
|
||||||
|
a comma",csv--12
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
|
||||||
|
csv--21,csv--22,csv--23
|
||||||
|
|
||||||
|
csv--41,csv--42,csv--43
|
||||||
|
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
"A","B","C"
|
||||||
|
0,,
|
||||||
|
1,1,
|
||||||
|
@@ -0,0 +1,5 @@
|
|||||||
|
A
|
||||||
|
" A "
|
||||||
|
"
|
||||||
|
A
|
||||||
|
"
|
||||||
|
@@ -0,0 +1,3 @@
|
|||||||
|
"A","B","C"
|
||||||
|
1,2,3
|
||||||
|
0,0,0
|
||||||
|
Binary file not shown.
Binary file not shown.
Binary file not shown.
Loaded 100 of 124 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user