Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3681a3421a | ||
|
|
3bbff7ea7d | ||
|
|
fd6dd46b25 | ||
|
|
6d44cd26cc | ||
|
|
ee5dee61c7 | ||
|
|
40b4a57e6b | ||
|
|
5d4166196a | ||
|
|
99816b0b8e | ||
|
|
606103f7fc | ||
|
|
4e6db6a8a1 | ||
|
|
048105461c | ||
|
|
1eb01a3d2a | ||
|
|
9f80ece73f | ||
|
|
7f8b95b2f3 | ||
|
|
4a65466b61 | ||
|
|
742780613a | ||
|
|
3128f86769 | ||
|
|
d898f91917 | ||
|
|
33c9d2f2ed | ||
|
|
36d3596f83 | ||
|
|
1ce931a424 | ||
|
|
6f4ddb1569 | ||
|
|
521f799366 | ||
|
|
984c9c1f67 | ||
|
|
e255895cff | ||
|
|
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 | ||
|
|
d1c4d563c1 | ||
|
|
01cc8b3da0 | ||
|
|
45980195cd | ||
|
|
a1a1077677 | ||
|
|
16d0290a17 | ||
|
|
f8c39287ad | ||
|
|
118810de22 | ||
|
|
3720c9ea1c | ||
|
|
6124528161 | ||
|
|
818ec2488c | ||
|
|
aa7978146f | ||
|
|
d6e707c5fe | ||
|
|
03ae367fb3 | ||
|
|
ac48dce334 | ||
|
|
e147c580ca | ||
|
|
7615acf398 | ||
|
|
1d38bb00c5 | ||
|
|
79d947a6b3 | ||
|
|
dd3cc5bf47 | ||
|
|
0a5be41c53 | ||
|
|
d2ba6d884c | ||
|
|
e4154dfdc3 | ||
|
|
3f0016f753 | ||
|
|
bc009a3241 | ||
|
|
0f8e7a8f58 | ||
|
|
156fd29a44 | ||
|
|
ef171910b9 | ||
|
|
5949cb2442 | ||
|
|
108a92e259 | ||
|
|
9fb1427944 | ||
|
|
a8f1fba854 | ||
|
|
1812b4f996 | ||
|
|
b4ace972e7 | ||
|
|
c428daff5a | ||
|
|
5908443583 | ||
|
|
050672755d | ||
|
|
e1928eb68a | ||
|
|
9467b5a810 | ||
|
|
2699ffbcae | ||
|
|
3559bc8834 | ||
|
|
506d682e74 | ||
|
|
7efab5576d | ||
|
|
366f121eb0 | ||
|
|
f8d0ac2682 | ||
|
|
82377403ff | ||
|
|
e0c84f77b1 | ||
|
|
b78227c370 | ||
|
|
444308d42c | ||
|
|
353d4e86a5 | ||
|
|
f043f8d4d0 | ||
|
|
c8ddcf5441 | ||
|
|
4a346cef0c | ||
|
|
1c8934790d | ||
|
|
2a1925bc51 | ||
|
|
2183ff6738 | ||
|
|
380ee524a5 | ||
|
|
21263a0730 | ||
|
|
0104714cbd | ||
|
|
611a091290 | ||
|
|
40a86c4b6c | ||
|
|
8a3b895afc | ||
|
|
e3f7ecfa64 | ||
|
|
93d7aafe8b | ||
|
|
dc53b6aa20 | ||
|
|
30aa8970d5 | ||
|
|
2ec12dd16b | ||
|
|
fd84c6f1c8 | ||
|
|
5e1cfbfdbd | ||
|
|
d946f12951 | ||
|
|
03d1917080 | ||
|
|
1ba10ed2b0 | ||
|
|
be3932af18 | ||
|
|
37d87a8a27 | ||
|
|
86a4c3790a | ||
|
|
2345a80784 | ||
|
|
15aab7902a | ||
|
|
c672558a18 | ||
|
|
c52dd7bde8 | ||
|
|
ae3ee357ff | ||
|
|
322c3d0738 | ||
|
|
0adbf439f5 | ||
|
|
ad6b881685 | ||
|
|
d410224e2d | ||
|
|
79982a6107 | ||
|
|
6ae79b63b3 | ||
|
|
277b665dad | ||
|
|
b0c7c6ca84 | ||
|
|
494c506d56 | ||
|
|
334f7087da | ||
|
|
2dcb86aae9 | ||
|
|
3edb056286 | ||
|
|
edc0c22009 | ||
|
|
0319e578cb | ||
|
|
1e2452934c | ||
|
|
3e1793d852 | ||
|
|
c1b1bd0b76 | ||
|
|
cf239905d7 | ||
|
|
7c8f9293cc | ||
|
|
503ba97e9d | ||
|
|
b3df57d2e5 | ||
|
|
c6ebf115fc | ||
|
|
7d922e6776 | ||
|
|
d4cf853270 | ||
|
|
8bac924d48 | ||
|
|
591d86cf07 | ||
|
|
ba4a55292d |
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
|
||||
+3
-3
@@ -1,6 +1,6 @@
|
||||
/.idea
|
||||
*.iml
|
||||
|
||||
/tests/resources/generated
|
||||
/tests/coverage
|
||||
/vendor
|
||||
composer.lock
|
||||
/.idea
|
||||
*.iml
|
||||
@@ -0,0 +1,57 @@
|
||||
filter:
|
||||
excluded_paths: [vendor/*, tests/*]
|
||||
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 600 # Wait 10 minutes for results
|
||||
runs: 1 # Results are only coming from the PHP 7.1 job
|
||||
php_mess_detector: true
|
||||
php_code_sniffer:
|
||||
config:
|
||||
standard: PSR4
|
||||
filter:
|
||||
paths: ['src']
|
||||
sensiolabs_security_checker: true
|
||||
php_pdepend: true
|
||||
php_loc:
|
||||
enabled: true
|
||||
filter:
|
||||
paths: ['src']
|
||||
php_cpd: false # Must be disabled to use php_sim instead
|
||||
php_sim:
|
||||
enabled: true
|
||||
filter:
|
||||
paths: ['src']
|
||||
|
||||
build_failure_conditions:
|
||||
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
|
||||
- 'project.metric_change("scrutinizer.test_coverage", < -0.005)' # Code Coverage decreased by more than 0.5%
|
||||
- 'project.metric("scrutinizer.test_coverage", < 0.97)' # Code Coverage drops below 97%
|
||||
|
||||
checks:
|
||||
php:
|
||||
remove_extra_empty_lines: true
|
||||
remove_php_closing_tag: true
|
||||
remove_trailing_whitespace: true
|
||||
fix_use_statements:
|
||||
remove_unused: true
|
||||
preserve_multiple: false
|
||||
preserve_blanklines: true
|
||||
fix_php_opening_tag: true
|
||||
fix_linefeed: true
|
||||
fix_line_ending: true
|
||||
fix_identation_4spaces: true
|
||||
fix_doc_comments: true
|
||||
uppercase_constants: true
|
||||
use_self_instead_of_fqcn: true
|
||||
simplify_boolean_return: true
|
||||
return_doc_comments: true
|
||||
return_doc_comment_if_not_inferrable: true
|
||||
phpunit_assertions: true
|
||||
parameters_in_camelcaps: true
|
||||
parameter_doc_comments: true
|
||||
param_doc_comment_if_not_inferrable: true
|
||||
optional_parameters_at_the_end: true
|
||||
newline_at_end_of_file: true
|
||||
encourage_single_quotes: true
|
||||
|
||||
+20
-5
@@ -1,14 +1,29 @@
|
||||
language: php
|
||||
|
||||
dist: trusty
|
||||
|
||||
php:
|
||||
- 5.4
|
||||
- 5.5
|
||||
- 5.6
|
||||
- nightly
|
||||
- hhvm
|
||||
- 7.0
|
||||
- 7.1
|
||||
- hhvm-3.6
|
||||
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.composer/cache
|
||||
|
||||
install:
|
||||
- composer self-update
|
||||
- composer install --prefer-source
|
||||
- composer install --no-interaction --prefer-source
|
||||
|
||||
script: phpunit
|
||||
script:
|
||||
- mkdir -p build/logs
|
||||
- php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
|
||||
|
||||
after_script:
|
||||
- |
|
||||
if [[ "$TRAVIS_PHP_VERSION" == '7.1' ]]; then
|
||||
wget https://scrutinizer-ci.com/ocular.phar
|
||||
php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover
|
||||
fi
|
||||
@@ -2,28 +2,20 @@
|
||||
|
||||
[](https://packagist.org/packages/box/spout)
|
||||
[](http://opensource.box.com/badges)
|
||||
[](http://travis-ci.org/box/spout)
|
||||
[](https://packagist.org/packages/box/spout) [](https://packagist.org/packages/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://packagist.org/packages/box/spout)
|
||||
|
||||
Spout is a PHP library to read and write CSV and XLSX files, 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).
|
||||
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 3MB).
|
||||
|
||||
Join the community and come discuss about Spout: [](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
|
||||
|
||||
## Installation
|
||||
## Documentation
|
||||
|
||||
The Spout library can be installed directly from [Composer](https://getcomposer.org/).
|
||||
|
||||
Add "box/spout" as a dependency in your project's composer.json file:
|
||||
```json
|
||||
"require": {
|
||||
"box/spout": "*"
|
||||
}
|
||||
```
|
||||
|
||||
Then run the install command from Composer:
|
||||
```
|
||||
php composer.phar install
|
||||
```
|
||||
Full documentation can be found at [http://opensource.box.com/spout/](http://opensource.box.com/spout/).
|
||||
|
||||
|
||||
## Requirements
|
||||
@@ -31,239 +23,32 @@ php composer.phar install
|
||||
* PHP version 5.4.0 or higher
|
||||
* PHP extension `php_zip` enabled
|
||||
* PHP extension `php_xmlreader` enabled
|
||||
* PHP extension `php_simplexml` enabled
|
||||
|
||||
|
||||
## Basic usage
|
||||
|
||||
### Reader
|
||||
|
||||
#### How to read a CSV file?
|
||||
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->open($filePath);
|
||||
|
||||
while ($reader->hasNextRow()) {
|
||||
$row = $reader->nextRow();
|
||||
// do stuff
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
```
|
||||
|
||||
#### How to read a XLSX file?
|
||||
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->open($filePath);
|
||||
|
||||
while ($reader->hasNextSheet()) {
|
||||
$reader->nextSheet();
|
||||
|
||||
while ($reader->hasNextRow()) {
|
||||
$row = $reader->nextRow();
|
||||
// do stuff
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
```
|
||||
|
||||
If there are multiple sheets in the file, the reader will read through all of them sequentially.
|
||||
|
||||
### Writer
|
||||
|
||||
### How to create a CSV file?
|
||||
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
$writer->openToFile($filePath); // write data to a file or to a PHP stream
|
||||
$writer->addRow($singleRow); // add a row at a time
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
### How to create a XLSX file?
|
||||
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->openToBrowser($fileName); // stream data directly to the browser
|
||||
$writer->addRows($multipleRows); // add multiple rows at a time
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
For XLSX files, the number of rows per sheet is limited to 1,048,576 (see [Office OpenXML specs](http://office.microsoft.com/en-us/excel-help/excel-specifications-and-limits-HP010073849.aspx)). By default, once this limit is reached, the writer will automatically create a new sheet and continue writing data into it.
|
||||
|
||||
|
||||
## Advanced usage
|
||||
|
||||
### Configuring the CSV reader and writer
|
||||
|
||||
It is possible to configure the both the CSV reader and writer to specify the field separator as well as the field enclosure:
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->setFieldDelimiter('|');
|
||||
$reader->setFieldEnclosure('@');
|
||||
```
|
||||
|
||||
### Configuring the XLSX writer
|
||||
|
||||
#### Strings storage
|
||||
|
||||
XLSX files support different ways to store the string values:
|
||||
* Shared strings are meant to optimize file size by separating strings from the sheet representation and ignoring strings duplicates (if a string is used three times, only one string will be stored)
|
||||
* Inline strings are less optimized (as duplicate strings are all stored) but is faster to process
|
||||
|
||||
In order to keep the memory usage really low, Spout does not optimize strings when using shared strings. It is nevertheless possible to use this mode.
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setShouldUseInlineStrings(true); // default (and recommended) value
|
||||
$writer->setShouldUseInlineStrings(false); // will use shared strings
|
||||
```
|
||||
|
||||
#### New sheet creation
|
||||
|
||||
It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setShouldCreateNewSheetsAutomatically(true); // default value
|
||||
$writer->setShouldCreateNewSheetsAutomatically(false); // will stop writing new data when limit is reached
|
||||
```
|
||||
|
||||
#### Using custom temporary folder
|
||||
|
||||
Processing XLSX files require temporary files to be created. By default, Spout will use the system default temporary folder (as returned by sys_get_temp_dir()). It is possible to override this by explicitly setting it on the reader or writer:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setTempFolder($customTempFolderPath);
|
||||
```
|
||||
|
||||
### Playing with XLSX sheets
|
||||
|
||||
When creating a XLSX file, it is possible to control in which sheet the data will be written to.
|
||||
At any point, you can retrieve the current sheet and set the current sheet:
|
||||
```php
|
||||
$firstSheet = $writer->getCurrentSheet();
|
||||
$writer->addRow($rowForSheet1); // writes the row to the first sheet
|
||||
|
||||
$newSheet = $writer->addNewSheetAndMakeItCurrent();
|
||||
$writer->addRow($rowForSheet2); // writes the row to the new sheet
|
||||
|
||||
$writer->setCurrentSheet($firstSheet);
|
||||
$writer->addRow($anotherRowForSheet1); // append the row to the first sheet
|
||||
```
|
||||
|
||||
It is also possible to retrieve all the sheets currently created:
|
||||
```php
|
||||
$sheets = $writer->getSheets();
|
||||
```
|
||||
|
||||
If you rely on the sheet's name in your application, you can access it and customize it this way:
|
||||
```php
|
||||
// Accessing the sheet name when reading
|
||||
while ($reader->hasNextSheet()) {
|
||||
$sheet = $reader->nextSheet();
|
||||
$sheetName = $sheet->getName();
|
||||
}
|
||||
|
||||
// Accessing the sheet name when writing
|
||||
$sheet = $writer->getCurrentSheet();
|
||||
$sheetName = $sheet->getName();
|
||||
|
||||
// Customizing the sheet name when writing
|
||||
$sheet = $writer->getCurrentSheet();
|
||||
$sheetName = $sheet->setName('My custom name');
|
||||
```
|
||||
|
||||
### Fluent interface
|
||||
|
||||
Because fluent interfaces are great, you can use them with Spout:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setTempFolder($customTempFolderPath)
|
||||
->setShouldUseInlineStrings(true)
|
||||
->openToFile($filePath)
|
||||
->addRow($headerRow)
|
||||
->addRows($dataRows)
|
||||
->close();
|
||||
```
|
||||
|
||||
|
||||
## Running tests
|
||||
|
||||
On the `master` branch, only unit and functional tests are included. The performance requires very large files and have been excluded.
|
||||
If you just want to check that everything is working as expected, executing the tests of the master branch is enough.
|
||||
On the `master` branch, only unit and functional tests are included. The performance tests require very large files and have been excluded.
|
||||
If you just want to check that everything is working as expected, executing the tests of the `master` branch is enough.
|
||||
|
||||
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 --testuite no-perf-tests` - only runs the unit and functional tests
|
||||
* `phpunit --testuite perf-tests` - only runs the performance tests
|
||||
* `phpunit --exclude-group perf-tests` - only runs the unit and functional 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).
|
||||
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
#### How can Spout handle such large data sets and still use less than 10MB 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.
|
||||
|
||||
Same goes with reading. Only one row at a time is stored in memory. A special technique is used to handle shared strings in XLSX, storing them into several small temporary files that allows fast access.
|
||||
|
||||
#### How long does it take to generate a file with X rows?
|
||||
|
||||
Here are a few numbers regarding the performance of Spout:
|
||||
|
||||
| | 2,000 rows (6,000 cells) | 200,000 rows (600,000 cells) | 2,000,000 rows (6,000,000 cells) |
|
||||
| :------------------------------- | :----------------------: | :--------------------------: | :------------------------------: |
|
||||
| Read CSV | < 1 second | 4 seconds | 2-3 minutes |
|
||||
| Write CSV | < 1 second | 2 seconds | 2-3 minutes |
|
||||
| Read XLSX (using inline strings) | < 1 second | 35-40 seconds | 18-20 minutes |
|
||||
| Read XLSX (using shared strings) | 1 second | 1-2 minutes | 35-40 minutes |
|
||||
| Write XLSX | 1 second | 20-25 seconds | 8-10 minutes |
|
||||
|
||||
#### Does Spout support charts or formulas?
|
||||
|
||||
No. This is a compromise to keep memory usage low. Charts and formulas requires data to be kept in memory in order to be used.
|
||||
So the larger the file would be, the more memory would be consumed, preventing your code to scale well.
|
||||
> Performance tests status: [](https://travis-ci.org/box/spout)
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
Need to contact us directly? Email oss@box.com and be sure to include the name of this project in the subject.
|
||||
|
||||
You can ask questions, submit new features ideas or discuss about Spout in the chat room:<br>
|
||||
[](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
|
||||
## Copyright and License
|
||||
|
||||
Copyright 2015 Box, Inc. All rights reserved.
|
||||
Copyright 2017 Box, Inc. All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+14
-6
@@ -1,9 +1,8 @@
|
||||
{
|
||||
"name": "box/spout",
|
||||
"description": "PHP Library to read and write CSV and XLSX files, in a fast and scalable way",
|
||||
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
|
||||
"type": "library",
|
||||
"version": "1.0.9",
|
||||
"keywords": ["php","read","write","csv","xlsx","excel","spreadsheet","scale","memory","stream","ooxml"],
|
||||
"keywords": ["php","read","write","csv","xlsx","ods","odf","open","office","excel","spreadsheet","scale","memory","stream","ooxml"],
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://www.github.com/box/spout",
|
||||
"authors": [
|
||||
@@ -15,15 +14,24 @@
|
||||
"require": {
|
||||
"php": ">=5.4.0",
|
||||
"ext-zip": "*",
|
||||
"ext-xmlreader" : "*",
|
||||
"ext-simplexml": "*"
|
||||
"ext-xmlreader" : "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": ">=3.7"
|
||||
"phpunit/phpunit": "^4.8.0"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
|
||||
"ext-intl": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Box\\Spout\\": "src/Spout"
|
||||
}
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8.x-dev"
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
Generated
+980
@@ -0,0 +1,980 @@
|
||||
{
|
||||
"_readme": [
|
||||
"This file locks the dependencies of your project to a known state",
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#composer-lock-the-lock-file",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"hash": "8957b9da742e28d7250c02fca8f9a5a7",
|
||||
"content-hash": "973b8a4a1d8c520dd99fcd32cb5e022f",
|
||||
"packages": [],
|
||||
"packages-dev": [
|
||||
{
|
||||
"name": "doctrine/instantiator",
|
||||
"version": "1.0.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/doctrine/instantiator.git",
|
||||
"reference": "8e884e78f9f0eb1329e445619e04456e64d8051d"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/doctrine/instantiator/zipball/8e884e78f9f0eb1329e445619e04456e64d8051d",
|
||||
"reference": "8e884e78f9f0eb1329e445619e04456e64d8051d",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3,<8.0-DEV"
|
||||
},
|
||||
"require-dev": {
|
||||
"athletic/athletic": "~0.1.8",
|
||||
"ext-pdo": "*",
|
||||
"ext-phar": "*",
|
||||
"phpunit/phpunit": "~4.0",
|
||||
"squizlabs/php_codesniffer": "~2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Doctrine\\Instantiator\\": "src/Doctrine/Instantiator/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Marco Pivetta",
|
||||
"email": "ocramius@gmail.com",
|
||||
"homepage": "http://ocramius.github.com/"
|
||||
}
|
||||
],
|
||||
"description": "A small, lightweight utility to instantiate objects in PHP without invoking their constructors",
|
||||
"homepage": "https://github.com/doctrine/instantiator",
|
||||
"keywords": [
|
||||
"constructor",
|
||||
"instantiate"
|
||||
],
|
||||
"time": "2015-06-14 21:17:01"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-docblock",
|
||||
"version": "2.0.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpDocumentor/ReflectionDocBlock.git",
|
||||
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/d68dbdc53dc358a816f00b300704702b2eaff7b8",
|
||||
"reference": "d68dbdc53dc358a816f00b300704702b2eaff7b8",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.0"
|
||||
},
|
||||
"suggest": {
|
||||
"dflydev/markdown": "~1.0",
|
||||
"erusev/parsedown": "~1.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"phpDocumentor": [
|
||||
"src/"
|
||||
]
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Mike van Riel",
|
||||
"email": "mike.vanriel@naenius.com"
|
||||
}
|
||||
],
|
||||
"time": "2015-02-03 12:10:50"
|
||||
},
|
||||
{
|
||||
"name": "phpspec/prophecy",
|
||||
"version": "v1.6.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpspec/prophecy.git",
|
||||
"reference": "3c91bdf81797d725b14cb62906f9a4ce44235972"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpspec/prophecy/zipball/3c91bdf81797d725b14cb62906f9a4ce44235972",
|
||||
"reference": "3c91bdf81797d725b14cb62906f9a4ce44235972",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/instantiator": "^1.0.2",
|
||||
"php": "^5.3|^7.0",
|
||||
"phpdocumentor/reflection-docblock": "~2.0",
|
||||
"sebastian/comparator": "~1.1",
|
||||
"sebastian/recursion-context": "~1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpspec/phpspec": "~2.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.5.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-0": {
|
||||
"Prophecy\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Konstantin Kudryashov",
|
||||
"email": "ever.zet@gmail.com",
|
||||
"homepage": "http://everzet.com"
|
||||
},
|
||||
{
|
||||
"name": "Marcello Duarte",
|
||||
"email": "marcello.duarte@gmail.com"
|
||||
}
|
||||
],
|
||||
"description": "Highly opinionated mocking framework for PHP 5.3+",
|
||||
"homepage": "https://github.com/phpspec/prophecy",
|
||||
"keywords": [
|
||||
"Double",
|
||||
"Dummy",
|
||||
"fake",
|
||||
"mock",
|
||||
"spy",
|
||||
"stub"
|
||||
],
|
||||
"time": "2016-02-15 07:46:21"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "2.2.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
||||
"reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/eabf68b476ac7d0f73793aada060f1c1a9bf8979",
|
||||
"reference": "eabf68b476ac7d0f73793aada060f1c1a9bf8979",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3",
|
||||
"phpunit/php-file-iterator": "~1.3",
|
||||
"phpunit/php-text-template": "~1.2",
|
||||
"phpunit/php-token-stream": "~1.3",
|
||||
"sebastian/environment": "^1.3.2",
|
||||
"sebastian/version": "~1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"ext-xdebug": ">=2.1.4",
|
||||
"phpunit/phpunit": "~4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-dom": "*",
|
||||
"ext-xdebug": ">=2.2.1",
|
||||
"ext-xmlwriter": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sb@sebastian-bergmann.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "Library that provides collection, processing, and rendering functionality for PHP code coverage information.",
|
||||
"homepage": "https://github.com/sebastianbergmann/php-code-coverage",
|
||||
"keywords": [
|
||||
"coverage",
|
||||
"testing",
|
||||
"xunit"
|
||||
],
|
||||
"time": "2015-10-06 15:47:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-file-iterator",
|
||||
"version": "1.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-file-iterator.git",
|
||||
"reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-file-iterator/zipball/6150bf2c35d3fc379e50c7602b75caceaa39dbf0",
|
||||
"reference": "6150bf2c35d3fc379e50c7602b75caceaa39dbf0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.4.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sb@sebastian-bergmann.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "FilterIterator implementation that filters files based on a list of suffixes.",
|
||||
"homepage": "https://github.com/sebastianbergmann/php-file-iterator/",
|
||||
"keywords": [
|
||||
"filesystem",
|
||||
"iterator"
|
||||
],
|
||||
"time": "2015-06-21 13:08:43"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-text-template",
|
||||
"version": "1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-text-template.git",
|
||||
"reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-text-template/zipball/31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
|
||||
"reference": "31f8b717e51d9a2afca6c9f046f5d69fc27c8686",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "Simple template engine.",
|
||||
"homepage": "https://github.com/sebastianbergmann/php-text-template/",
|
||||
"keywords": [
|
||||
"template"
|
||||
],
|
||||
"time": "2015-06-21 13:50:34"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-timer",
|
||||
"version": "1.0.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-timer.git",
|
||||
"reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-timer/zipball/38e9124049cf1a164f1e4537caf19c99bf1eb260",
|
||||
"reference": "38e9124049cf1a164f1e4537caf19c99bf1eb260",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4|~5"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sb@sebastian-bergmann.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "Utility class for timing",
|
||||
"homepage": "https://github.com/sebastianbergmann/php-timer/",
|
||||
"keywords": [
|
||||
"timer"
|
||||
],
|
||||
"time": "2016-05-12 18:03:57"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-token-stream",
|
||||
"version": "1.4.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-token-stream.git",
|
||||
"reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-token-stream/zipball/3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da",
|
||||
"reference": "3144ae21711fb6cac0b1ab4cbe63b75ce3d4e8da",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-tokenizer": "*",
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.2"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
}
|
||||
],
|
||||
"description": "Wrapper around PHP's tokenizer extension.",
|
||||
"homepage": "https://github.com/sebastianbergmann/php-token-stream/",
|
||||
"keywords": [
|
||||
"tokenizer"
|
||||
],
|
||||
"time": "2015-09-15 10:49:45"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "4.8.26",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "fc1d8cd5b5de11625979125c5639347896ac2c74"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/fc1d8cd5b5de11625979125c5639347896ac2c74",
|
||||
"reference": "fc1d8cd5b5de11625979125c5639347896ac2c74",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-dom": "*",
|
||||
"ext-json": "*",
|
||||
"ext-pcre": "*",
|
||||
"ext-reflection": "*",
|
||||
"ext-spl": "*",
|
||||
"php": ">=5.3.3",
|
||||
"phpspec/prophecy": "^1.3.1",
|
||||
"phpunit/php-code-coverage": "~2.1",
|
||||
"phpunit/php-file-iterator": "~1.4",
|
||||
"phpunit/php-text-template": "~1.2",
|
||||
"phpunit/php-timer": "^1.0.6",
|
||||
"phpunit/phpunit-mock-objects": "~2.3",
|
||||
"sebastian/comparator": "~1.1",
|
||||
"sebastian/diff": "~1.2",
|
||||
"sebastian/environment": "~1.3",
|
||||
"sebastian/exporter": "~1.2",
|
||||
"sebastian/global-state": "~1.0",
|
||||
"sebastian/version": "~1.0",
|
||||
"symfony/yaml": "~2.1|~3.0"
|
||||
},
|
||||
"suggest": {
|
||||
"phpunit/php-invoker": "~1.1"
|
||||
},
|
||||
"bin": [
|
||||
"phpunit"
|
||||
],
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.8.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "The PHP Unit Testing framework.",
|
||||
"homepage": "https://phpunit.de/",
|
||||
"keywords": [
|
||||
"phpunit",
|
||||
"testing",
|
||||
"xunit"
|
||||
],
|
||||
"time": "2016-05-17 03:09:28"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit-mock-objects",
|
||||
"version": "2.3.8",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit-mock-objects.git",
|
||||
"reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit-mock-objects/zipball/ac8e7a3db35738d56ee9a76e78a4e03d97628983",
|
||||
"reference": "ac8e7a3db35738d56ee9a76e78a4e03d97628983",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"doctrine/instantiator": "^1.0.2",
|
||||
"php": ">=5.3.3",
|
||||
"phpunit/php-text-template": "~1.2",
|
||||
"sebastian/exporter": "~1.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.4"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-soap": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sb@sebastian-bergmann.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "Mock Object library for PHPUnit",
|
||||
"homepage": "https://github.com/sebastianbergmann/phpunit-mock-objects/",
|
||||
"keywords": [
|
||||
"mock",
|
||||
"xunit"
|
||||
],
|
||||
"time": "2015-10-02 06:51:40"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/comparator",
|
||||
"version": "1.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/comparator.git",
|
||||
"reference": "937efb279bd37a375bcadf584dec0726f84dbf22"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/comparator/zipball/937efb279bd37a375bcadf584dec0726f84dbf22",
|
||||
"reference": "937efb279bd37a375bcadf584dec0726f84dbf22",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3",
|
||||
"sebastian/diff": "~1.2",
|
||||
"sebastian/exporter": "~1.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeff Welch",
|
||||
"email": "whatthejeff@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Volker Dusch",
|
||||
"email": "github@wallbash.com"
|
||||
},
|
||||
{
|
||||
"name": "Bernhard Schussek",
|
||||
"email": "bschussek@2bepublished.at"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
}
|
||||
],
|
||||
"description": "Provides the functionality to compare PHP values for equality",
|
||||
"homepage": "http://www.github.com/sebastianbergmann/comparator",
|
||||
"keywords": [
|
||||
"comparator",
|
||||
"compare",
|
||||
"equality"
|
||||
],
|
||||
"time": "2015-07-26 15:48:44"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/diff",
|
||||
"version": "1.4.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/diff.git",
|
||||
"reference": "13edfd8706462032c2f52b4b862974dd46b71c9e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/diff/zipball/13edfd8706462032c2f52b4b862974dd46b71c9e",
|
||||
"reference": "13edfd8706462032c2f52b4b862974dd46b71c9e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.8"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.4-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Kore Nordmann",
|
||||
"email": "mail@kore-nordmann.de"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
}
|
||||
],
|
||||
"description": "Diff implementation",
|
||||
"homepage": "https://github.com/sebastianbergmann/diff",
|
||||
"keywords": [
|
||||
"diff"
|
||||
],
|
||||
"time": "2015-12-08 07:14:41"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/environment",
|
||||
"version": "1.3.7",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/environment.git",
|
||||
"reference": "4e8f0da10ac5802913afc151413bc8c53b6c2716"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/environment/zipball/4e8f0da10ac5802913afc151413bc8c53b6c2716",
|
||||
"reference": "4e8f0da10ac5802913afc151413bc8c53b6c2716",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
}
|
||||
],
|
||||
"description": "Provides functionality to handle HHVM/PHP environments",
|
||||
"homepage": "http://www.github.com/sebastianbergmann/environment",
|
||||
"keywords": [
|
||||
"Xdebug",
|
||||
"environment",
|
||||
"hhvm"
|
||||
],
|
||||
"time": "2016-05-17 03:18:57"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/exporter",
|
||||
"version": "1.2.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/exporter.git",
|
||||
"reference": "7ae5513327cb536431847bcc0c10edba2701064e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/exporter/zipball/7ae5513327cb536431847bcc0c10edba2701064e",
|
||||
"reference": "7ae5513327cb536431847bcc0c10edba2701064e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3",
|
||||
"sebastian/recursion-context": "~1.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.2.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeff Welch",
|
||||
"email": "whatthejeff@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Volker Dusch",
|
||||
"email": "github@wallbash.com"
|
||||
},
|
||||
{
|
||||
"name": "Bernhard Schussek",
|
||||
"email": "bschussek@2bepublished.at"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
},
|
||||
{
|
||||
"name": "Adam Harvey",
|
||||
"email": "aharvey@php.net"
|
||||
}
|
||||
],
|
||||
"description": "Provides the functionality to export PHP variables for visualization",
|
||||
"homepage": "http://www.github.com/sebastianbergmann/exporter",
|
||||
"keywords": [
|
||||
"export",
|
||||
"exporter"
|
||||
],
|
||||
"time": "2015-06-21 07:55:53"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/global-state",
|
||||
"version": "1.1.1",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/global-state.git",
|
||||
"reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/global-state/zipball/bc37d50fea7d017d3d340f230811c9f1d7280af4",
|
||||
"reference": "bc37d50fea7d017d3d340f230811c9f1d7280af4",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-uopz": "*"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
}
|
||||
],
|
||||
"description": "Snapshotting of global state",
|
||||
"homepage": "http://www.github.com/sebastianbergmann/global-state",
|
||||
"keywords": [
|
||||
"global state"
|
||||
],
|
||||
"time": "2015-10-12 03:26:01"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/recursion-context",
|
||||
"version": "1.0.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/recursion-context.git",
|
||||
"reference": "913401df809e99e4f47b27cdd781f4a258d58791"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/recursion-context/zipball/913401df809e99e4f47b27cdd781f4a258d58791",
|
||||
"reference": "913401df809e99e4f47b27cdd781f4a258d58791",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.3"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "~4.4"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.0.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Jeff Welch",
|
||||
"email": "whatthejeff@gmail.com"
|
||||
},
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de"
|
||||
},
|
||||
{
|
||||
"name": "Adam Harvey",
|
||||
"email": "aharvey@php.net"
|
||||
}
|
||||
],
|
||||
"description": "Provides functionality to recursively process PHP variables",
|
||||
"homepage": "http://www.github.com/sebastianbergmann/recursion-context",
|
||||
"time": "2015-11-11 19:50:13"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/version",
|
||||
"version": "1.0.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/version.git",
|
||||
"reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/version/zipball/58b3a85e7999757d6ad81c787a1fbf5ff6c628c6",
|
||||
"reference": "58b3a85e7999757d6ad81c787a1fbf5ff6c628c6",
|
||||
"shasum": ""
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Sebastian Bergmann",
|
||||
"email": "sebastian@phpunit.de",
|
||||
"role": "lead"
|
||||
}
|
||||
],
|
||||
"description": "Library that helps with managing the version number of Git-hosted PHP projects",
|
||||
"homepage": "https://github.com/sebastianbergmann/version",
|
||||
"time": "2015-06-21 13:59:46"
|
||||
},
|
||||
{
|
||||
"name": "symfony/yaml",
|
||||
"version": "v2.8.6",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/symfony/yaml.git",
|
||||
"reference": "e4fbcc65f90909c999ac3b4dfa699ee6563a9940"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/symfony/yaml/zipball/e4fbcc65f90909c999ac3b4dfa699ee6563a9940",
|
||||
"reference": "e4fbcc65f90909c999ac3b4dfa699ee6563a9940",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=5.3.9"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.8-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Symfony\\Component\\Yaml\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com"
|
||||
},
|
||||
{
|
||||
"name": "Symfony Community",
|
||||
"homepage": "https://symfony.com/contributors"
|
||||
}
|
||||
],
|
||||
"description": "Symfony Yaml Component",
|
||||
"homepage": "https://symfony.com",
|
||||
"time": "2016-03-29 19:00:15"
|
||||
}
|
||||
],
|
||||
"aliases": [],
|
||||
"minimum-stability": "stable",
|
||||
"stability-flags": [],
|
||||
"prefer-stable": false,
|
||||
"prefer-lowest": false,
|
||||
"platform": {
|
||||
"php": ">=5.4.0",
|
||||
"ext-zip": "*",
|
||||
"ext-xmlreader": "*",
|
||||
"ext-simplexml": "*"
|
||||
},
|
||||
"platform-dev": []
|
||||
}
|
||||
+3
-5
@@ -5,7 +5,6 @@
|
||||
colors="true"
|
||||
convertErrorsToExceptions="false"
|
||||
convertWarningsToExceptions="false"
|
||||
strict="false"
|
||||
verbose="false">
|
||||
|
||||
<testsuites>
|
||||
@@ -14,13 +13,12 @@
|
||||
</testsuite>
|
||||
</testsuites>
|
||||
|
||||
<logging>
|
||||
<log type="coverage-html" target="tests/coverage/"/>
|
||||
</logging>
|
||||
|
||||
<filter>
|
||||
<whitelist>
|
||||
<directory suffix=".php">src/</directory>
|
||||
<exclude>
|
||||
<directory>src/Spout/Autoloader</directory>
|
||||
</exclude>
|
||||
</whitelist>
|
||||
</filter>
|
||||
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Autoloader;
|
||||
|
||||
/**
|
||||
* Class Psr4Autoloader
|
||||
* @see https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader-examples.md#class-example
|
||||
*
|
||||
* @package Box\Spout\Autoloader
|
||||
*/
|
||||
class Psr4Autoloader
|
||||
{
|
||||
/**
|
||||
* An associative array where the key is a namespace prefix and the value
|
||||
* is an array of base directories for classes in that namespace.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $prefixes = array();
|
||||
|
||||
/**
|
||||
* Register loader with SPL autoloader stack.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function register()
|
||||
{
|
||||
spl_autoload_register(array($this, 'loadClass'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a base directory for a namespace prefix.
|
||||
*
|
||||
* @param string $prefix The namespace prefix.
|
||||
* @param string $baseDir A base directory for class files in the
|
||||
* namespace.
|
||||
* @param bool $prepend If true, prepend the base directory to the stack
|
||||
* instead of appending it; this causes it to be searched first rather
|
||||
* than last.
|
||||
* @return void
|
||||
*/
|
||||
public function addNamespace($prefix, $baseDir, $prepend = false)
|
||||
{
|
||||
// normalize namespace prefix
|
||||
$prefix = trim($prefix, '\\') . '\\';
|
||||
|
||||
// normalize the base directory with a trailing separator
|
||||
$baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . '/';
|
||||
|
||||
// initialize the namespace prefix array
|
||||
if (isset($this->prefixes[$prefix]) === false) {
|
||||
$this->prefixes[$prefix] = array();
|
||||
}
|
||||
|
||||
// retain the base directory for the namespace prefix
|
||||
if ($prepend) {
|
||||
array_unshift($this->prefixes[$prefix], $baseDir);
|
||||
} else {
|
||||
array_push($this->prefixes[$prefix], $baseDir);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads the class file for a given class name.
|
||||
*
|
||||
* @param string $class The fully-qualified class name.
|
||||
* @return mixed The mapped file name on success, or boolean false on
|
||||
* failure.
|
||||
*/
|
||||
public function loadClass($class)
|
||||
{
|
||||
// the current namespace prefix
|
||||
$prefix = $class;
|
||||
|
||||
// work backwards through the namespace names of the fully-qualified
|
||||
// class name to find a mapped file name
|
||||
while (false !== $pos = strrpos($prefix, '\\')) {
|
||||
|
||||
// retain the trailing namespace separator in the prefix
|
||||
$prefix = substr($class, 0, $pos + 1);
|
||||
|
||||
// the rest is the relative class name
|
||||
$relativeClass = substr($class, $pos + 1);
|
||||
|
||||
// try to load a mapped file for the prefix and relative class
|
||||
$mappedFile = $this->loadMappedFile($prefix, $relativeClass);
|
||||
if ($mappedFile !== false) {
|
||||
return $mappedFile;
|
||||
}
|
||||
|
||||
// remove the trailing namespace separator for the next iteration
|
||||
// of strrpos()
|
||||
$prefix = rtrim($prefix, '\\');
|
||||
}
|
||||
|
||||
// never found a mapped file
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the mapped file for a namespace prefix and relative class.
|
||||
*
|
||||
* @param string $prefix The namespace prefix.
|
||||
* @param string $relativeClass The relative class name.
|
||||
* @return mixed Boolean false if no mapped file can be loaded, or the
|
||||
* name of the mapped file that was loaded.
|
||||
*/
|
||||
protected function loadMappedFile($prefix, $relativeClass)
|
||||
{
|
||||
// are there any base directories for this namespace prefix?
|
||||
if (isset($this->prefixes[$prefix]) === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// look through base directories for this namespace prefix
|
||||
foreach ($this->prefixes[$prefix] as $baseDir) {
|
||||
|
||||
// replace the namespace prefix with the base directory,
|
||||
// replace namespace separators with directory separators
|
||||
// in the relative class name, append with .php
|
||||
$file = $baseDir
|
||||
. str_replace('\\', '/', $relativeClass)
|
||||
. '.php';
|
||||
|
||||
// if the mapped file exists, require it
|
||||
if ($this->requireFile($file)) {
|
||||
// yes, we're done
|
||||
return $file;
|
||||
}
|
||||
}
|
||||
|
||||
// never found it
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* If a file exists, require it from the file system.
|
||||
*
|
||||
* @param string $file The file to require.
|
||||
* @return bool True if the file exists, false if not.
|
||||
*/
|
||||
protected function requireFile($file)
|
||||
{
|
||||
if (file_exists($file)) {
|
||||
require $file;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Autoloader;
|
||||
|
||||
require_once 'Psr4Autoloader.php';
|
||||
|
||||
/**
|
||||
* @var string $srcBaseDirectory
|
||||
* Full path to "src/Spout" which is what we want "Box\Spout" to map to.
|
||||
*/
|
||||
$srcBaseDirectory = dirname(dirname(__FILE__));
|
||||
|
||||
$loader = new Psr4Autoloader();
|
||||
$loader->register();
|
||||
$loader->addNamespace('Box\Spout', $srcBaseDirectory);
|
||||
@@ -0,0 +1,66 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Escaper;
|
||||
|
||||
use Box\Spout\Common\Singleton;
|
||||
|
||||
/**
|
||||
* Class ODS
|
||||
* Provides functions to escape and unescape data for ODS files
|
||||
*
|
||||
* @package Box\Spout\Common\Escaper
|
||||
*/
|
||||
class ODS implements EscaperInterface
|
||||
{
|
||||
use Singleton;
|
||||
|
||||
/**
|
||||
* Escapes the given string to make it compatible with XLSX
|
||||
*
|
||||
* @param string $string The string to escape
|
||||
* @return string The escaped string
|
||||
*/
|
||||
public function escape($string)
|
||||
{
|
||||
if (defined('ENT_DISALLOWED')) {
|
||||
// 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
|
||||
// Otherwise control characters like a vertical tab "\v" will make the XML document unreadable by the XML processor
|
||||
// @link https://github.com/box/spout/issues/329
|
||||
$replacedString = htmlspecialchars($string, ENT_NOQUOTES | ENT_DISALLOWED);
|
||||
} else {
|
||||
// We are on hhvm or any other engine that does not support ENT_DISALLOWED.
|
||||
//
|
||||
// @NOTE: Using ENT_NOQUOTES as only XML entities ('<', '>', '&') need to be encoded.
|
||||
// Single and double quotes can be left as is.
|
||||
$escapedString = htmlspecialchars($string, ENT_NOQUOTES);
|
||||
|
||||
// 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".
|
||||
$regexPattern = '[\x00-\x08' .
|
||||
// skipping "\t" (0x9) and "\n" (0xA)
|
||||
'\x0B-\x0C' .
|
||||
// skipping "\r" (0xD)
|
||||
'\x0E-\x1F]';
|
||||
$replacedString = preg_replace("/$regexPattern/", '�', $escapedString);
|
||||
}
|
||||
|
||||
return $replacedString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes the given string to make it compatible with XLSX
|
||||
*
|
||||
* @param string $string The string to unescape
|
||||
* @return string The unescaped string
|
||||
*/
|
||||
public function unescape($string)
|
||||
{
|
||||
// ==============
|
||||
// = WARNING =
|
||||
// ==============
|
||||
// It is assumed that the given string has already had its XML entities decoded.
|
||||
// This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
|
||||
// Therefore there is no need to call "htmlspecialchars_decode()".
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Box\Spout\Common\Escaper;
|
||||
|
||||
use Box\Spout\Common\Singleton;
|
||||
|
||||
/**
|
||||
* Class XLSX
|
||||
* Provides functions to escape and unescape data for XLSX files
|
||||
@@ -10,12 +12,25 @@ namespace Box\Spout\Common\Escaper;
|
||||
*/
|
||||
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;
|
||||
|
||||
public function __construct()
|
||||
/** @var string[] Map containing control characters to be escaped (value) and their escaped value (key) */
|
||||
protected $controlCharactersEscapingReverseMap;
|
||||
|
||||
/**
|
||||
* Initializes the singleton instance
|
||||
*/
|
||||
protected function init()
|
||||
{
|
||||
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
|
||||
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
|
||||
$this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -27,7 +42,9 @@ class XLSX implements EscaperInterface
|
||||
public function escape($string)
|
||||
{
|
||||
$escapedString = $this->escapeControlCharacters($string);
|
||||
$escapedString = htmlspecialchars($escapedString, ENT_QUOTES);
|
||||
// @NOTE: Using ENT_NOQUOTES as only XML entities ('<', '>', '&') need to be encoded.
|
||||
// Single and double quotes can be left as is.
|
||||
$escapedString = htmlspecialchars($escapedString, ENT_NOQUOTES);
|
||||
|
||||
return $escapedString;
|
||||
}
|
||||
@@ -40,12 +57,31 @@ class XLSX implements EscaperInterface
|
||||
*/
|
||||
public function unescape($string)
|
||||
{
|
||||
$unescapedString = htmlspecialchars_decode($string, ENT_QUOTES);
|
||||
$unescapedString = $this->unescapeControlCharacters($unescapedString);
|
||||
// ==============
|
||||
// = WARNING =
|
||||
// ==============
|
||||
// It is assumed that the given string has already had its XML entities decoded.
|
||||
// This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
|
||||
// Therefore there is no need to call "htmlspecialchars_decode()".
|
||||
$unescapedString = $this->unescapeControlCharacters($string);
|
||||
|
||||
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
|
||||
* mapped to their escaped values.
|
||||
@@ -59,14 +95,14 @@ class XLSX implements EscaperInterface
|
||||
protected function getControlCharactersEscapingMap()
|
||||
{
|
||||
$controlCharactersEscapingMap = [];
|
||||
$whitelistedControlCharacters = ["\t", "\r", "\n"];
|
||||
|
||||
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
||||
for ($charValue = 0x0; $charValue <= 0x1F; $charValue++) {
|
||||
if (!in_array(chr($charValue), $whitelistedControlCharacters)) {
|
||||
for ($charValue = 0x00; $charValue <= 0x1F; $charValue++) {
|
||||
$character = chr($charValue);
|
||||
if (preg_match("/{$this->escapableControlCharactersPattern}/", $character)) {
|
||||
$charHexValue = dechex($charValue);
|
||||
$escapedChar = '_x' . sprintf('%04s' , strtoupper($charHexValue)) . '_';
|
||||
$controlCharactersEscapingMap[$escapedChar] = chr($charValue);
|
||||
$controlCharactersEscapingMap[$escapedChar] = $character;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +125,15 @@ class XLSX implements EscaperInterface
|
||||
protected function escapeControlCharacters($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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -119,6 +163,7 @@ class XLSX implements EscaperInterface
|
||||
protected function unescapeControlCharacters($string)
|
||||
{
|
||||
$unescapedString = $string;
|
||||
|
||||
foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) {
|
||||
// only unescape characters that don't contain the escaped escape character for now
|
||||
$unescapedString = preg_replace("/(?<!_x005F)($escapedCharValue)/", $charValue, $unescapedString);
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Exception;
|
||||
|
||||
/**
|
||||
* Class BadUsageException
|
||||
*
|
||||
* @package Box\Spout\Common\Exception
|
||||
*/
|
||||
class BadUsageException extends SpoutException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Exception;
|
||||
|
||||
/**
|
||||
* Class EncodingConversionException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Common\Exception
|
||||
*/
|
||||
class EncodingConversionException extends SpoutException
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
|
||||
/**
|
||||
* Class IOException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Common\Exception
|
||||
*/
|
||||
class IOException extends SpoutException
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
|
||||
/**
|
||||
* Class InvalidArgumentException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Common\Exception
|
||||
*/
|
||||
class InvalidArgumentException extends SpoutException
|
||||
|
||||
@@ -6,6 +6,7 @@ namespace Box\Spout\Common\Exception;
|
||||
* Class SpoutException
|
||||
*
|
||||
* @package Box\Spout\Common\Exception
|
||||
* @abstract
|
||||
*/
|
||||
abstract class SpoutException extends \Exception
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
|
||||
/**
|
||||
* Class UnsupportedTypeException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Common\Exception
|
||||
*/
|
||||
class UnsupportedTypeException extends SpoutException
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Helper;
|
||||
|
||||
use Box\Spout\Common\Exception\EncodingConversionException;
|
||||
|
||||
/**
|
||||
* Class EncodingHelper
|
||||
* This class provides helper functions to work with encodings.
|
||||
*
|
||||
* @package Box\Spout\Common\Helper
|
||||
*/
|
||||
class EncodingHelper
|
||||
{
|
||||
/** Definition of the encodings that can have a BOM */
|
||||
const ENCODING_UTF8 = 'UTF-8';
|
||||
const ENCODING_UTF16_LE = 'UTF-16LE';
|
||||
const ENCODING_UTF16_BE = 'UTF-16BE';
|
||||
const ENCODING_UTF32_LE = 'UTF-32LE';
|
||||
const ENCODING_UTF32_BE = 'UTF-32BE';
|
||||
|
||||
/** Definition of the BOMs for the different encodings */
|
||||
const BOM_UTF8 = "\xEF\xBB\xBF";
|
||||
const BOM_UTF16_LE = "\xFF\xFE";
|
||||
const BOM_UTF16_BE = "\xFE\xFF";
|
||||
const BOM_UTF32_LE = "\xFF\xFE\x00\x00";
|
||||
const BOM_UTF32_BE = "\x00\x00\xFE\xFF";
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/** @var array Map representing the encodings supporting BOMs (key) and their associated BOM (value) */
|
||||
protected $supportedEncodingsWithBom;
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($globalFunctionsHelper)
|
||||
{
|
||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||
|
||||
$this->supportedEncodingsWithBom = [
|
||||
self::ENCODING_UTF8 => self::BOM_UTF8,
|
||||
self::ENCODING_UTF16_LE => self::BOM_UTF16_LE,
|
||||
self::ENCODING_UTF16_BE => self::BOM_UTF16_BE,
|
||||
self::ENCODING_UTF32_LE => self::BOM_UTF32_LE,
|
||||
self::ENCODING_UTF32_BE => self::BOM_UTF32_BE,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the number of bytes to use as offset in order to skip the BOM.
|
||||
*
|
||||
* @param resource $filePointer Pointer to the file to check
|
||||
* @param string $encoding Encoding of the file to check
|
||||
* @return int Bytes offset to apply to skip the BOM (0 means no BOM)
|
||||
*/
|
||||
public function getBytesOffsetToSkipBOM($filePointer, $encoding)
|
||||
{
|
||||
$byteOffsetToSkipBom = 0;
|
||||
|
||||
if ($this->hasBOM($filePointer, $encoding)) {
|
||||
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
|
||||
|
||||
// we skip the N first bytes
|
||||
$byteOffsetToSkipBom = strlen($bomUsed);
|
||||
}
|
||||
|
||||
return $byteOffsetToSkipBom;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the file identified by the given pointer has a BOM.
|
||||
*
|
||||
* @param resource $filePointer Pointer to the file to check
|
||||
* @param string $encoding Encoding of the file to check
|
||||
* @return bool TRUE if the file has a BOM, FALSE otherwise
|
||||
*/
|
||||
protected function hasBOM($filePointer, $encoding)
|
||||
{
|
||||
$hasBOM = false;
|
||||
|
||||
$this->globalFunctionsHelper->rewind($filePointer);
|
||||
|
||||
if (array_key_exists($encoding, $this->supportedEncodingsWithBom)) {
|
||||
$potentialBom = $this->supportedEncodingsWithBom[$encoding];
|
||||
$numBytesInBom = strlen($potentialBom);
|
||||
|
||||
$hasBOM = ($this->globalFunctionsHelper->fgets($filePointer, $numBytesInBom + 1) === $potentialBom);
|
||||
}
|
||||
|
||||
return $hasBOM;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to convert a non UTF-8 string into UTF-8.
|
||||
*
|
||||
* @param string $string Non UTF-8 string to be converted
|
||||
* @param string $sourceEncoding The encoding used to encode the source string
|
||||
* @return string The converted, UTF-8 string
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
|
||||
*/
|
||||
public function attemptConversionToUTF8($string, $sourceEncoding)
|
||||
{
|
||||
return $this->attemptConversion($string, $sourceEncoding, self::ENCODING_UTF8);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to convert a UTF-8 string into the given encoding.
|
||||
*
|
||||
* @param string $string UTF-8 string to be converted
|
||||
* @param string $targetEncoding The encoding the string should be re-encoded into
|
||||
* @return string The converted string, encoded with the given encoding
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
|
||||
*/
|
||||
public function attemptConversionFromUTF8($string, $targetEncoding)
|
||||
{
|
||||
return $this->attemptConversion($string, self::ENCODING_UTF8, $targetEncoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to convert the given string to the given encoding.
|
||||
* Depending on what is installed on the server, we will try to iconv or mbstring.
|
||||
*
|
||||
* @param string $string string to be converted
|
||||
* @param string $sourceEncoding The encoding used to encode the source string
|
||||
* @param string $targetEncoding The encoding the string should be re-encoded into
|
||||
* @return string The converted string, encoded with the given encoding
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
|
||||
*/
|
||||
protected function attemptConversion($string, $sourceEncoding, $targetEncoding)
|
||||
{
|
||||
// if source and target encodings are the same, it's a no-op
|
||||
if ($sourceEncoding === $targetEncoding) {
|
||||
return $string;
|
||||
}
|
||||
|
||||
$convertedString = null;
|
||||
|
||||
if ($this->canUseIconv()) {
|
||||
$convertedString = $this->globalFunctionsHelper->iconv($string, $sourceEncoding, $targetEncoding);
|
||||
} else if ($this->canUseMbString()) {
|
||||
$convertedString = $this->globalFunctionsHelper->mb_convert_encoding($string, $sourceEncoding, $targetEncoding);
|
||||
} else {
|
||||
throw new EncodingConversionException("The conversion from $sourceEncoding to $targetEncoding is not supported. Please install \"iconv\" or \"PHP Intl\".");
|
||||
}
|
||||
|
||||
if ($convertedString === false) {
|
||||
throw new EncodingConversionException("The conversion from $sourceEncoding to $targetEncoding failed.");
|
||||
}
|
||||
|
||||
return $convertedString;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether "iconv" can be used.
|
||||
*
|
||||
* @return bool TRUE if "iconv" is available and can be used, FALSE otherwise
|
||||
*/
|
||||
protected function canUseIconv()
|
||||
{
|
||||
return $this->globalFunctionsHelper->function_exists('iconv');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether "mb_string" functions can be used.
|
||||
* These functions come with the PHP Intl package.
|
||||
*
|
||||
* @return bool TRUE if "mb_string" functions are available and can be used, FALSE otherwise
|
||||
*/
|
||||
protected function canUseMbString()
|
||||
{
|
||||
return $this->globalFunctionsHelper->function_exists('mb_convert_encoding');
|
||||
}
|
||||
}
|
||||
@@ -13,15 +13,15 @@ use Box\Spout\Common\Exception\IOException;
|
||||
*/
|
||||
class FileSystemHelper
|
||||
{
|
||||
/** @var string Path of the base folder where all the I/O can occur */
|
||||
protected $baseFolderPath;
|
||||
/** @var string Real path of the base folder where all the I/O can occur */
|
||||
protected $baseFolderRealPath;
|
||||
|
||||
/**
|
||||
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
|
||||
*/
|
||||
public function __construct($baseFolderPath)
|
||||
{
|
||||
$this->baseFolderPath = $baseFolderPath;
|
||||
$this->baseFolderRealPath = realpath($baseFolderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,11 +36,11 @@ class FileSystemHelper
|
||||
{
|
||||
$this->throwIfOperationNotInBaseFolder($parentFolderPath);
|
||||
|
||||
$folderPath = $parentFolderPath . DIRECTORY_SEPARATOR . $folderName;
|
||||
$folderPath = $parentFolderPath . '/' . $folderName;
|
||||
|
||||
$wasCreationSuccessful = mkdir($folderPath, 0777, true);
|
||||
if (!$wasCreationSuccessful) {
|
||||
throw new IOException('Unable to create folder: ' . $folderPath);
|
||||
throw new IOException("Unable to create folder: $folderPath");
|
||||
}
|
||||
|
||||
return $folderPath;
|
||||
@@ -60,11 +60,11 @@ class FileSystemHelper
|
||||
{
|
||||
$this->throwIfOperationNotInBaseFolder($parentFolderPath);
|
||||
|
||||
$filePath = $parentFolderPath . DIRECTORY_SEPARATOR . $fileName;
|
||||
$filePath = $parentFolderPath . '/' . $fileName;
|
||||
|
||||
$wasCreationSuccessful = file_put_contents($filePath, $fileContents);
|
||||
if (!$wasCreationSuccessful) {
|
||||
throw new IOException('Unable to create file: ' . $filePath);
|
||||
if ($wasCreationSuccessful === false) {
|
||||
throw new IOException("Unable to create file: $filePath");
|
||||
}
|
||||
|
||||
return $filePath;
|
||||
@@ -124,9 +124,10 @@ class FileSystemHelper
|
||||
*/
|
||||
protected function throwIfOperationNotInBaseFolder($operationFolderPath)
|
||||
{
|
||||
$isInBaseFolder = (strpos($operationFolderPath, $this->baseFolderPath) === 0);
|
||||
$operationFolderRealPath = realpath($operationFolderPath);
|
||||
$isInBaseFolder = (strpos($operationFolderRealPath, $this->baseFolderRealPath) === 0);
|
||||
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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -106,6 +106,19 @@ class GlobalFunctionsHelper
|
||||
return fputcsv($handle, $fields, $delimiter, $enclosure);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function fwrite()
|
||||
* @see fwrite()
|
||||
*
|
||||
* @param resource $handle
|
||||
* @param string $string
|
||||
* @return int
|
||||
*/
|
||||
public function fwrite($handle, $string)
|
||||
{
|
||||
return fwrite($handle, $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function fclose()
|
||||
* @see fclose()
|
||||
@@ -147,11 +160,59 @@ class GlobalFunctionsHelper
|
||||
* @see file_get_contents()
|
||||
*
|
||||
* @param string $filePath
|
||||
* @return bool
|
||||
* @return string
|
||||
*/
|
||||
public function file_get_contents($filePath)
|
||||
{
|
||||
return file_get_contents($filePath);
|
||||
$realFilePath = $this->convertToUseRealPath($filePath);
|
||||
return file_get_contents($realFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given file path to use a real path.
|
||||
* This is to avoid issues on some Windows setup.
|
||||
*
|
||||
* @param string $filePath File path
|
||||
* @return string The file path using a real path
|
||||
*/
|
||||
protected function convertToUseRealPath($filePath)
|
||||
{
|
||||
$realFilePath = $filePath;
|
||||
|
||||
if ($this->isZipStream($filePath)) {
|
||||
if (preg_match('/zip:\/\/(.*)#(.*)/', $filePath, $matches)) {
|
||||
$documentPath = $matches[1];
|
||||
$documentInsideZipPath = $matches[2];
|
||||
$realFilePath = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
|
||||
}
|
||||
} else {
|
||||
$realFilePath = realpath($filePath);
|
||||
}
|
||||
|
||||
return $realFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given path is a zip stream.
|
||||
*
|
||||
* @param string $path Path pointing to a document
|
||||
* @return bool TRUE if path is a zip stream, FALSE otherwise
|
||||
*/
|
||||
protected function isZipStream($path)
|
||||
{
|
||||
return (strpos($path, 'zip://') === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function feof()
|
||||
* @see feof()
|
||||
*
|
||||
* @param resource
|
||||
* @return bool
|
||||
*/
|
||||
public function feof($handle)
|
||||
{
|
||||
return feof($handle);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -190,4 +251,68 @@ class GlobalFunctionsHelper
|
||||
{
|
||||
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()
|
||||
* @see iconv()
|
||||
*
|
||||
* @param string $string The string to be converted
|
||||
* @param string $sourceEncoding The encoding of the source string
|
||||
* @param string $targetEncoding The encoding the source string should be converted to
|
||||
* @return string|bool the converted string or FALSE on failure.
|
||||
*/
|
||||
public function iconv($string, $sourceEncoding, $targetEncoding)
|
||||
{
|
||||
return iconv($sourceEncoding, $targetEncoding, $string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function mb_convert_encoding()
|
||||
* @see mb_convert_encoding()
|
||||
*
|
||||
* @param string $string The string to be converted
|
||||
* @param string $sourceEncoding The encoding of the source string
|
||||
* @param string $targetEncoding The encoding the source string should be converted to
|
||||
* @return string|bool the converted string or FALSE on failure.
|
||||
*/
|
||||
public function mb_convert_encoding($string, $sourceEncoding, $targetEncoding)
|
||||
{
|
||||
return mb_convert_encoding($string, $targetEncoding, $sourceEncoding);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function stream_get_wrappers()
|
||||
* @see stream_get_wrappers()
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function stream_get_wrappers()
|
||||
{
|
||||
return stream_get_wrappers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function function_exists()
|
||||
* @see function_exists()
|
||||
*
|
||||
* @param string $functionName
|
||||
* @return bool
|
||||
*/
|
||||
public function function_exists($functionName)
|
||||
{
|
||||
return function_exists($functionName);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Helper;
|
||||
|
||||
/**
|
||||
* Class StringHelper
|
||||
* This class provides helper functions to work with strings and multibyte strings.
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @package Box\Spout\Common\Helper
|
||||
*/
|
||||
class StringHelper
|
||||
{
|
||||
/** @var bool Whether the mbstring extension is loaded */
|
||||
protected $hasMbstringSupport;
|
||||
|
||||
/**
|
||||
*
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->hasMbstringSupport = extension_loaded('mbstring');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the length of the given string.
|
||||
* It uses the multi-bytes function is available.
|
||||
* @see strlen
|
||||
* @see mb_strlen
|
||||
*
|
||||
* @param string $string
|
||||
* @return int
|
||||
*/
|
||||
public function getStringLength($string)
|
||||
{
|
||||
return $this->hasMbstringSupport ? mb_strlen($string) : strlen($string);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the first occurrence of the given character/substring within the given string.
|
||||
* It uses the multi-bytes function is available.
|
||||
* @see strpos
|
||||
* @see mb_strpos
|
||||
*
|
||||
* @param string $char Needle
|
||||
* @param string $string Haystack
|
||||
* @return int Char/substring's first occurrence position within the string if found (starts at 0) or -1 if not found
|
||||
*/
|
||||
public function getCharFirstOccurrencePosition($char, $string)
|
||||
{
|
||||
$position = $this->hasMbstringSupport ? mb_strpos($string, $char) : strpos($string, $char);
|
||||
return ($position !== false) ? $position : -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the position of the last occurrence of the given character/substring within the given string.
|
||||
* It uses the multi-bytes function is available.
|
||||
* @see strrpos
|
||||
* @see mb_strrpos
|
||||
*
|
||||
* @param string $char Needle
|
||||
* @param string $string Haystack
|
||||
* @return int Char/substring's last occurrence position within the string if found (starts at 0) or -1 if not found
|
||||
*/
|
||||
public function getCharLastOccurrencePosition($char, $string)
|
||||
{
|
||||
$position = $this->hasMbstringSupport ? mb_strrpos($string, $char) : strrpos($string, $char);
|
||||
return ($position !== false) ? $position : -1;
|
||||
}
|
||||
}
|
||||
@@ -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() {}
|
||||
}
|
||||
@@ -5,9 +5,12 @@ namespace Box\Spout\Common;
|
||||
/**
|
||||
* Class Type
|
||||
* This class references the supported types
|
||||
*
|
||||
* @api
|
||||
*/
|
||||
abstract class Type
|
||||
{
|
||||
const CSV = "csv";
|
||||
const XLSX = "xlsx";
|
||||
const CSV = 'csv';
|
||||
const XLSX = 'xlsx';
|
||||
const ODS = 'ods';
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace Box\Spout\Reader;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\ReaderNotOpenedException;
|
||||
use Box\Spout\Reader\Exception\EndOfFileReachedException;
|
||||
|
||||
/**
|
||||
* Class AbstractReader
|
||||
@@ -14,21 +13,29 @@ use Box\Spout\Reader\Exception\EndOfFileReachedException;
|
||||
*/
|
||||
abstract class AbstractReader implements ReaderInterface
|
||||
{
|
||||
/** @var int Used to keep track of the row index */
|
||||
protected $currentRowIndex = 0;
|
||||
|
||||
/** @var bool Indicates whether the stream is currently open */
|
||||
protected $isStreamOpened = false;
|
||||
|
||||
/** @var bool Indicates whether all rows have been read */
|
||||
protected $hasReachedEndOfFile = false;
|
||||
|
||||
/** @var array Buffer used to store the row data, while checking if there are more rows to read */
|
||||
protected $rowDataBuffer = null;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
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
|
||||
*
|
||||
@@ -38,11 +45,11 @@ abstract class AbstractReader implements ReaderInterface
|
||||
abstract protected function openReader($filePath);
|
||||
|
||||
/**
|
||||
* Reads and returns next row if available.
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return array|null Array that contains the data for the read row or null at the end of the file
|
||||
* @return \Iterator To iterate over sheets
|
||||
*/
|
||||
abstract protected function read();
|
||||
abstract protected function getConcreteSheetIterator();
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
@@ -52,7 +59,7 @@ abstract class AbstractReader implements ReaderInterface
|
||||
abstract protected function closeReader();
|
||||
|
||||
/**
|
||||
* @param $globalFunctionsHelper
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
* @return AbstractReader
|
||||
*/
|
||||
public function setGlobalFunctionsHelper($globalFunctionsHelper)
|
||||
@@ -61,36 +68,126 @@ abstract class AbstractReader implements ReaderInterface
|
||||
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
|
||||
* that the file exists and is readable.
|
||||
*
|
||||
* @api
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path does not exist, is not readable or is corrupted
|
||||
*/
|
||||
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)) {
|
||||
// we skip the checks if the provided file path points to a PHP stream
|
||||
if (!$this->globalFunctionsHelper->file_exists($filePath)) {
|
||||
throw new IOException('Could not open ' . $filePath . ' for reading! File does not exist.');
|
||||
throw new IOException("Could not open $filePath for reading! File does not exist.");
|
||||
} else if (!$this->globalFunctionsHelper->is_readable($filePath)) {
|
||||
throw new IOException('Could not open ' . $filePath . ' for reading! File is not readable.');
|
||||
throw new IOException("Could not open $filePath for reading! File is not readable.");
|
||||
}
|
||||
}
|
||||
|
||||
$this->currentRowIndex = 0;
|
||||
$this->hasReachedEndOfFile = false;
|
||||
|
||||
try {
|
||||
$this->openReader($filePath);
|
||||
$fileRealPath = $this->getFileRealPath($filePath);
|
||||
$this->openReader($fileRealPath);
|
||||
$this->isStreamOpened = true;
|
||||
} catch (\Exception $exception) {
|
||||
throw new IOException('Could not open ' . $filePath . ' for reading! (' . $exception->getMessage() . ')');
|
||||
throw new IOException("Could not open $filePath for reading! ({$exception->getMessage()})");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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, ...)
|
||||
*
|
||||
@@ -99,97 +196,42 @@ abstract class AbstractReader implements ReaderInterface
|
||||
*/
|
||||
protected function isPhpStream($filePath)
|
||||
{
|
||||
return (strpos($filePath, 'php://') === 0);
|
||||
$streamScheme = $this->getStreamWrapperScheme($filePath);
|
||||
return ($streamScheme === 'php');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether all rows have been read (i.e. if we are at the end of the file).
|
||||
* To know if the end of file has been reached, it uses a buffer. If the buffer is
|
||||
* empty (meaning, nothing has been read or previous read line has been consumed), then
|
||||
* it reads the next line, store it in the buffer for the next time or flip a variable if
|
||||
* the end of file has been reached.
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return bool Whether all rows have been read (i.e. if we are at the end of the file)
|
||||
* @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If the stream was not opened first
|
||||
* @api
|
||||
* @return \Iterator To iterate over sheets
|
||||
* @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If called before opening the reader
|
||||
*/
|
||||
public function hasNextRow()
|
||||
public function getSheetIterator()
|
||||
{
|
||||
if (!$this->isStreamOpened) {
|
||||
throw new ReaderNotOpenedException('Stream should be opened first.');
|
||||
throw new ReaderNotOpenedException('Reader should be opened first.');
|
||||
}
|
||||
|
||||
if ($this->hasReachedEndOfFile) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// if the buffer contains unprocessed row
|
||||
if (!$this->isRowDataBufferEmpty()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// otherwise, try to read the next line line, and store it in the buffer
|
||||
$this->rowDataBuffer = $this->read();
|
||||
|
||||
// if the buffer is still empty after reading a row, it means end of file was reached
|
||||
$this->hasReachedEndOfFile = $this->isRowDataBufferEmpty();
|
||||
|
||||
return (!$this->hasReachedEndOfFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns next row if available. The row is either retrieved from the buffer if it is not empty or fetched by
|
||||
* actually reading the file.
|
||||
*
|
||||
* @return array Array that contains the data for the read row
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the stream was not opened first
|
||||
* @throws \Box\Spout\Reader\Exception\EndOfFileReachedException
|
||||
*/
|
||||
public function nextRow()
|
||||
{
|
||||
if (!$this->hasNextRow()) {
|
||||
throw new EndOfFileReachedException('End of file was reached. Cannot read more rows.');
|
||||
}
|
||||
|
||||
// Get data from buffer (if the buffer was empty, it was filled by the call to hasNextRow())
|
||||
$rowData = $this->rowDataBuffer;
|
||||
|
||||
// empty buffer to mark the row as consumed
|
||||
$this->emptyRowDataBuffer();
|
||||
|
||||
$this->currentRowIndex++;
|
||||
|
||||
return $rowData;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the buffer where the row data is stored is empty
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
protected function isRowDataBufferEmpty()
|
||||
{
|
||||
return ($this->rowDataBuffer === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Empty the buffer that stores row data
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function emptyRowDataBuffer()
|
||||
{
|
||||
$this->rowDataBuffer = null;
|
||||
return $this->getConcreteSheetIterator();
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader, preventing any additional reading
|
||||
*
|
||||
* @api
|
||||
* @return void
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if ($this->isStreamOpened) {
|
||||
$this->closeReader();
|
||||
|
||||
$sheetIterator = $this->getConcreteSheetIterator();
|
||||
if ($sheetIterator) {
|
||||
$sheetIterator->end();
|
||||
}
|
||||
|
||||
$this->isStreamOpened = false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,130 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
|
||||
/**
|
||||
* Class CSV
|
||||
* This class provides support to read data from a CSV file.
|
||||
*
|
||||
* @package Box\Spout\Reader
|
||||
*/
|
||||
class CSV extends AbstractReader
|
||||
{
|
||||
const UTF8_BOM = "\xEF\xBB\xBF";
|
||||
|
||||
/** @var resource Pointer to the file to be written */
|
||||
protected $filePointer;
|
||||
|
||||
/** @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 = '"';
|
||||
|
||||
/**
|
||||
* Sets the field delimiter for the CSV
|
||||
*
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @return CSV
|
||||
*/
|
||||
public function setFieldDelimiter($fieldDelimiter)
|
||||
{
|
||||
$this->fieldDelimiter = $fieldDelimiter;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the field enclosure for the CSV
|
||||
*
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @return CSV
|
||||
*/
|
||||
public function setFieldEnclosure($fieldEnclosure)
|
||||
{
|
||||
$this->fieldEnclosure = $fieldEnclosure;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Opens the file at the given path to make it ready to be read.
|
||||
* The file must be UTF-8 encoded.
|
||||
* @TODO add encoding detection/conversion
|
||||
*
|
||||
* @param string $filePath Path of the CSV file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException
|
||||
*/
|
||||
protected function openReader($filePath)
|
||||
{
|
||||
$this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
|
||||
if (!$this->filePointer) {
|
||||
throw new IOException('Could not open file ' . $filePath . ' for reading.');
|
||||
}
|
||||
|
||||
$this->skipUtf8Bom();
|
||||
}
|
||||
|
||||
/**
|
||||
* This skips the UTF-8 BOM if inserted at the beginning of the file
|
||||
* by moving the file pointer after it, so that it is not read.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function skipUtf8Bom()
|
||||
{
|
||||
$this->globalFunctionsHelper->rewind($this->filePointer);
|
||||
|
||||
$hasUtf8Bom = ($this->globalFunctionsHelper->fgets($this->filePointer, 4) === self::UTF8_BOM);
|
||||
|
||||
if ($hasUtf8Bom) {
|
||||
// we skip the 2 first bytes (so start from the 3rd byte)
|
||||
$this->globalFunctionsHelper->fseek($this->filePointer, 3);
|
||||
} else {
|
||||
// if no BOM, reset the pointer to read from the beginning
|
||||
$this->globalFunctionsHelper->fseek($this->filePointer, 0);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and returns next row if available.
|
||||
* Empty rows are skipped.
|
||||
*
|
||||
* @return array|null Array that contains the data for the read row or null at the end of the file
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
$lineData = null;
|
||||
|
||||
if ($this->filePointer) {
|
||||
do {
|
||||
$lineData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, 0, $this->fieldDelimiter, $this->fieldEnclosure);
|
||||
} while ($lineData && $this->isEmptyLine($lineData));
|
||||
}
|
||||
|
||||
// When reaching the end of the file, return null instead of false
|
||||
return ($lineData !== false) ? $lineData : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $lineData Array containing the cells value for the line
|
||||
* @return bool Whether the given line is empty
|
||||
*/
|
||||
protected function isEmptyLine($lineData)
|
||||
{
|
||||
return (count($lineData) === 1 && $lineData[0] === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeReader()
|
||||
{
|
||||
if ($this->filePointer) {
|
||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Reader\AbstractReader;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
|
||||
/**
|
||||
* Class Reader
|
||||
* This class provides support to read data from a CSV file.
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class Reader extends AbstractReader
|
||||
{
|
||||
/** @var resource Pointer to the file to be written */
|
||||
protected $filePointer;
|
||||
|
||||
/** @var SheetIterator To iterator over the CSV unique "sheet" */
|
||||
protected $sheetIterator;
|
||||
|
||||
/** @var string Original value for the "auto_detect_line_endings" INI value */
|
||||
protected $originalAutoDetectLineEndings;
|
||||
|
||||
/**
|
||||
* Returns the reader's current options
|
||||
*
|
||||
* @return ReaderOptions
|
||||
*/
|
||||
protected function getOptions()
|
||||
{
|
||||
if (!isset($this->options)) {
|
||||
$this->options = new ReaderOptions();
|
||||
}
|
||||
return $this->options;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the field delimiter for the CSV.
|
||||
* Needs to be called before opening the reader.
|
||||
*
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @return Reader
|
||||
*/
|
||||
public function setFieldDelimiter($fieldDelimiter)
|
||||
{
|
||||
$this->getOptions()->setFieldDelimiter($fieldDelimiter);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the field enclosure for the CSV.
|
||||
* Needs to be called before opening the reader.
|
||||
*
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @return Reader
|
||||
*/
|
||||
public function setFieldEnclosure($fieldEnclosure)
|
||||
{
|
||||
$this->getOptions()->setFieldEnclosure($fieldEnclosure);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 Reader
|
||||
*/
|
||||
public function setEncoding($encoding)
|
||||
{
|
||||
$this->getOptions()->setEncoding($encoding);
|
||||
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.
|
||||
* If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
|
||||
*
|
||||
* @param string $filePath Path of the CSV file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException
|
||||
*/
|
||||
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');
|
||||
if (!$this->filePointer) {
|
||||
throw new IOException("Could not open file $filePath for reading.");
|
||||
}
|
||||
|
||||
$this->sheetIterator = new SheetIterator(
|
||||
$this->filePointer,
|
||||
$this->getOptions(),
|
||||
$this->globalFunctionsHelper
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeReader()
|
||||
{
|
||||
if ($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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Common\Helper\EncodingHelper;
|
||||
|
||||
/**
|
||||
* Class RowIterator
|
||||
* Iterate over CSV rows.
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class RowIterator implements IteratorInterface
|
||||
{
|
||||
/**
|
||||
* Value passed to fgetcsv. 0 means "unlimited" (slightly slower but accomodates for very long lines).
|
||||
*/
|
||||
const MAX_READ_BYTES_PER_LINE = 0;
|
||||
|
||||
/** @var resource Pointer to the CSV file to read */
|
||||
protected $filePointer;
|
||||
|
||||
/** @var int Number of read rows */
|
||||
protected $numReadRows = 0;
|
||||
|
||||
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
|
||||
protected $rowDataBuffer = null;
|
||||
|
||||
/** @var bool Indicates whether all rows have been read */
|
||||
protected $hasReachedEndOfFile = false;
|
||||
|
||||
/** @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;
|
||||
|
||||
/** @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 */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\EncodingHelper Helper to work with different encodings */
|
||||
protected $encodingHelper;
|
||||
|
||||
/** @var string End of line delimiter, encoded using the same encoding as the CSV */
|
||||
protected $encodedEOLDelimiter;
|
||||
|
||||
/**
|
||||
* @param resource $filePointer Pointer to the CSV file to read
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->filePointer = $filePointer;
|
||||
$this->fieldDelimiter = $options->getFieldDelimiter();
|
||||
$this->fieldEnclosure = $options->getFieldEnclosure();
|
||||
$this->encoding = $options->getEncoding();
|
||||
$this->inputEOLDelimiter = $options->getEndOfLineCharacter();
|
||||
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
|
||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||
|
||||
$this->encodingHelper = new EncodingHelper($globalFunctionsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->rewindAndSkipBom();
|
||||
|
||||
$this->numReadRows = 0;
|
||||
$this->rowDataBuffer = null;
|
||||
|
||||
$this->next();
|
||||
}
|
||||
|
||||
/**
|
||||
* This rewinds and skips the BOM if inserted at the beginning of the file
|
||||
* by moving the file pointer after it, so that it is not read.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function rewindAndSkipBom()
|
||||
{
|
||||
$byteOffsetToSkipBom = $this->encodingHelper->getBytesOffsetToSkipBOM($this->filePointer, $this->encoding);
|
||||
|
||||
// sets the cursor after the BOM (0 means no BOM, so rewind it)
|
||||
$this->globalFunctionsHelper->fseek($this->filePointer, $byteOffsetToSkipBom);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return ($this->filePointer && !$this->hasReachedEndOfFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element. Reads data for the next unprocessed row.
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||
|
||||
if (!$this->hasReachedEndOfFile) {
|
||||
$this->readDataForNextRow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||
*/
|
||||
protected function readDataForNextRow()
|
||||
{
|
||||
do {
|
||||
$rowData = $this->getNextUTF8EncodedRow();
|
||||
} while ($this->shouldReadNextRow($rowData));
|
||||
|
||||
if ($rowData !== false) {
|
||||
// str_replace will replace NULL values by empty strings
|
||||
$this->rowDataBuffer = str_replace(null, null, $rowData);
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the end of line delimiter, encoded using the same encoding as the CSV.
|
||||
* The return value is cached.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getEncodedEOLDelimiter()
|
||||
{
|
||||
if (!isset($this->encodedEOLDelimiter)) {
|
||||
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8($this->inputEOLDelimiter, $this->encoding);
|
||||
}
|
||||
|
||||
return $this->encodedEOLDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array|bool $lineData Array containing the cells value for the line
|
||||
* @return bool Whether the given line is empty
|
||||
*/
|
||||
protected function isEmptyLine($lineData)
|
||||
{
|
||||
return (is_array($lineData) && count($lineData) === 1 && $lineData[0] === null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element from the buffer
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->rowDataBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->numReadRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Reader\SheetInterface;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class Sheet implements SheetInterface
|
||||
{
|
||||
/** @var \Box\Spout\Reader\CSV\RowIterator To iterate over the CSV's rows */
|
||||
protected $rowIterator;
|
||||
|
||||
/**
|
||||
* @param resource $filePointer Pointer to the CSV file to read
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($filePointer, $options, $globalFunctionsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return \Box\Spout\Reader\CSV\RowIterator
|
||||
*/
|
||||
public function getRowIterator()
|
||||
{
|
||||
return $this->rowIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return int Index of the sheet
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return string Name of the sheet - empty string since CSV does not support that
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Always TRUE as there is only one sheet
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
|
||||
/**
|
||||
* Class SheetIterator
|
||||
* Iterate over CSV unique "sheet".
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class SheetIterator implements IteratorInterface
|
||||
{
|
||||
/** @var \Box\Spout\Reader\CSV\Sheet The CSV unique "sheet" */
|
||||
protected $sheet;
|
||||
|
||||
/** @var bool Whether the unique "sheet" has already been read */
|
||||
protected $hasReadUniqueSheet = false;
|
||||
|
||||
/**
|
||||
* @param resource $filePointer
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->sheet = new Sheet($filePointer, $options, $globalFunctionsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->hasReadUniqueSheet = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return (!$this->hasReadUniqueSheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$this->hasReadUniqueSheet = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return \Box\Spout\Reader\CSV\Sheet
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->sheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
// do nothing
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Exception;
|
||||
|
||||
/**
|
||||
* Class EndOfWorksheetsReachedException
|
||||
*
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class EndOfWorksheetsReachedException extends ReaderException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Exception;
|
||||
|
||||
/**
|
||||
* Class IteratorNotRewindableException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class IteratorNotRewindableException extends ReaderException
|
||||
{
|
||||
}
|
||||
+3
-2
@@ -3,10 +3,11 @@
|
||||
namespace Box\Spout\Reader\Exception;
|
||||
|
||||
/**
|
||||
* Class NoWorksheetsFoundException
|
||||
* Class NoSheetsFoundException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class NoWorksheetsFoundException extends ReaderException
|
||||
class NoSheetsFoundException extends ReaderException
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\Exception;
|
||||
/**
|
||||
* Class ReaderNotOpenedException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class ReaderNotOpenedException extends ReaderException
|
||||
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\Exception;
|
||||
/**
|
||||
* Class SharedStringNotFoundException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class SharedStringNotFoundException extends ReaderException
|
||||
|
||||
+2
-2
@@ -3,10 +3,10 @@
|
||||
namespace Box\Spout\Reader\Exception;
|
||||
|
||||
/**
|
||||
* Class EndOfFileReachedException
|
||||
* Class XMLProcessingException
|
||||
*
|
||||
* @package Box\Spout\Reader\Exception
|
||||
*/
|
||||
class EndOfFileReachedException extends ReaderException
|
||||
class XMLProcessingException extends ReaderException
|
||||
{
|
||||
}
|
||||
@@ -1,97 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Helper\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Class CellHelper
|
||||
* This class provides helper functions when working with cells
|
||||
*
|
||||
* @package Box\Spout\Reader\Helper\XLSX
|
||||
*/
|
||||
class CellHelper
|
||||
{
|
||||
/**
|
||||
* Fills the missing indexes of an array with a given value.
|
||||
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
|
||||
* Calling fillMissingArrayIndexes($dataArray, 'FILL') will return this array: ['FILL', 1, 'FILL', 3]
|
||||
*
|
||||
* @param array $dataArray The array to fill
|
||||
* @param string|void $fillValue optional
|
||||
* @return array
|
||||
*/
|
||||
public static function fillMissingArrayIndexes($dataArray, $fillValue = '')
|
||||
{
|
||||
$existingIndexes = array_keys($dataArray);
|
||||
|
||||
$newIndexes = array_fill_keys(range(0, max($existingIndexes)), $fillValue);
|
||||
$dataArray += $newIndexes;
|
||||
|
||||
ksort($dataArray);
|
||||
|
||||
return $dataArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base 10 column index associated to the cell index (base 26).
|
||||
* Excel uses A to Z letters for column indexing, where A is the 1st column,
|
||||
* Z is the 26th and AA is the 27th.
|
||||
* The mapping is zero based, so that A1 maps to 0, B2 maps to 1, Z13 to 25 and AA4 to 26.
|
||||
*
|
||||
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
|
||||
* @return int
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
|
||||
*/
|
||||
public static function getColumnIndexFromCellIndex($cellIndex)
|
||||
{
|
||||
if (!self::isValidCellIndex($cellIndex)) {
|
||||
throw new InvalidArgumentException('Cannot get column index from an invalid cell index.');
|
||||
}
|
||||
|
||||
$columnIndex = 0;
|
||||
$capitalAAsciiValue = ord('A');
|
||||
$capitalZAsciiValue = ord('Z');
|
||||
$step = $capitalZAsciiValue - $capitalAAsciiValue + 1;
|
||||
|
||||
// Remove row information
|
||||
$column = preg_replace('/\d/', '', $cellIndex);
|
||||
$columnLength = strlen($column);
|
||||
|
||||
/*
|
||||
* This is how the following loop will process the data:
|
||||
* A => 0
|
||||
* 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) {
|
||||
$columnIndex += $currentColumnIndex;
|
||||
} else {
|
||||
$columnIndex += pow($step, ($columnLength - 1)) * ($currentColumnIndex + 1);
|
||||
}
|
||||
|
||||
$columnLength--;
|
||||
}
|
||||
|
||||
return $columnIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isValidCellIndex($cellIndex)
|
||||
{
|
||||
return (preg_match('/^[A-Z]+\d+$/', $cellIndex) === 1);
|
||||
}
|
||||
}
|
||||
@@ -1,325 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Helper\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Common\Helper\FileSystemHelper;
|
||||
use Box\Spout\Reader\Exception\SharedStringNotFoundException;
|
||||
|
||||
/**
|
||||
* Class SharedStringsHelper
|
||||
* This class provides helper functions for reading sharedStrings XML file
|
||||
*
|
||||
* @package Box\Spout\Reader\Helper\XLSX
|
||||
*/
|
||||
class SharedStringsHelper
|
||||
{
|
||||
/** Path of sharedStrings XML file inside the XLSX file */
|
||||
const SHARED_STRINGS_XML_FILE_PATH = 'xl/sharedStrings.xml';
|
||||
|
||||
/** Main namespace for the sharedStrings.xml file */
|
||||
const MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
||||
|
||||
/**
|
||||
* To avoid running out of memory when extracting the shared strings, they will be saved to temporary files
|
||||
* instead of in memory. Then, when accessing a string, the corresponding file contents will be loaded in memory
|
||||
* and the string will be quickly retrieved.
|
||||
* The performance bottleneck is not when creating these temporary files, but rather when loading their content.
|
||||
* Because the contents of the last loaded file stays in memory until another file needs to be loaded, it works
|
||||
* best when the indexes of the shared strings are sorted in the sheet data.
|
||||
* 10,000 was chosen because it creates small files that are fast to be loaded in memory.
|
||||
*/
|
||||
const MAX_NUM_STRINGS_PER_TEMP_FILE = 10000;
|
||||
|
||||
/** Value to use to escape the line feed character ("\n") */
|
||||
const ESCAPED_LINE_FEED_CHARACTER = '_x000A_';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
/** @var string Temporary folder where the temporary files to store shared strings will be stored */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var \Box\Spout\Writer\Helper\XLSX\FileSystemHelper Helper to perform file system operations */
|
||||
protected $fileSystemHelper;
|
||||
|
||||
/** @var resource Pointer to the last temp file a shared string was written to */
|
||||
protected $tempFilePointer;
|
||||
|
||||
/**
|
||||
* @var string Path of the temporary file whose contents is currently stored in memory
|
||||
* @see MAX_NUM_STRINGS_PER_TEMP_FILE
|
||||
*/
|
||||
protected $inMemoryTempFilePath;
|
||||
|
||||
/**
|
||||
* @var string Contents of the temporary file that was last read
|
||||
* @see MAX_NUM_STRINGS_PER_TEMP_FILE
|
||||
*/
|
||||
protected $inMemoryTempFileContents;
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
public function __construct($filePath, $tempFolder = null)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
|
||||
$rootTempFolder = ($tempFolder) ?: sys_get_temp_dir();
|
||||
$this->fileSystemHelper = new FileSystemHelper($rootTempFolder);
|
||||
$this->tempFolder = $this->fileSystemHelper->createFolder($rootTempFolder, uniqid('sharedstrings'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the XLSX file contains a shared strings XML file
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSharedStrings()
|
||||
{
|
||||
$hasSharedStrings = false;
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($this->filePath) === true) {
|
||||
$hasSharedStrings = ($zip->locateName(self::SHARED_STRINGS_XML_FILE_PATH) !== false);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return $hasSharedStrings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an in-memory array containing all the shared strings of the worksheet.
|
||||
* All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'.
|
||||
* It is then accessed by the worksheet data, via the string index in the built table.
|
||||
*
|
||||
* More documentation available here: http://msdn.microsoft.com/en-us/library/office/gg278314.aspx
|
||||
*
|
||||
* The XML file can be really big with worksheets containing a lot of data. That is why
|
||||
* we need to use a XML reader that provides streaming like the XMLReader library.
|
||||
* Please note that SimpleXML does not provide such a functionality but since it is faster
|
||||
* and more handy to parse few XML nodes, it is used in combination with XMLReader for that purpose.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml can't be read
|
||||
*/
|
||||
public function extractSharedStrings()
|
||||
{
|
||||
$xmlReader = new \XMLReader();
|
||||
$sharedStringIndex = 0;
|
||||
$this->tempFilePointer = null;
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
|
||||
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
||||
if ($xmlReader->open($sharedStringsFilePath, null, LIBXML_NONET) === false) {
|
||||
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
|
||||
}
|
||||
|
||||
while ($xmlReader->read() && $xmlReader->name !== 'si') {
|
||||
// do nothing until a 'si' tag is reached
|
||||
}
|
||||
|
||||
while ($xmlReader->name === 'si') {
|
||||
$node = new \SimpleXMLElement($xmlReader->readOuterXml());
|
||||
$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);
|
||||
|
||||
// The shared string retrieval logic expects each cell data to be on one line only
|
||||
// Encoding the line feed character allows to preserve this assumption
|
||||
$lineFeedEncodedTextValue = $this->escapeLineFeed($unescapedTextValue);
|
||||
|
||||
$this->writeSharedStringToTempFile($lineFeedEncodedTextValue, $sharedStringIndex);
|
||||
|
||||
$sharedStringIndex++;
|
||||
|
||||
// jump to the next 'si' tag
|
||||
$xmlReader->next('si');
|
||||
}
|
||||
|
||||
// close pointer to the last temp file that was written
|
||||
if ($this->tempFilePointer) {
|
||||
fclose($this->tempFilePointer);
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The path to the shared strings XML file, working cross-platforms
|
||||
*/
|
||||
protected function getSharedStringsFilePath()
|
||||
{
|
||||
$sharedStringsXmlFilePath = str_replace('/', DIRECTORY_SEPARATOR, self::SHARED_STRINGS_XML_FILE_PATH);
|
||||
return 'zip://' . $this->filePath . '#' . $sharedStringsXmlFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes nodes that should not be read, like the pronunciation of the Kanji characters.
|
||||
* By keeping them, their text content would be added to the read string.
|
||||
*
|
||||
* @param \SimpleXMLElement $parentNode Parent node that may contain nodes to remove
|
||||
* @return \SimpleXMLElement Cleaned parent node
|
||||
*/
|
||||
protected function removeSuperfluousTextNodes($parentNode)
|
||||
{
|
||||
$tagsToRemove = [
|
||||
'rPh', // Pronunciation of the text
|
||||
];
|
||||
|
||||
foreach ($tagsToRemove as $tagToRemove) {
|
||||
$xpath = '//ns:' . $tagToRemove;
|
||||
$nodesToRemove = $parentNode->xpath($xpath);
|
||||
|
||||
foreach ($nodesToRemove as $nodeToRemove) {
|
||||
// This is how to remove a node from the XML
|
||||
unset($nodeToRemove[0]);
|
||||
}
|
||||
}
|
||||
|
||||
return $parentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
||||
*
|
||||
* @param \SimpleXMLElement $textNode The text node element (<t>) whitespace may be preserved
|
||||
* @return bool Whether whitespace should be preserved
|
||||
*/
|
||||
protected function shouldPreserveWhitespace($textNode)
|
||||
{
|
||||
$shouldPreserveWhitespace = false;
|
||||
|
||||
$attributes = $textNode->attributes('xml', true);
|
||||
if ($attributes) {
|
||||
foreach ($attributes as $attributeName => $attributeValue) {
|
||||
if ($attributeName === 'space' && $attributeValue->__toString() === 'preserve') {
|
||||
$shouldPreserveWhitespace = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $shouldPreserveWhitespace;
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes the given string to its associated temp file.
|
||||
* A new temporary file is created when the previous one has reached its max capacity.
|
||||
*
|
||||
* @param string $sharedString Shared string to write to the temp file
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return void
|
||||
*/
|
||||
protected function writeSharedStringToTempFile($sharedString, $sharedStringIndex)
|
||||
{
|
||||
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
|
||||
|
||||
if (!file_exists($tempFilePath)) {
|
||||
if ($this->tempFilePointer) {
|
||||
fclose($this->tempFilePointer);
|
||||
}
|
||||
$this->tempFilePointer = fopen($tempFilePath, 'w');
|
||||
}
|
||||
|
||||
fwrite($this->tempFilePointer, $sharedString . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path for the temp file that should contain the string for the given index
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The temp file path for the given index
|
||||
*/
|
||||
protected function getSharedStringTempFilePath($sharedStringIndex)
|
||||
{
|
||||
$numTempFile = intval($sharedStringIndex / self::MAX_NUM_STRINGS_PER_TEMP_FILE);
|
||||
return $this->tempFolder . DIRECTORY_SEPARATOR . 'sharedstrings' . $numTempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared string at the given index.
|
||||
* Because the strings have been split into different files, it looks for the value in the correct file.
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The shared string at the given index
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
|
||||
*/
|
||||
public function getStringAtIndex($sharedStringIndex)
|
||||
{
|
||||
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
|
||||
$indexInFile = $sharedStringIndex % self::MAX_NUM_STRINGS_PER_TEMP_FILE;
|
||||
|
||||
if (!file_exists($tempFilePath)) {
|
||||
throw new SharedStringNotFoundException("Shared string temp file not found: $tempFilePath ; for index: $sharedStringIndex");
|
||||
}
|
||||
|
||||
if ($this->inMemoryTempFilePath !== $tempFilePath) {
|
||||
// free memory
|
||||
unset($this->inMemoryTempFileContents);
|
||||
|
||||
$this->inMemoryTempFileContents = explode(PHP_EOL, file_get_contents($tempFilePath));
|
||||
$this->inMemoryTempFilePath = $tempFilePath;
|
||||
}
|
||||
|
||||
$sharedString = null;
|
||||
if (array_key_exists($indexInFile, $this->inMemoryTempFileContents)) {
|
||||
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
|
||||
$sharedString = $this->unescapeLineFeed($escapedSharedString);
|
||||
}
|
||||
|
||||
if ($sharedString === null) {
|
||||
throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex");
|
||||
}
|
||||
|
||||
return rtrim($sharedString, PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the line feed character (\n)
|
||||
*
|
||||
* @param string $unescapedString
|
||||
* @return string
|
||||
*/
|
||||
private function escapeLineFeed($unescapedString)
|
||||
{
|
||||
return str_replace("\n", self::ESCAPED_LINE_FEED_CHARACTER, $unescapedString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes the line feed character (\n)
|
||||
*
|
||||
* @param string $escapedString
|
||||
* @return string
|
||||
*/
|
||||
private function unescapeLineFeed($escapedString)
|
||||
{
|
||||
return str_replace(self::ESCAPED_LINE_FEED_CHARACTER, "\n", $escapedString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the created temporary folder and all its contents
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function cleanup()
|
||||
{
|
||||
$this->fileSystemHelper->deleteFolderRecursively($this->tempFolder);
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Helper\XLSX;
|
||||
|
||||
use Box\Spout\Reader\Internal\XLSX\Worksheet;
|
||||
use Box\Spout\Reader\Sheet;
|
||||
|
||||
/**
|
||||
* Class WorksheetHelper
|
||||
* This class provides helper functions related to XLSX worksheets
|
||||
*
|
||||
* @package Box\Spout\Reader\Helper\XLSX
|
||||
*/
|
||||
class WorksheetHelper
|
||||
{
|
||||
/** Extension for XML files */
|
||||
const XML_EXTENSION = '.xml';
|
||||
|
||||
/** 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_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 worksheets */
|
||||
const OVERRIDE_CONTENT_TYPES_ATTRIBUTE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/** @var \SimpleXMLElement XML element representing the workbook.xml.rels file */
|
||||
protected $workbookXMLRelsAsXMLElement;
|
||||
|
||||
/** @var \SimpleXMLElement XML element representing the workbook.xml file */
|
||||
protected $workbookXMLAsXMLElement;
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the XLSX file being read
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePath, $globalFunctionsHelper)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the file paths of the worksheet data XML files within the XLSX file.
|
||||
* The paths are read from the [Content_Types].xml file.
|
||||
*
|
||||
* @return Worksheet[] Worksheets within the XLSX file
|
||||
*/
|
||||
public function getWorksheets()
|
||||
{
|
||||
$worksheets = [];
|
||||
|
||||
$contentTypesAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
||||
self::CONTENT_TYPES_XML_FILE_PATH,
|
||||
self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML
|
||||
);
|
||||
|
||||
// find all nodes defining a worksheet
|
||||
$sheetNodes = $contentTypesAsXMLElement->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]');
|
||||
|
||||
for ($i = 0; $i < count($sheetNodes); $i++) {
|
||||
$sheetNode = $sheetNodes[$i];
|
||||
$sheetDataXMLFilePath = (string) $sheetNode->attributes()->PartName;
|
||||
|
||||
$sheet = $this->getSheet($sheetDataXMLFilePath, $i);
|
||||
$worksheets[] = new Worksheet($sheet, $i, $sheetDataXMLFilePath);
|
||||
}
|
||||
|
||||
return $worksheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an instance of a sheet, given the path of its data XML file.
|
||||
* We first look at "xl/_rels/workbook.xml.rels" to find the relationship ID of the sheet.
|
||||
* Then we look at "xl/worbook.xml" to find the sheet entry associated to the found ID.
|
||||
* 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
|
||||
* to the sheet index, based on order in [Content_Types].xml. Similarly, the sheet's name will
|
||||
* 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\Sheet Sheet instance
|
||||
*/
|
||||
protected function getSheet($sheetDataXMLFilePath, $sheetIndexZeroBased)
|
||||
{
|
||||
$sheetId = $sheetIndexZeroBased + 1;
|
||||
$sheetName = $this->getDefaultSheetName($sheetDataXMLFilePath);
|
||||
|
||||
/*
|
||||
* 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 = (string) $relationshipNode->attributes()->Id;
|
||||
|
||||
$workbookXMLElement = $this->getWorkbookXMLAsXMLElement();
|
||||
$sheetNodes = $workbookXMLElement->xpath('//ns:sheet[@r:id="' . $sheetId . '"]');
|
||||
|
||||
if (count($sheetNodes) === 1) {
|
||||
$sheetNode = $sheetNodes[0];
|
||||
$sheetId = (int) $sheetNode->attributes()->sheetId;
|
||||
$escapedSheetName = (string) $sheetNode->attributes()->name;
|
||||
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$sheetName = $escaper->unescape($escapedSheetName);
|
||||
}
|
||||
}
|
||||
|
||||
return new Sheet($sheetId, $sheetIndexZeroBased, $sheetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default name of the sheet whose data is located
|
||||
* at the given path.
|
||||
*
|
||||
* @param $sheetDataXMLFilePath
|
||||
* @return string The default sheet name
|
||||
*/
|
||||
protected function getDefaultSheetName($sheetDataXMLFilePath)
|
||||
{
|
||||
return $this->globalFunctionsHelper->basename($sheetDataXMLFilePath, self::XML_EXTENSION);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a representation of the workbook.xml.rels file, ready to be parsed.
|
||||
* The returned value is cached.
|
||||
*
|
||||
* @return \SimpleXMLElement XML element representating the workbook.xml.rels file
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a representation of the workbook.xml file, ready to be parsed.
|
||||
* The returned value is cached.
|
||||
*
|
||||
* @return \SimpleXMLElement XML element representating the workbook.xml.rels file
|
||||
*/
|
||||
protected function getWorkbookXMLAsXMLElement()
|
||||
{
|
||||
if (!$this->workbookXMLAsXMLElement) {
|
||||
$this->workbookXMLAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
||||
self::WORKBOOK_XML_FILE_PATH,
|
||||
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML
|
||||
);
|
||||
}
|
||||
|
||||
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 \SimpleXMLElement The XML element representing the file
|
||||
*/
|
||||
protected function getFileAsXMLElementWithNamespace($xmlFilePath, $mainNamespace)
|
||||
{
|
||||
$normalizedXmlFilePath = str_replace('/', DIRECTORY_SEPARATOR, $xmlFilePath);
|
||||
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $normalizedXmlFilePath);
|
||||
|
||||
$xmlElement = new \SimpleXMLElement($xmlContents);
|
||||
$xmlElement->registerXPathNamespace('ns', $mainNamespace);
|
||||
|
||||
return $xmlElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether another worksheet exists after the current worksheet.
|
||||
* The order is determined by the order of appearance in the [Content_Types].xml file.
|
||||
*
|
||||
* @param Worksheet|null $currentWorksheet The worksheet being currently read or null if reading has not started yet
|
||||
* @param Worksheet[] $allWorksheets A list of all worksheets in the XLSX file. Must contain at least one worksheet
|
||||
* @return bool Whether another worksheet exists after the current sheet
|
||||
*/
|
||||
public function hasNextWorksheet($currentWorksheet, $allWorksheets)
|
||||
{
|
||||
return ($currentWorksheet === null || ($currentWorksheet->getWorksheetIndex() + 1 < count($allWorksheets)));
|
||||
}
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Internal\XLSX;
|
||||
|
||||
/**
|
||||
* Class Worksheet
|
||||
* Represents a worksheet within a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Reader\Internal\XLSX
|
||||
*/
|
||||
class Worksheet
|
||||
{
|
||||
/** @var \Box\Spout\Reader\Sheet The "external" sheet */
|
||||
protected $externalSheet;
|
||||
|
||||
/** @var int Worksheet index, based on the order of appareance in [Content_Types].xml (zero-based) */
|
||||
protected $worksheetIndex;
|
||||
|
||||
/** @var string Path of the XML file containing the worksheet data */
|
||||
protected $dataXmlFilePath;
|
||||
|
||||
/**\
|
||||
* @param \Box\Spout\Reader\Sheet $externalSheet The associated "external" sheet
|
||||
* @param int $worksheetIndex Worksheet index, based on the order of appareance in [Content_Types].xml (zero-based)
|
||||
* @param string $dataXmlFilePath Path of the XML file containing the worksheet data
|
||||
*/
|
||||
public function __construct($externalSheet, $worksheetIndex, $dataXmlFilePath)
|
||||
{
|
||||
$this->externalSheet = $externalSheet;
|
||||
$this->worksheetIndex = $worksheetIndex;
|
||||
$this->dataXmlFilePath = $dataXmlFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Path of the XML file containing the worksheet data,
|
||||
* without the leading slash and working cross-platforms.
|
||||
*/
|
||||
public function getDataXmlFilePath()
|
||||
{
|
||||
$dataXmlFilePath = ltrim($this->dataXmlFilePath, '/');
|
||||
return str_replace('/', DIRECTORY_SEPARATOR, $dataXmlFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Reader\Sheet The "external" sheet
|
||||
*/
|
||||
public function getExternalSheet()
|
||||
{
|
||||
return $this->externalSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function getWorksheetIndex()
|
||||
{
|
||||
return $this->worksheetIndex;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader;
|
||||
|
||||
/**
|
||||
* Interface IteratorInterface
|
||||
*
|
||||
* @package Box\Spout\Reader
|
||||
*/
|
||||
interface IteratorInterface extends \Iterator
|
||||
{
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end();
|
||||
}
|
||||
@@ -0,0 +1,231 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS\Helper;
|
||||
|
||||
/**
|
||||
* Class CellValueFormatter
|
||||
* This class provides helper functions to format cell values
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS\Helper
|
||||
*/
|
||||
class CellValueFormatter
|
||||
{
|
||||
/** Definition of all possible cell types */
|
||||
const CELL_TYPE_STRING = 'string';
|
||||
const CELL_TYPE_FLOAT = 'float';
|
||||
const CELL_TYPE_BOOLEAN = 'boolean';
|
||||
const CELL_TYPE_DATE = 'date';
|
||||
const CELL_TYPE_TIME = 'time';
|
||||
const CELL_TYPE_CURRENCY = 'currency';
|
||||
const CELL_TYPE_PERCENTAGE = 'percentage';
|
||||
const CELL_TYPE_VOID = 'void';
|
||||
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_P = 'p';
|
||||
const XML_NODE_S = 'text:s';
|
||||
const XML_NODE_A = 'text:a';
|
||||
const XML_NODE_SPAN = 'text:span';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_TYPE = 'office:value-type';
|
||||
const XML_ATTRIBUTE_VALUE = 'office:value';
|
||||
const XML_ATTRIBUTE_BOOLEAN_VALUE = 'office:boolean-value';
|
||||
const XML_ATTRIBUTE_DATE_VALUE = 'office:date-value';
|
||||
const XML_ATTRIBUTE_TIME_VALUE = 'office:time-value';
|
||||
const XML_ATTRIBUTE_CURRENCY = 'office:currency';
|
||||
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 */
|
||||
protected $escaper;
|
||||
|
||||
/**
|
||||
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
|
||||
*/
|
||||
public function __construct($shouldFormatDates)
|
||||
{
|
||||
$this->shouldFormatDates = $shouldFormatDates;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
||||
* @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
|
||||
*/
|
||||
public function extractAndFormatNodeValue($node)
|
||||
{
|
||||
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE);
|
||||
|
||||
switch ($cellType) {
|
||||
case self::CELL_TYPE_STRING:
|
||||
return $this->formatStringCellValue($node);
|
||||
case self::CELL_TYPE_FLOAT:
|
||||
return $this->formatFloatCellValue($node);
|
||||
case self::CELL_TYPE_BOOLEAN:
|
||||
return $this->formatBooleanCellValue($node);
|
||||
case self::CELL_TYPE_DATE:
|
||||
return $this->formatDateCellValue($node);
|
||||
case self::CELL_TYPE_TIME:
|
||||
return $this->formatTimeCellValue($node);
|
||||
case self::CELL_TYPE_CURRENCY:
|
||||
return $this->formatCurrencyCellValue($node);
|
||||
case self::CELL_TYPE_PERCENTAGE:
|
||||
return $this->formatPercentageCellValue($node);
|
||||
case self::CELL_TYPE_VOID:
|
||||
default:
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell String value.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string The value associated with the cell
|
||||
*/
|
||||
protected function formatStringCellValue($node)
|
||||
{
|
||||
$pNodeValues = [];
|
||||
$pNodes = $node->getElementsByTagName(self::XML_NODE_P);
|
||||
|
||||
foreach ($pNodes as $pNode) {
|
||||
$currentPValue = '';
|
||||
|
||||
foreach ($pNode->childNodes as $childNode) {
|
||||
if ($childNode instanceof \DOMText) {
|
||||
$currentPValue .= $childNode->nodeValue;
|
||||
} else if ($childNode->nodeName === self::XML_NODE_S) {
|
||||
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
|
||||
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
|
||||
$currentPValue .= str_repeat(' ', $numSpaces);
|
||||
} else if ($childNode->nodeName === self::XML_NODE_A || $childNode->nodeName === self::XML_NODE_SPAN) {
|
||||
$currentPValue .= $childNode->nodeValue;
|
||||
}
|
||||
}
|
||||
|
||||
$pNodeValues[] = $currentPValue;
|
||||
}
|
||||
|
||||
$escapedCellValue = implode("\n", $pNodeValues);
|
||||
$cellValue = $this->escaper->unescape($escapedCellValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Numeric value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return int|float The value associated with the cell
|
||||
*/
|
||||
protected function formatFloatCellValue($node)
|
||||
{
|
||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
|
||||
$nodeIntValue = intval($nodeValue);
|
||||
// The "==" is intentionally not a "===" because only the value matters, not the type
|
||||
$cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Boolean value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return bool The value associated with the cell
|
||||
*/
|
||||
protected function formatBooleanCellValue($node)
|
||||
{
|
||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_BOOLEAN_VALUE);
|
||||
// !! is similar to boolval()
|
||||
$cellValue = !!$nodeValue;
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Date value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||
*/
|
||||
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 {
|
||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
|
||||
return new \DateTime($nodeValue);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Time value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
|
||||
*/
|
||||
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 {
|
||||
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
|
||||
return new \DateInterval($nodeValue);
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Currency value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string The value associated with the cell (e.g. "100 USD" or "9.99 EUR")
|
||||
*/
|
||||
protected function formatCurrencyCellValue($node)
|
||||
{
|
||||
$value = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
|
||||
$currency = $node->getAttribute(self::XML_ATTRIBUTE_CURRENCY);
|
||||
|
||||
return "$value $currency";
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Percentage value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return int|float The value associated with the cell
|
||||
*/
|
||||
protected function formatPercentageCellValue($node)
|
||||
{
|
||||
// percentages are formatted like floats
|
||||
return $this->formatFloatCellValue($node);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS\Helper;
|
||||
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
* Class SettingsHelper
|
||||
* This class provides helper functions to extract data from the "settings.xml" file.
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS\Helper
|
||||
*/
|
||||
class SettingsHelper
|
||||
{
|
||||
const SETTINGS_XML_FILE_PATH = 'settings.xml';
|
||||
|
||||
/** Definition of XML nodes name and attribute used to parse settings data */
|
||||
const XML_NODE_CONFIG_ITEM = 'config:config-item';
|
||||
const XML_ATTRIBUTE_CONFIG_NAME = 'config:name';
|
||||
const XML_ATTRIBUTE_VALUE_ACTIVE_TABLE = 'ActiveTable';
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return string|null Name of the sheet that was defined as active or NULL if none found
|
||||
*/
|
||||
public function getActiveSheetName($filePath)
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($filePath, self::SETTINGS_XML_FILE_PATH) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeSheetName = null;
|
||||
|
||||
try {
|
||||
while ($xmlReader->readUntilNodeFound(self::XML_NODE_CONFIG_ITEM)) {
|
||||
if ($xmlReader->getAttribute(self::XML_ATTRIBUTE_CONFIG_NAME) === self::XML_ATTRIBUTE_VALUE_ACTIVE_TABLE) {
|
||||
$activeSheetName = $xmlReader->readString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (XMLProcessingException $exception) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $activeSheetName;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\AbstractReader;
|
||||
|
||||
/**
|
||||
* Class Reader
|
||||
* This class provides support to read data from a ODS file
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class Reader extends AbstractReader
|
||||
{
|
||||
/** @var \ZipArchive */
|
||||
protected $zip;
|
||||
|
||||
/** @var SheetIterator To iterator over the ODS sheets */
|
||||
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.
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
|
||||
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
||||
*/
|
||||
protected function openReader($filePath)
|
||||
{
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions());
|
||||
} else {
|
||||
throw new IOException("Could not open $filePath for reading.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeReader()
|
||||
{
|
||||
if ($this->zip) {
|
||||
$this->zip->close();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,352 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\IteratorNotRewindableException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\Common\XMLProcessor;
|
||||
|
||||
/**
|
||||
* Class RowIterator
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class RowIterator implements IteratorInterface
|
||||
{
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_TABLE = 'table:table';
|
||||
const XML_NODE_ROW = 'table:table-row';
|
||||
const XML_NODE_CELL = 'table:table-cell';
|
||||
const MAX_COLUMNS_EXCEL = 16384;
|
||||
|
||||
/** 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';
|
||||
|
||||
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||
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 */
|
||||
protected $cellValueFormatter;
|
||||
|
||||
/** @var bool Whether the iterator has already been rewound once */
|
||||
protected $hasAlreadyBeenRewound = false;
|
||||
|
||||
/** @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 */
|
||||
protected $rowDataBuffer = null;
|
||||
|
||||
/** @var bool Indicates whether all rows have been read */
|
||||
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 \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
*/
|
||||
public function __construct($xmlReader, $options)
|
||||
{
|
||||
$this->xmlReader = $xmlReader;
|
||||
$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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element.
|
||||
* NOTE: It can only be done once, as it is not possible to read an XML file backwards.
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Reader\Exception\IteratorNotRewindableException If the iterator is rewound more than once
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
// Because sheet and row data is located in the file, we can't rewind both the
|
||||
// sheet iterator and the row iterator, as XML file cannot be read backwards.
|
||||
// Therefore, rewinding the row iterator has been disabled.
|
||||
if ($this->hasAlreadyBeenRewound) {
|
||||
throw new IteratorNotRewindableException();
|
||||
}
|
||||
|
||||
$this->hasAlreadyBeenRewound = true;
|
||||
$this->lastRowIndexProcessed = 0;
|
||||
$this->nextRowIndexToBeProcessed = 1;
|
||||
$this->rowDataBuffer = null;
|
||||
$this->hasReachedEndOfFile = false;
|
||||
|
||||
$this->next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return (!$this->hasReachedEndOfFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element. Empty rows will be skipped.
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
if ($this->doesNeedDataForNextRowToBeProcessed()) {
|
||||
$this->readDataForNextRow();
|
||||
}
|
||||
|
||||
$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 {
|
||||
$this->xmlProcessor->readUntilStopped();
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$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);
|
||||
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$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
|
||||
*/
|
||||
protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
|
||||
{
|
||||
$numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
|
||||
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
|
||||
*/
|
||||
protected function getCellValue($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.
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->rowDataBuffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->lastRowIndexProcessed;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
use Box\Spout\Reader\SheetInterface;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* Represents a sheet within a ODS file
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class Sheet implements SheetInterface
|
||||
{
|
||||
/** @var \Box\Spout\Reader\ODS\RowIterator To iterate over sheet's rows */
|
||||
protected $rowIterator;
|
||||
|
||||
/** @var int ID of the sheet */
|
||||
protected $id;
|
||||
|
||||
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||
protected $index;
|
||||
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/** @var bool Whether the sheet was the active one */
|
||||
protected $isActive;
|
||||
|
||||
/**
|
||||
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
||||
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param string $sheetName Name of the sheet
|
||||
* @param bool $isSheetActive Whether the sheet was defined as active
|
||||
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
*/
|
||||
public function __construct($xmlReader, $sheetIndex, $sheetName, $isSheetActive, $options)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($xmlReader, $options);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
$this->isActive = $isSheetActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return \Box\Spout\Reader\ODS\RowIterator
|
||||
*/
|
||||
public function getRowIterator()
|
||||
{
|
||||
return $this->rowIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return string Name of the sheet
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Whether the sheet was defined as active
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\ODS\Helper\SettingsHelper;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
* Class SheetIterator
|
||||
* Iterate over ODS sheet.
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class SheetIterator implements IteratorInterface
|
||||
{
|
||||
const CONTENT_XML_FILE_PATH = 'content.xml';
|
||||
|
||||
/** Definition of XML nodes name and attribute used to parse sheet data */
|
||||
const XML_NODE_TABLE = 'table:table';
|
||||
const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
|
||||
|
||||
/** @var string $filePath Path of the file to be read */
|
||||
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 */
|
||||
protected $xmlReader;
|
||||
|
||||
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
|
||||
protected $escaper;
|
||||
|
||||
/** @var bool Whether there are still at least a sheet to be read */
|
||||
protected $hasFoundSheet;
|
||||
|
||||
/** @var int The index of the sheet being read (zero-based) */
|
||||
protected $currentSheetIndex;
|
||||
|
||||
/** @var string The name of the sheet that was defined as active */
|
||||
protected $activeSheetName;
|
||||
|
||||
/**
|
||||
* @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
|
||||
*/
|
||||
public function __construct($filePath, $options)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->options = $options;
|
||||
$this->xmlReader = new XMLReader();
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
|
||||
$settingsHelper = new SettingsHelper();
|
||||
$this->activeSheetName = $settingsHelper->getActiveSheetName($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the XML file containing sheets' data
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
|
||||
if ($this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH) === false) {
|
||||
$contentXmlFilePath = $this->filePath . '#' . self::CONTENT_XML_FILE_PATH;
|
||||
throw new IOException("Could not open \"{$contentXmlFilePath}\".");
|
||||
}
|
||||
|
||||
try {
|
||||
$this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The content.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$this->currentSheetIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return $this->hasFoundSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
|
||||
|
||||
if ($this->hasFoundSheet) {
|
||||
$this->currentSheetIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return \Box\Spout\Reader\ODS\Sheet
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
|
||||
$sheetName = $this->escaper->unescape($escapedSheetName);
|
||||
$isActiveSheet = $this->isActiveSheet($sheetName, $this->currentSheetIndex, $this->activeSheetName);
|
||||
|
||||
return new Sheet($this->xmlReader, $this->currentSheetIndex, $sheetName, $isActiveSheet, $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current sheet was defined as the active one
|
||||
*
|
||||
* @param string $sheetName Name of the current sheet
|
||||
* @param int $sheetIndex Index of the current sheet
|
||||
* @param string|null Name of the sheet that was defined as active or NULL if none defined
|
||||
* @return bool Whether the current sheet was defined as the active one
|
||||
*/
|
||||
private function isActiveSheet($sheetName, $sheetIndex, $activeSheetName)
|
||||
{
|
||||
// The given sheet is active if its name matches the defined active sheet's name
|
||||
// or if no information about the active sheet was found, it defaults to the first sheet.
|
||||
return (
|
||||
($activeSheetName === null && $sheetIndex === 0) ||
|
||||
($activeSheetName === $sheetName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->currentSheetIndex + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
}
|
||||
}
|
||||
@@ -18,8 +18,9 @@ class ReaderFactory
|
||||
/**
|
||||
* This creates an instance of the appropriate reader, given the type of the file to be read
|
||||
*
|
||||
* @api
|
||||
* @param string $readerType Type of the reader to instantiate
|
||||
* @return \Box\Spout\Reader\CSV|\Box\Spout\Reader\XLSX
|
||||
* @return ReaderInterface
|
||||
* @throws \Box\Spout\Common\Exception\UnsupportedTypeException
|
||||
*/
|
||||
public static function create($readerType)
|
||||
@@ -28,10 +29,13 @@ class ReaderFactory
|
||||
|
||||
switch ($readerType) {
|
||||
case Type::CSV:
|
||||
$reader = new CSV();
|
||||
$reader = new CSV\Reader();
|
||||
break;
|
||||
case Type::XLSX:
|
||||
$reader = new XLSX();
|
||||
$reader = new XLSX\Reader();
|
||||
break;
|
||||
case Type::ODS:
|
||||
$reader = new ODS\Reader();
|
||||
break;
|
||||
default:
|
||||
throw new UnsupportedTypeException('No readers supporting the given type: ' . $readerType);
|
||||
|
||||
@@ -20,26 +20,12 @@ interface ReaderInterface
|
||||
public function open($filePath);
|
||||
|
||||
/**
|
||||
* Returns whether all rows have been read (i.e. if we are at the end of the file).
|
||||
* To know if the end of file has been reached, it uses a buffer. If the buffer is
|
||||
* empty (meaning, nothing has been read or previous read line has been consumed), then
|
||||
* it reads the next line, store it in the buffer for the next time or flip a variable if
|
||||
* the end of file has been reached.
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return bool
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the stream was not opened first
|
||||
* @return \Iterator To iterate over sheets
|
||||
* @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If called before opening the reader
|
||||
*/
|
||||
public function hasNextRow();
|
||||
|
||||
/**
|
||||
* Returns next row if available. The row is either retrieved from the buffer if it is not empty or fetched by
|
||||
* actually reading the file.
|
||||
*
|
||||
* @return array Array that contains the data for the read row
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the stream was not opened first
|
||||
* @throws \Box\Spout\Reader\Exception\EndOfFileReachedException
|
||||
*/
|
||||
public function nextRow();
|
||||
public function getSheetIterator();
|
||||
|
||||
/**
|
||||
* Closes the reader, preventing any additional reading
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* Represents a worksheet within a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Reader
|
||||
*/
|
||||
class Sheet
|
||||
{
|
||||
/** @var int ID of the sheet */
|
||||
protected $id;
|
||||
|
||||
/** @var int Index of the sheet, based on order of creation (zero-based) */
|
||||
protected $index;
|
||||
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/**
|
||||
* @param int $sheetId ID of the sheet
|
||||
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
|
||||
* @param string $sheetName Name of the sheet
|
||||
*/
|
||||
function __construct($sheetId, $sheetIndex, $sheetName)
|
||||
{
|
||||
$this->id = $sheetId;
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int ID of the sheet
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
return $this->id;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Index of the sheet, based on order of creation (zero-based)
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Name of the sheet
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader;
|
||||
|
||||
/**
|
||||
* Interface SheetInterface
|
||||
*
|
||||
* @package Box\Spout\Reader
|
||||
*/
|
||||
interface SheetInterface
|
||||
{
|
||||
/**
|
||||
* Returns an iterator to iterate over the sheet's rows.
|
||||
*
|
||||
* @return \Iterator
|
||||
*/
|
||||
public function getRowIterator();
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Wrapper;
|
||||
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
|
||||
/**
|
||||
* Trait XMLInternalErrorsHelper
|
||||
*
|
||||
* @package Box\Spout\Reader\Wrapper
|
||||
*/
|
||||
trait XMLInternalErrorsHelper
|
||||
{
|
||||
/** @var bool Stores whether XML errors were initially stored internally - used to reset */
|
||||
protected $initialUseInternalErrorsValue;
|
||||
|
||||
/**
|
||||
* To avoid displaying lots of warning/error messages on screen,
|
||||
* stores errors internally instead.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function useXMLInternalErrors()
|
||||
{
|
||||
libxml_clear_errors();
|
||||
$this->initialUseInternalErrorsValue = libxml_use_internal_errors(true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an XMLProcessingException if an error occured.
|
||||
* It also always resets the "libxml_use_internal_errors" setting back to its initial value.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
|
||||
*/
|
||||
protected function resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured()
|
||||
{
|
||||
if ($this->hasXMLErrorOccured()) {
|
||||
$this->resetXMLInternalErrorsSetting();
|
||||
throw new XMLProcessingException($this->getLastXMLErrorMessage());
|
||||
}
|
||||
|
||||
$this->resetXMLInternalErrorsSetting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the a XML error has occured since the last time errors were cleared.
|
||||
*
|
||||
* @return bool TRUE if an error occured, FALSE otherwise
|
||||
*/
|
||||
private function hasXMLErrorOccured()
|
||||
{
|
||||
return (libxml_get_last_error() !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the error message for the last XML error that occured.
|
||||
* @see libxml_get_last_error
|
||||
*
|
||||
* @return String|null Last XML error message or null if no error
|
||||
*/
|
||||
private function getLastXMLErrorMessage()
|
||||
{
|
||||
$errorMessage = null;
|
||||
$error = libxml_get_last_error();
|
||||
|
||||
if ($error !== false) {
|
||||
$errorMessage = trim($error->message);
|
||||
}
|
||||
|
||||
return $errorMessage;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function resetXMLInternalErrorsSetting()
|
||||
{
|
||||
libxml_use_internal_errors($this->initialUseInternalErrorsValue);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Wrapper;
|
||||
use DOMNode;
|
||||
|
||||
|
||||
/**
|
||||
* Class XMLReader
|
||||
* Wrapper around the built-in XMLReader
|
||||
* @see \XMLReader
|
||||
*
|
||||
* @package Box\Spout\Reader\Wrapper
|
||||
*/
|
||||
class XMLReader extends \XMLReader
|
||||
{
|
||||
use XMLInternalErrorsHelper;
|
||||
|
||||
const ZIP_WRAPPER = 'zip://';
|
||||
|
||||
/**
|
||||
* Opens the XML Reader to read a file located inside a ZIP file.
|
||||
*
|
||||
* @param string $zipFilePath Path to the ZIP file
|
||||
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
|
||||
* @return bool TRUE on success or FALSE on failure
|
||||
*/
|
||||
public function openFileInZip($zipFilePath, $fileInsideZipPath)
|
||||
{
|
||||
$wasOpenSuccessful = false;
|
||||
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
|
||||
|
||||
// We need to check first that the file we are trying to read really exist because:
|
||||
// - PHP emits a warning when trying to open a file that does not exist.
|
||||
// - HHVM does not check if file exists within zip file (@link https://github.com/facebook/hhvm/issues/5779)
|
||||
if ($this->fileExistsWithinZip($realPathURI)) {
|
||||
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||
}
|
||||
|
||||
return $wasOpenSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real path for the given path components.
|
||||
* This is useful to avoid issues on some Windows setup.
|
||||
*
|
||||
* @param string $zipFilePath Path to the ZIP file
|
||||
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
|
||||
* @return string The real path URI
|
||||
*/
|
||||
public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
|
||||
{
|
||||
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the file at the given location exists
|
||||
*
|
||||
* @param string $zipStreamURI URI of a zip stream, e.g. "zip://file.zip#path/inside.xml"
|
||||
* @return bool TRUE if the file exists, FALSE otherwise
|
||||
*/
|
||||
protected function fileExistsWithinZip($zipStreamURI)
|
||||
{
|
||||
$doesFileExists = false;
|
||||
|
||||
$pattern = '/zip:\/\/([^#]+)#(.*)/';
|
||||
if (preg_match($pattern, $zipStreamURI, $matches)) {
|
||||
$zipFilePath = $matches[1];
|
||||
$innerFilePath = $matches[2];
|
||||
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($zipFilePath) === true) {
|
||||
$doesFileExists = ($zip->locateName($innerFilePath) !== false);
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
return $doesFileExists;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move to next node in document
|
||||
* @see \XMLReader::read
|
||||
*
|
||||
* @return bool TRUE on success or FALSE on failure
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
|
||||
*/
|
||||
public function read()
|
||||
{
|
||||
$this->useXMLInternalErrors();
|
||||
|
||||
$wasReadSuccessful = parent::read();
|
||||
|
||||
$this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
|
||||
|
||||
return $wasReadSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Read until the element with the given name is found, or the end of the file.
|
||||
*
|
||||
* @param string $nodeName Name of the node to find
|
||||
* @return bool TRUE on success or FALSE on failure
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
|
||||
*/
|
||||
public function readUntilNodeFound($nodeName)
|
||||
{
|
||||
do {
|
||||
$wasReadSuccessful = $this->read();
|
||||
$isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
|
||||
} while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
|
||||
|
||||
return $wasReadSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Move cursor to next node skipping all subtrees
|
||||
* @see \XMLReader::next
|
||||
*
|
||||
* @param string|void $localName The name of the next node to move to
|
||||
* @return bool TRUE on success or FALSE on failure
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
|
||||
*/
|
||||
public function next($localName = null)
|
||||
{
|
||||
$this->useXMLInternalErrors();
|
||||
|
||||
$wasNextSuccessful = parent::next($localName);
|
||||
|
||||
$this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
|
||||
|
||||
return $wasNextSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeName
|
||||
* @return bool Whether the XML Reader is currently positioned on the starting node with given name
|
||||
*/
|
||||
public function isPositionedOnStartingNode($nodeName)
|
||||
{
|
||||
return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeName
|
||||
* @return bool Whether the XML Reader is currently positioned on the ending node with given name
|
||||
*/
|
||||
public function isPositionedOnEndingNode($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);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The name of the current node, un-prefixed
|
||||
*/
|
||||
public function getCurrentNodeName()
|
||||
{
|
||||
return $this->localName;
|
||||
}
|
||||
}
|
||||
@@ -1,258 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader;
|
||||
|
||||
use Box\Spout\Common\Exception\BadUsageException;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\EndOfWorksheetsReachedException;
|
||||
use Box\Spout\Reader\Exception\NoWorksheetsFoundException;
|
||||
use Box\Spout\Reader\Exception\ReaderNotOpenedException;
|
||||
use Box\Spout\Reader\Helper\XLSX\CellHelper;
|
||||
use Box\Spout\Reader\Helper\XLSX\SharedStringsHelper;
|
||||
use Box\Spout\Reader\Helper\XLSX\WorksheetHelper;
|
||||
|
||||
/**
|
||||
* Class XLSX
|
||||
* This class provides support to read data from a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Reader
|
||||
*/
|
||||
class XLSX extends AbstractReader
|
||||
{
|
||||
/** @var string Real path of the file to read */
|
||||
protected $filePath;
|
||||
|
||||
/** @var string Temporary folder where the temporary files will be created */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var \ZipArchive */
|
||||
protected $zip;
|
||||
|
||||
/** @var Helper\XLSX\SharedStringsHelper Helper to work with shared strings */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var Helper\XLSX\WorksheetHelper Helper to work with worksheets */
|
||||
protected $worksheetHelper;
|
||||
|
||||
/** @var Internal\XLSX\Worksheet[] The list of worksheets present in the file */
|
||||
protected $worksheets;
|
||||
|
||||
/** @var Internal\XLSX\Worksheet The worksheet being read */
|
||||
protected $currentWorksheet;
|
||||
|
||||
/** @var \XMLReader The XMLReader object that will help read sheets XML data */
|
||||
protected $xmlReader;
|
||||
|
||||
/** @var int The number of columns the worksheet has (0 meaning undefined) */
|
||||
protected $numberOfColumns = 0;
|
||||
|
||||
/**
|
||||
* @param string $tempFolder Temporary folder where the temporary files will be created
|
||||
* @return XLSX
|
||||
*/
|
||||
public function setTempFolder($tempFolder)
|
||||
{
|
||||
$this->tempFolder = $tempFolder;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* and fetches all the available worksheets.
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
|
||||
* @throws Exception\NoWorksheetsFoundException If there are no worksheets in the file
|
||||
*/
|
||||
protected function openReader($filePath)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
|
||||
|
||||
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
||||
// Extracts all the strings from the worksheets for easy access in the future
|
||||
$this->sharedStringsHelper->extractSharedStrings();
|
||||
}
|
||||
|
||||
// Fetch all available worksheets
|
||||
$this->worksheetHelper = new WorksheetHelper($filePath, $this->globalFunctionsHelper);
|
||||
$this->worksheets = $this->worksheetHelper->getWorksheets($filePath);
|
||||
|
||||
if (count($this->worksheets) === 0) {
|
||||
throw new NoWorksheetsFoundException('The file must contain at least one worksheet.');
|
||||
}
|
||||
} else {
|
||||
throw new IOException('Could not open ' . $filePath . ' for reading.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether another worksheet exists after the current worksheet.
|
||||
*
|
||||
* @return bool Whether another worksheet exists after the current worksheet.
|
||||
* @throws Exception\ReaderNotOpenedException If the stream was not opened first
|
||||
*/
|
||||
public function hasNextSheet()
|
||||
{
|
||||
if (!$this->isStreamOpened) {
|
||||
throw new ReaderNotOpenedException('Stream should be opened first.');
|
||||
}
|
||||
|
||||
return $this->worksheetHelper->hasNextWorksheet($this->currentWorksheet, $this->worksheets);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the pointer to the current worksheet.
|
||||
* Moving to another worksheet will stop the reading in the current worksheet.
|
||||
*
|
||||
* @return \Box\Spout\Reader\Sheet The next sheet
|
||||
* @throws Exception\ReaderNotOpenedException If the stream was not opened first
|
||||
* @throws Exception\EndOfWorksheetsReachedException If there is no more worksheets to read
|
||||
*/
|
||||
public function nextSheet()
|
||||
{
|
||||
if (!$this->hasNextSheet()) {
|
||||
throw new EndOfWorksheetsReachedException('End of worksheets was reached. Cannot read more worksheets.');
|
||||
}
|
||||
|
||||
if ($this->currentWorksheet === null) {
|
||||
$nextWorksheet = $this->worksheets[0];
|
||||
} else {
|
||||
$currentWorksheetIndex = $this->currentWorksheet->getWorksheetIndex();
|
||||
$nextWorksheet = $this->worksheets[$currentWorksheetIndex + 1];
|
||||
}
|
||||
|
||||
$this->initXmlReaderForWorksheetData($nextWorksheet);
|
||||
$this->currentWorksheet = $nextWorksheet;
|
||||
|
||||
// make sure that we are ready to read more rows
|
||||
$this->hasReachedEndOfFile = false;
|
||||
$this->emptyRowDataBuffer();
|
||||
|
||||
return $this->currentWorksheet->getExternalSheet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the XMLReader object that reads worksheet data for the given worksheet.
|
||||
* If another worksheet was being read, it closes the reader before reopening it for the new worksheet.
|
||||
* The XMLReader is configured to be safe from billion laughs attack.
|
||||
*
|
||||
* @param Internal\XLSX\Worksheet $worksheet The worksheet to initialize the XMLReader with
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the worksheet data XML cannot be read
|
||||
*/
|
||||
protected function initXmlReaderForWorksheetData($worksheet)
|
||||
{
|
||||
// if changing worksheet and the XMLReader was initialized for the current worksheet
|
||||
if ($worksheet != $this->currentWorksheet && $this->xmlReader) {
|
||||
$this->xmlReader->close();
|
||||
} else if (!$this->xmlReader) {
|
||||
$this->xmlReader = new \XMLReader();
|
||||
}
|
||||
|
||||
$worksheetDataXMLFilePath = $worksheet->getDataXmlFilePath();
|
||||
|
||||
$worksheetDataFilePath = 'zip://' . $this->filePath . '#' . $worksheetDataXMLFilePath;
|
||||
if ($this->xmlReader->open($worksheetDataFilePath, null, LIBXML_NONET) === false) {
|
||||
throw new IOException('Could not open "' . $worksheetDataXMLFilePath . '".');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and returns data of the line that comes after the last read line, on the current worksheet.
|
||||
* Empty rows will be skipped.
|
||||
*
|
||||
* @return array|null Array that contains the data for the read line or null at the end of the file
|
||||
* @throws \Box\Spout\Common\Exception\BadUsageException If the pointer to the current worksheet has not been set
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
|
||||
*/
|
||||
protected function read()
|
||||
{
|
||||
if (!$this->currentWorksheet) {
|
||||
throw new BadUsageException('You must call nextSheet() before calling hasNextRow() or nextRow()');
|
||||
}
|
||||
|
||||
$isInsideRowTag = false;
|
||||
$rowData = [];
|
||||
|
||||
while ($this->xmlReader->read()) {
|
||||
if ($this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'dimension') {
|
||||
// Read dimensions of the worksheet
|
||||
$dimensionRef = $this->xmlReader->getAttribute('ref'); // returns 'A1:M13' for instance (or 'A1' for empty sheet)
|
||||
if (preg_match('/[A-Z\d]+:([A-Z\d]+)/', $dimensionRef, $matches)) {
|
||||
$lastCellIndex = $matches[1];
|
||||
$this->numberOfColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
|
||||
}
|
||||
} else if ($this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'row') {
|
||||
// Start of the row description
|
||||
$isInsideRowTag = true;
|
||||
|
||||
// Read spans info if present
|
||||
$numberOfColumnsForRow = $this->numberOfColumns;
|
||||
$spans = $this->xmlReader->getAttribute('spans'); // returns '1:5' for instance
|
||||
if ($spans) {
|
||||
list(, $numberOfColumnsForRow) = explode(':', $spans);
|
||||
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
||||
}
|
||||
$rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
||||
} else if ($isInsideRowTag && $this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'c') {
|
||||
// Start of a cell description
|
||||
$currentCellIndex = $this->xmlReader->getAttribute('r');
|
||||
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
|
||||
$node = $this->xmlReader->expand();
|
||||
|
||||
$hasInlineString = ($this->xmlReader->getAttribute('t') === 'inlineStr');
|
||||
$hasSharedString = ($this->xmlReader->getAttribute('t') === 's');
|
||||
|
||||
if ($hasInlineString) {
|
||||
// inline strings are formatted this way:
|
||||
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
|
||||
$tNode = $node->getElementsByTagName('t')->item(0);
|
||||
$rowData[$currentColumnIndex] = trim($tNode->nodeValue);
|
||||
} else if ($hasSharedString) {
|
||||
// shared strings are formatted this way:
|
||||
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
|
||||
$vNode = $node->getElementsByTagName('v')->item(0);
|
||||
$sharedStringIndex = intval($vNode->nodeValue);
|
||||
$rowData[$currentColumnIndex] = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
|
||||
} else {
|
||||
// other values are formatted this way:
|
||||
// <c r="A1"><v>[VALUE]</v></c>
|
||||
$vNode = $node->getElementsByTagName('v')->item(0);
|
||||
$rowData[$currentColumnIndex] = intval($vNode->nodeValue);
|
||||
}
|
||||
} else if ($this->xmlReader->nodeType == \XMLReader::END_ELEMENT && $this->xmlReader->name === 'row') {
|
||||
// End of the row description
|
||||
// If needed, we fill the empty cells
|
||||
$rowData = ($this->numberOfColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// no data means "end of file"
|
||||
return ($rowData !== []) ? $rowData : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeReader()
|
||||
{
|
||||
if ($this->xmlReader) {
|
||||
$this->xmlReader->close();
|
||||
}
|
||||
|
||||
if ($this->zip) {
|
||||
$this->zip->close();
|
||||
}
|
||||
|
||||
$this->sharedStringsHelper->cleanup();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Common\Exception\InvalidArgumentException;
|
||||
|
||||
/**
|
||||
* Class CellHelper
|
||||
* This class provides helper functions when working with cells
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper
|
||||
*/
|
||||
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.
|
||||
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
|
||||
* Calling fillMissingArrayIndexes($dataArray, 'FILL') will return this array: ['FILL', 1, 'FILL', 3]
|
||||
*
|
||||
* @param array $dataArray The array to fill
|
||||
* @param string|void $fillValue optional
|
||||
* @return array
|
||||
*/
|
||||
public static function fillMissingArrayIndexes($dataArray, $fillValue = '')
|
||||
{
|
||||
if (empty($dataArray)) {
|
||||
return [];
|
||||
}
|
||||
$existingIndexes = array_keys($dataArray);
|
||||
|
||||
$newIndexes = array_fill_keys(range(0, max($existingIndexes)), $fillValue);
|
||||
$dataArray += $newIndexes;
|
||||
|
||||
ksort($dataArray);
|
||||
|
||||
return $dataArray;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the base 10 column index associated to the cell index (base 26).
|
||||
* Excel uses A to Z letters for column indexing, where A is the 1st column,
|
||||
* Z is the 26th and AA is the 27th.
|
||||
* The mapping is zero based, so that A1 maps to 0, B2 maps to 1, Z13 to 25 and AA4 to 26.
|
||||
*
|
||||
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
|
||||
* @return int
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
|
||||
*/
|
||||
public static function getColumnIndexFromCellIndex($cellIndex)
|
||||
{
|
||||
if (!self::isValidCellIndex($cellIndex)) {
|
||||
throw new InvalidArgumentException('Cannot get column index from an invalid cell index.');
|
||||
}
|
||||
|
||||
$columnIndex = 0;
|
||||
|
||||
// Remove row information
|
||||
$columnLetters = preg_replace('/\d/', '', $cellIndex);
|
||||
|
||||
// strlen() is super slow too... Using isset() is way faster and not too unreadable,
|
||||
// since we checked before that there are between 1 and 3 letters.
|
||||
$columnLength = isset($columnLetters[1]) ? (isset($columnLetters[2]) ? 3 : 2) : 1;
|
||||
|
||||
// Looping over the different letters of the column is slower than this method.
|
||||
// Also, not using the pow() function because it's slooooow...
|
||||
switch ($columnLength) {
|
||||
case 1:
|
||||
$columnIndex = (self::$columnLetterToIndexMapping[$columnLetters]);
|
||||
break;
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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', ...)
|
||||
* @return bool
|
||||
*/
|
||||
protected static function isValidCellIndex($cellIndex)
|
||||
{
|
||||
return (preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex) === 1);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
/**
|
||||
* Class CellValueFormatter
|
||||
* This class provides helper functions to format cell values
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper
|
||||
*/
|
||||
class CellValueFormatter
|
||||
{
|
||||
/** Definition of all possible cell types */
|
||||
const CELL_TYPE_INLINE_STRING = 'inlineStr';
|
||||
const CELL_TYPE_STR = 'str';
|
||||
const CELL_TYPE_SHARED_STRING = 's';
|
||||
const CELL_TYPE_BOOLEAN = 'b';
|
||||
const CELL_TYPE_NUMERIC = 'n';
|
||||
const CELL_TYPE_DATE = 'd';
|
||||
const CELL_TYPE_ERROR = 'e';
|
||||
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_VALUE = 'v';
|
||||
const XML_NODE_INLINE_STRING_VALUE = 't';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
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 */
|
||||
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 */
|
||||
protected $escaper;
|
||||
|
||||
/**
|
||||
* @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, $styleHelper, $shouldFormatDates)
|
||||
{
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->styleHelper = $styleHelper;
|
||||
$this->shouldFormatDates = $shouldFormatDates;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string|int|float|bool|\DateTime|null The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
public function extractAndFormatNodeValue($node)
|
||||
{
|
||||
// Default cell type is "n"
|
||||
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
|
||||
$cellStyleId = intval($node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID));
|
||||
$vNodeValue = $this->getVNodeValue($node);
|
||||
|
||||
if (($vNodeValue === '') && ($cellType !== self::CELL_TYPE_INLINE_STRING)) {
|
||||
return $vNodeValue;
|
||||
}
|
||||
|
||||
switch ($cellType) {
|
||||
case self::CELL_TYPE_INLINE_STRING:
|
||||
return $this->formatInlineStringCellValue($node);
|
||||
case self::CELL_TYPE_SHARED_STRING:
|
||||
return $this->formatSharedStringCellValue($vNodeValue);
|
||||
case self::CELL_TYPE_STR:
|
||||
return $this->formatStrCellValue($vNodeValue);
|
||||
case self::CELL_TYPE_BOOLEAN:
|
||||
return $this->formatBooleanCellValue($vNodeValue);
|
||||
case self::CELL_TYPE_NUMERIC:
|
||||
return $this->formatNumericCellValue($vNodeValue, $cellStyleId);
|
||||
case self::CELL_TYPE_DATE:
|
||||
return $this->formatDateCellValue($vNodeValue);
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell's string value from a node's nested value node
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string The value associated with the cell
|
||||
*/
|
||||
protected function getVNodeValue($node)
|
||||
{
|
||||
// for cell types having a "v" tag containing the value.
|
||||
// if not, the returned value should be empty string.
|
||||
$vNode = $node->getElementsByTagName(self::XML_NODE_VALUE)->item(0);
|
||||
return ($vNode !== null) ? $vNode->nodeValue : '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell String value where string is inline.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
protected function formatInlineStringCellValue($node)
|
||||
{
|
||||
// inline strings are formatted this way:
|
||||
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
|
||||
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
|
||||
$cellValue = $this->escaper->unescape($tNode->nodeValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell String value from shared-strings file using nodeValue index.
|
||||
*
|
||||
* @param string $nodeValue
|
||||
* @return string The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
protected function formatSharedStringCellValue($nodeValue)
|
||||
{
|
||||
// shared strings are formatted this way:
|
||||
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
|
||||
$sharedStringIndex = intval($nodeValue);
|
||||
$escapedCellValue = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
|
||||
$cellValue = $this->escaper->unescape($escapedCellValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell String value, where string is stored in value node.
|
||||
*
|
||||
* @param string $nodeValue
|
||||
* @return string The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
protected function formatStrCellValue($nodeValue)
|
||||
{
|
||||
$escapedCellValue = trim($nodeValue);
|
||||
$cellValue = $this->escaper->unescape($escapedCellValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 int $cellStyleId 0 being the default style
|
||||
* @return int|float|\DateTime|null The value associated with the cell
|
||||
*/
|
||||
protected function formatNumericCellValue($nodeValue, $cellStyleId)
|
||||
{
|
||||
// Numeric values can represent numbers as well as timestamps.
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Boolean value from a specific node's Value.
|
||||
*
|
||||
* @param string $nodeValue
|
||||
* @return bool The value associated with the cell
|
||||
*/
|
||||
protected function formatBooleanCellValue($nodeValue)
|
||||
{
|
||||
// !! is similar to boolval()
|
||||
$cellValue = !!$nodeValue;
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
|
||||
* @see ECMA-376 Part 1 - §18.17.4
|
||||
*
|
||||
* @param string $nodeValue ISO 8601 Date string
|
||||
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||
*/
|
||||
protected function formatDateCellValue($nodeValue)
|
||||
{
|
||||
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
|
||||
try {
|
||||
return ($this->shouldFormatDates) ? $nodeValue : new \DateTime($nodeValue);
|
||||
} catch (\Exception $e) {
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
|
||||
|
||||
/**
|
||||
* Class CachingStrategyFactory
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
|
||||
*/
|
||||
class CachingStrategyFactory
|
||||
{
|
||||
/**
|
||||
* The memory amount needed to store a string was obtained empirically from this data:
|
||||
*
|
||||
* ------------------------------------
|
||||
* | Number of chars⁺ | Memory needed |
|
||||
* ------------------------------------
|
||||
* | 3,000 | 1 MB |
|
||||
* | 15,000 | 2 MB |
|
||||
* | 30,000 | 5 MB |
|
||||
* | 75,000 | 11 MB |
|
||||
* | 150,000 | 21 MB |
|
||||
* | 300,000 | 43 MB |
|
||||
* | 750,000 | 105 MB |
|
||||
* | 1,500,000 | 210 MB |
|
||||
* | 2,250,000 | 315 MB |
|
||||
* | 3,000,000 | 420 MB |
|
||||
* | 4,500,000 | 630 MB |
|
||||
* ------------------------------------
|
||||
*
|
||||
* ⁺ All characters were 1 byte long
|
||||
*
|
||||
* This gives a linear graph where each 1-byte character requires about 150 bytes to be stored.
|
||||
* Given that some characters can take up to 4 bytes, we need 600 bytes per character to be safe.
|
||||
* Also, there is on average about 20 characters per cell (this is entirely empirical data...).
|
||||
*
|
||||
* This means that in order to store one shared string in memory, the memory amount needed is:
|
||||
* => 20 * 600 ≈ 12KB
|
||||
*/
|
||||
const AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB = 12;
|
||||
|
||||
/**
|
||||
* To avoid running out of memory when extracting a huge number of shared strings, they can be saved to temporary files
|
||||
* instead of in memory. Then, when accessing a string, the corresponding file contents will be loaded in memory
|
||||
* and the string will be quickly retrieved.
|
||||
* The performance bottleneck is not when creating these temporary files, but rather when loading their content.
|
||||
* Because the contents of the last loaded file stays in memory until another file needs to be loaded, it works
|
||||
* best when the indexes of the shared strings are sorted in the sheet data.
|
||||
* 10,000 was chosen because it creates small files that are fast to be loaded in memory.
|
||||
*/
|
||||
const MAX_NUM_STRINGS_PER_TEMP_FILE = 10000;
|
||||
|
||||
/** @var CachingStrategyFactory|null Singleton instance */
|
||||
protected static $instance = null;
|
||||
|
||||
/**
|
||||
* Private constructor for singleton
|
||||
*/
|
||||
private function __construct()
|
||||
{
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the singleton instance of the factory
|
||||
*
|
||||
* @return CachingStrategyFactory
|
||||
*/
|
||||
public static function getInstance()
|
||||
{
|
||||
if (self::$instance === null) {
|
||||
self::$instance = new CachingStrategyFactory();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best caching strategy, given the number of unique shared strings
|
||||
* and the amount of memory available.
|
||||
*
|
||||
* @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
|
||||
* @return CachingStrategyInterface The best caching strategy
|
||||
*/
|
||||
public function getBestCachingStrategy($sharedStringsUniqueCount, $tempFolder = null)
|
||||
{
|
||||
if ($this->isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)) {
|
||||
return new InMemoryStrategy($sharedStringsUniqueCount);
|
||||
} else {
|
||||
return new FileBasedStrategy($tempFolder, self::MAX_NUM_STRINGS_PER_TEMP_FILE);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings
|
||||
* and the amount of memory available.
|
||||
*
|
||||
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||
* @return bool
|
||||
*/
|
||||
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();
|
||||
|
||||
if ($memoryAvailable === -1) {
|
||||
// if cannot get memory limit or if memory limit set as unlimited, don't trust and play safe
|
||||
return ($sharedStringsUniqueCount < self::MAX_NUM_STRINGS_PER_TEMP_FILE);
|
||||
} else {
|
||||
$memoryNeeded = $sharedStringsUniqueCount * self::AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB;
|
||||
return ($memoryAvailable > $memoryNeeded);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the PHP "memory_limit" in Kilobytes
|
||||
*
|
||||
* @return float
|
||||
*/
|
||||
protected function getMemoryLimitInKB()
|
||||
{
|
||||
$memoryLimitFormatted = $this->getMemoryLimitFromIni();
|
||||
$memoryLimitFormatted = strtolower(trim($memoryLimitFormatted));
|
||||
|
||||
// No memory limit
|
||||
if ($memoryLimitFormatted === '-1') {
|
||||
return -1;
|
||||
}
|
||||
|
||||
if (preg_match('/(\d+)([bkmgt])b?/', $memoryLimitFormatted, $matches)) {
|
||||
$amount = intval($matches[1]);
|
||||
$unit = $matches[2];
|
||||
|
||||
switch ($unit) {
|
||||
case 'b': return ($amount / 1024);
|
||||
case 'k': return $amount;
|
||||
case 'm': return ($amount * 1024);
|
||||
case 'g': return ($amount * 1024 * 1024);
|
||||
case 't': return ($amount * 1024 * 1024 * 1024);
|
||||
}
|
||||
}
|
||||
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the formatted "memory_limit" value
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getMemoryLimitFromIni()
|
||||
{
|
||||
return ini_get('memory_limit');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
|
||||
|
||||
/**
|
||||
* Interface CachingStrategyInterface
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
|
||||
*/
|
||||
interface CachingStrategyInterface
|
||||
{
|
||||
/**
|
||||
* Adds the given string to the cache.
|
||||
*
|
||||
* @param string $sharedString The string to be added to the cache
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return void
|
||||
*/
|
||||
public function addStringForIndex($sharedString, $sharedStringIndex);
|
||||
|
||||
/**
|
||||
* Closes the cache after the last shared string was added.
|
||||
* This prevents any additional string from being added to the cache.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function closeCache();
|
||||
|
||||
/**
|
||||
* Returns the string located at the given index from the cache.
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The shared string at the given index
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
|
||||
*/
|
||||
public function getStringAtIndex($sharedStringIndex);
|
||||
|
||||
/**
|
||||
* Destroys the cache, freeing memory and removing any created artifacts
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache();
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
|
||||
|
||||
use Box\Spout\Common\Helper\FileSystemHelper;
|
||||
use Box\Spout\Common\Helper\GlobalFunctionsHelper;
|
||||
use Box\Spout\Reader\Exception\SharedStringNotFoundException;
|
||||
|
||||
/**
|
||||
* Class FileBasedStrategy
|
||||
*
|
||||
* This class implements the file-based caching strategy for shared strings.
|
||||
* Shared strings are stored in small files (with a max number of strings per file).
|
||||
* This strategy is slower than an in-memory strategy but is used to avoid out of memory crashes.
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
|
||||
*/
|
||||
class FileBasedStrategy implements CachingStrategyInterface
|
||||
{
|
||||
/** Value to use to escape the line feed character ("\n") */
|
||||
const ESCAPED_LINE_FEED_CHARACTER = '_x000A_';
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\FileSystemHelper Helper to perform file system operations */
|
||||
protected $fileSystemHelper;
|
||||
|
||||
/** @var string Temporary folder where the temporary files will be created */
|
||||
protected $tempFolder;
|
||||
|
||||
/**
|
||||
* @var int Maximum number of strings that can be stored in one temp file
|
||||
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
|
||||
*/
|
||||
protected $maxNumStringsPerTempFile;
|
||||
|
||||
/** @var resource Pointer to the last temp file a shared string was written to */
|
||||
protected $tempFilePointer;
|
||||
|
||||
/**
|
||||
* @var string Path of the temporary file whose contents is currently stored in memory
|
||||
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
|
||||
*/
|
||||
protected $inMemoryTempFilePath;
|
||||
|
||||
/**
|
||||
* @var array Contents of the temporary file that was last read
|
||||
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
|
||||
*/
|
||||
protected $inMemoryTempFileContents;
|
||||
|
||||
/**
|
||||
* @param string|null $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||
* @param int $maxNumStringsPerTempFile Maximum number of strings that can be stored in one temp file
|
||||
*/
|
||||
public function __construct($tempFolder, $maxNumStringsPerTempFile)
|
||||
{
|
||||
$rootTempFolder = ($tempFolder) ?: sys_get_temp_dir();
|
||||
$this->fileSystemHelper = new FileSystemHelper($rootTempFolder);
|
||||
$this->tempFolder = $this->fileSystemHelper->createFolder($rootTempFolder, uniqid('sharedstrings'));
|
||||
|
||||
$this->maxNumStringsPerTempFile = $maxNumStringsPerTempFile;
|
||||
|
||||
$this->globalFunctionsHelper = new GlobalFunctionsHelper();
|
||||
$this->tempFilePointer = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given string to the cache.
|
||||
*
|
||||
* @param string $sharedString The string to be added to the cache
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return void
|
||||
*/
|
||||
public function addStringForIndex($sharedString, $sharedStringIndex)
|
||||
{
|
||||
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
|
||||
|
||||
if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
|
||||
if ($this->tempFilePointer) {
|
||||
$this->globalFunctionsHelper->fclose($this->tempFilePointer);
|
||||
}
|
||||
$this->tempFilePointer = $this->globalFunctionsHelper->fopen($tempFilePath, 'w');
|
||||
}
|
||||
|
||||
// The shared string retrieval logic expects each cell data to be on one line only
|
||||
// Encoding the line feed character allows to preserve this assumption
|
||||
$lineFeedEncodedSharedString = $this->escapeLineFeed($sharedString);
|
||||
|
||||
$this->globalFunctionsHelper->fwrite($this->tempFilePointer, $lineFeedEncodedSharedString . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path for the temp file that should contain the string for the given index
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The temp file path for the given index
|
||||
*/
|
||||
protected function getSharedStringTempFilePath($sharedStringIndex)
|
||||
{
|
||||
$numTempFile = intval($sharedStringIndex / $this->maxNumStringsPerTempFile);
|
||||
return $this->tempFolder . '/sharedstrings' . $numTempFile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the cache after the last shared string was added.
|
||||
* This prevents any additional string from being added to the cache.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function closeCache()
|
||||
{
|
||||
// close pointer to the last temp file that was written
|
||||
if ($this->tempFilePointer) {
|
||||
$this->globalFunctionsHelper->fclose($this->tempFilePointer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the string located at the given index from the cache.
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The shared string at the given index
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
|
||||
*/
|
||||
public function getStringAtIndex($sharedStringIndex)
|
||||
{
|
||||
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
|
||||
$indexInFile = $sharedStringIndex % $this->maxNumStringsPerTempFile;
|
||||
|
||||
if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
|
||||
throw new SharedStringNotFoundException("Shared string temp file not found: $tempFilePath ; for index: $sharedStringIndex");
|
||||
}
|
||||
|
||||
if ($this->inMemoryTempFilePath !== $tempFilePath) {
|
||||
// free memory
|
||||
unset($this->inMemoryTempFileContents);
|
||||
|
||||
$this->inMemoryTempFileContents = explode(PHP_EOL, $this->globalFunctionsHelper->file_get_contents($tempFilePath));
|
||||
$this->inMemoryTempFilePath = $tempFilePath;
|
||||
}
|
||||
|
||||
$sharedString = null;
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (isset($this->inMemoryTempFileContents[$indexInFile])) {
|
||||
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
|
||||
$sharedString = $this->unescapeLineFeed($escapedSharedString);
|
||||
}
|
||||
|
||||
if ($sharedString === null) {
|
||||
throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex");
|
||||
}
|
||||
|
||||
return rtrim($sharedString, PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* Escapes the line feed characters (\n)
|
||||
*
|
||||
* @param string $unescapedString
|
||||
* @return string
|
||||
*/
|
||||
private function escapeLineFeed($unescapedString)
|
||||
{
|
||||
return str_replace("\n", self::ESCAPED_LINE_FEED_CHARACTER, $unescapedString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Unescapes the line feed characters (\n)
|
||||
*
|
||||
* @param string $escapedString
|
||||
* @return string
|
||||
*/
|
||||
private function unescapeLineFeed($escapedString)
|
||||
{
|
||||
return str_replace(self::ESCAPED_LINE_FEED_CHARACTER, "\n", $escapedString);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the cache, freeing memory and removing any created artifacts
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache()
|
||||
{
|
||||
if ($this->tempFolder) {
|
||||
$this->fileSystemHelper->deleteFolderRecursively($this->tempFolder);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
|
||||
|
||||
use Box\Spout\Reader\Exception\SharedStringNotFoundException;
|
||||
|
||||
/**
|
||||
* Class InMemoryStrategy
|
||||
*
|
||||
* This class implements the in-memory caching strategy for shared strings.
|
||||
* This strategy is used when the number of unique strings is low, compared to the memory available.
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
|
||||
*/
|
||||
class InMemoryStrategy implements CachingStrategyInterface
|
||||
{
|
||||
/** @var \SplFixedArray Array used to cache the shared strings */
|
||||
protected $inMemoryCache;
|
||||
|
||||
/** @var bool Whether the cache has been closed */
|
||||
protected $isCacheClosed;
|
||||
|
||||
/**
|
||||
* @param int $sharedStringsUniqueCount Number of unique shared strings
|
||||
*/
|
||||
public function __construct($sharedStringsUniqueCount)
|
||||
{
|
||||
$this->inMemoryCache = new \SplFixedArray($sharedStringsUniqueCount);
|
||||
$this->isCacheClosed = false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given string to the cache.
|
||||
*
|
||||
* @param string $sharedString The string to be added to the cache
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return void
|
||||
*/
|
||||
public function addStringForIndex($sharedString, $sharedStringIndex)
|
||||
{
|
||||
if (!$this->isCacheClosed) {
|
||||
$this->inMemoryCache->offsetSet($sharedStringIndex, $sharedString);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the cache after the last shared string was added.
|
||||
* This prevents any additional string from being added to the cache.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function closeCache()
|
||||
{
|
||||
$this->isCacheClosed = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the string located at the given index from the cache.
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The shared string at the given index
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
|
||||
*/
|
||||
public function getStringAtIndex($sharedStringIndex)
|
||||
{
|
||||
try {
|
||||
return $this->inMemoryCache->offsetGet($sharedStringIndex);
|
||||
} catch (\RuntimeException $e) {
|
||||
throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the cache, freeing memory and removing any created artifacts
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function clearCache()
|
||||
{
|
||||
unset($this->inMemoryCache);
|
||||
$this->isCacheClosed = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyFactory;
|
||||
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyInterface;
|
||||
|
||||
/**
|
||||
* Class SharedStringsHelper
|
||||
* This class provides helper functions for reading sharedStrings XML file
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper
|
||||
*/
|
||||
class SharedStringsHelper
|
||||
{
|
||||
/** Path of sharedStrings XML file inside the XLSX file */
|
||||
const SHARED_STRINGS_XML_FILE_PATH = 'xl/sharedStrings.xml';
|
||||
|
||||
/** Main namespace for the sharedStrings.xml file */
|
||||
const MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
||||
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_SST = 'sst';
|
||||
const XML_NODE_SI = 'si';
|
||||
const XML_NODE_R = 'r';
|
||||
const XML_NODE_T = 't';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_COUNT = 'count';
|
||||
const XML_ATTRIBUTE_UNIQUE_COUNT = 'uniqueCount';
|
||||
const XML_ATTRIBUTE_XML_SPACE = 'xml:space';
|
||||
const XML_ATTRIBUTE_VALUE_PRESERVE = 'preserve';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
/** @var string Temporary folder where the temporary files to store shared strings will be stored */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var CachingStrategyInterface The best caching strategy for storing shared strings */
|
||||
protected $cachingStrategy;
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the XLSX file being read
|
||||
* @param string|null|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||
*/
|
||||
public function __construct($filePath, $tempFolder = null)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->tempFolder = $tempFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the XLSX file contains a shared strings XML file
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSharedStrings()
|
||||
{
|
||||
$hasSharedStrings = false;
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($this->filePath) === true) {
|
||||
$hasSharedStrings = ($zip->locateName(self::SHARED_STRINGS_XML_FILE_PATH) !== false);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return $hasSharedStrings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an in-memory array containing all the shared strings of the sheet.
|
||||
* All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'.
|
||||
* It is then accessed by the sheet data, via the string index in the built table.
|
||||
*
|
||||
* More documentation available here: http://msdn.microsoft.com/en-us/library/office/gg278314.aspx
|
||||
*
|
||||
* The XML file can be really big with sheets containing a lot of data. That is why
|
||||
* we need to use a XML reader that provides streaming like the XMLReader library.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml can't be read
|
||||
*/
|
||||
public function extractSharedStrings()
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
$sharedStringIndex = 0;
|
||||
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::SHARED_STRINGS_XML_FILE_PATH) === false) {
|
||||
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
|
||||
}
|
||||
|
||||
try {
|
||||
$sharedStringsUniqueCount = $this->getSharedStringsUniqueCount($xmlReader);
|
||||
$this->cachingStrategy = $this->getBestSharedStringsCachingStrategy($sharedStringsUniqueCount);
|
||||
|
||||
$xmlReader->readUntilNodeFound(self::XML_NODE_SI);
|
||||
|
||||
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SI) {
|
||||
$this->processSharedStringsItem($xmlReader, $sharedStringIndex);
|
||||
$sharedStringIndex++;
|
||||
|
||||
// jump to the next '<si>' tag
|
||||
$xmlReader->next(self::XML_NODE_SI);
|
||||
}
|
||||
|
||||
$this->cachingStrategy->closeCache();
|
||||
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared strings unique count, as specified in <sst> tag.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
|
||||
* @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
|
||||
*/
|
||||
protected function getSharedStringsUniqueCount($xmlReader)
|
||||
{
|
||||
$xmlReader->next(self::XML_NODE_SST);
|
||||
|
||||
// Iterate over the "sst" elements to get the actual "sst ELEMENT" (skips any DOCTYPE)
|
||||
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SST && $xmlReader->nodeType !== XMLReader::ELEMENT) {
|
||||
$xmlReader->read();
|
||||
}
|
||||
|
||||
$uniqueCount = $xmlReader->getAttribute(self::XML_ATTRIBUTE_UNIQUE_COUNT);
|
||||
|
||||
// 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(self::XML_ATTRIBUTE_COUNT);
|
||||
}
|
||||
|
||||
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best shared strings caching strategy.
|
||||
*
|
||||
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||
* @return CachingStrategyInterface
|
||||
*/
|
||||
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
|
||||
{
|
||||
return CachingStrategyFactory::getInstance()
|
||||
->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 XML Reader positioned on a "<si>" node
|
||||
* @param int $sharedStringIndex Index of the processed shared strings item
|
||||
* @return void
|
||||
*/
|
||||
protected function processSharedStringsItem($xmlReader, $sharedStringIndex)
|
||||
{
|
||||
$sharedStringValue = '';
|
||||
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$siNode = $xmlReader->expand();
|
||||
$textNodes = $siNode->getElementsByTagName(self::XML_NODE_T);
|
||||
|
||||
foreach ($textNodes as $textNode) {
|
||||
if ($this->shouldExtractTextNodeValue($textNode)) {
|
||||
$textNodeValue = $textNode->nodeValue;
|
||||
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
|
||||
|
||||
$sharedStringValue .= ($shouldPreserveWhitespace) ? $textNodeValue : trim($textNodeValue);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cachingStrategy->addStringForIndex($sharedStringValue, $sharedStringIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Not all text nodes' values must be extracted.
|
||||
* Some text nodes are part of a node describing the pronunciation for instance.
|
||||
* We'll only consider the nodes whose parents are "<si>" or "<r>".
|
||||
*
|
||||
* @param \DOMElement $textNode Text node to check
|
||||
* @return bool Whether the given text node's value must be extracted
|
||||
*/
|
||||
protected function shouldExtractTextNodeValue($textNode)
|
||||
{
|
||||
$parentTagName = $textNode->parentNode->localName;
|
||||
return ($parentTagName === self::XML_NODE_SI || $parentTagName === self::XML_NODE_R);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
||||
*
|
||||
* @param \DOMElement $textNode The text node element (<t>) whose whitespace may be preserved
|
||||
* @return bool Whether whitespace should be preserved
|
||||
*/
|
||||
protected function shouldPreserveWhitespace($textNode)
|
||||
{
|
||||
$spaceValue = $textNode->getAttribute(self::XML_ATTRIBUTE_XML_SPACE);
|
||||
return ($spaceValue === self::XML_ATTRIBUTE_VALUE_PRESERVE);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared string at the given index, using the previously chosen caching strategy.
|
||||
*
|
||||
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
|
||||
* @return string The shared string at the given index
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
|
||||
*/
|
||||
public function getStringAtIndex($sharedStringIndex)
|
||||
{
|
||||
return $this->cachingStrategy->getStringAtIndex($sharedStringIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Destroys the cache, freeing memory and removing any created artifacts
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function cleanup()
|
||||
{
|
||||
if ($this->cachingStrategy) {
|
||||
$this->cachingStrategy->clearCache();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,156 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\XLSX\Sheet;
|
||||
|
||||
/**
|
||||
* Class SheetHelper
|
||||
* This class provides helper functions related to XLSX sheets
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper
|
||||
*/
|
||||
class SheetHelper
|
||||
{
|
||||
/** Paths of XML files relative to the XLSX file root */
|
||||
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
|
||||
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
|
||||
|
||||
/** Definition of XML node names used to parse data */
|
||||
const XML_NODE_WORKBOOK_VIEW = 'workbookView';
|
||||
const XML_NODE_SHEET = 'sheet';
|
||||
const XML_NODE_SHEETS = 'sheets';
|
||||
const XML_NODE_RELATIONSHIP = 'Relationship';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_ACTIVE_TAB = 'activeTab';
|
||||
const XML_ATTRIBUTE_R_ID = 'r:id';
|
||||
const XML_ATTRIBUTE_NAME = 'name';
|
||||
const XML_ATTRIBUTE_ID = 'Id';
|
||||
const XML_ATTRIBUTE_TARGET = 'Target';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
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 */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/**
|
||||
* @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\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->options = $options;
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the sheets metadata of the file located at the previously given file path.
|
||||
* The paths to the sheets' data are read from the [Content_Types].xml file.
|
||||
*
|
||||
* @return Sheet[] Sheets within the XLSX file
|
||||
*/
|
||||
public function getSheets()
|
||||
{
|
||||
$sheets = [];
|
||||
$sheetIndex = 0;
|
||||
$activeSheetIndex = 0; // By default, the first sheet is active
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_WORKBOOK_VIEW)) {
|
||||
// The "workbookView" node is located before "sheet" nodes, ensuring that
|
||||
// the active sheet is known before parsing sheets data.
|
||||
$activeSheetIndex = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_ACTIVE_TAB);
|
||||
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_SHEET)) {
|
||||
$isSheetActive = ($sheetIndex === $activeSheetIndex);
|
||||
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex, $isSheetActive);
|
||||
$sheetIndex++;
|
||||
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_SHEETS)) {
|
||||
// stop reading once all sheets have been read
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an instance of a sheet, given the XML node describing the sheet - from "workbook.xml".
|
||||
* We can find the XML file path describing the sheet inside "workbook.xml.res", by mapping with the sheet ID
|
||||
* ("r:id" in "workbook.xml", "Id" in "workbook.xml.res").
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReaderOnSheetNode XML Reader instance, pointing on the node describing the sheet, as defined in "workbook.xml"
|
||||
* @param int $sheetIndexZeroBased Index of the sheet, based on order of appearance in the workbook (zero-based)
|
||||
* @param bool $isSheetActive Whether this sheet was defined as active
|
||||
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
|
||||
*/
|
||||
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased, $isSheetActive)
|
||||
{
|
||||
$sheetId = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_R_ID);
|
||||
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME);
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
$sheetName = $escaper->unescape($escapedSheetName);
|
||||
|
||||
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
|
||||
|
||||
return new Sheet(
|
||||
$this->filePath, $sheetDataXMLFilePath,
|
||||
$sheetIndexZeroBased, $sheetName, $isSheetActive,
|
||||
$this->options, $this->sharedStringsHelper
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $sheetId The sheet ID, as defined in "workbook.xml"
|
||||
* @return string The XML file path describing the sheet inside "workbook.xml.res", for the given sheet ID
|
||||
*/
|
||||
protected function getSheetDataXMLFilePathForSheetId($sheetId)
|
||||
{
|
||||
$sheetDataXMLFilePath = '';
|
||||
|
||||
// find the file path of the sheet, by looking at the "workbook.xml.res" file
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_RELS_FILE_PATH)) {
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_RELATIONSHIP)) {
|
||||
$relationshipSheetId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ID);
|
||||
|
||||
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(self::XML_ATTRIBUTE_TARGET);
|
||||
|
||||
// sometimes, the sheet data file path already contains "/xl/"...
|
||||
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
|
||||
$sheetDataXMLFilePath = '/xl/' . $sheetDataXMLFilePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
return $sheetDataXMLFilePath;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\AbstractReader;
|
||||
use Box\Spout\Reader\XLSX\Helper\SharedStringsHelper;
|
||||
|
||||
/**
|
||||
* Class Reader
|
||||
* This class provides support to read data from a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX
|
||||
*/
|
||||
class Reader extends AbstractReader
|
||||
{
|
||||
/** @var \ZipArchive */
|
||||
protected $zip;
|
||||
|
||||
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var SheetIterator To iterator over the XLSX sheets */
|
||||
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
|
||||
* @return Reader
|
||||
*/
|
||||
public function setTempFolder($tempFolder)
|
||||
{
|
||||
$this->getOptions()->setTempFolder($tempFolder);
|
||||
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.
|
||||
* It also parses the sharedStrings.xml file to get all the shared strings available in memory
|
||||
* and fetches all the available sheets.
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
|
||||
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
||||
*/
|
||||
protected function openReader($filePath)
|
||||
{
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->getOptions()->getTempFolder());
|
||||
|
||||
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
||||
// Extracts all the strings from the sheets for easy access in the future
|
||||
$this->sharedStringsHelper->extractSharedStrings();
|
||||
}
|
||||
|
||||
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions(), $this->sharedStringsHelper, $this->globalFunctionsHelper);
|
||||
} else {
|
||||
throw new IOException("Could not open $filePath for reading.");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an iterator to iterate over sheets.
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeReader()
|
||||
{
|
||||
if ($this->zip) {
|
||||
$this->zip->close();
|
||||
}
|
||||
|
||||
if ($this->sharedStringsHelper) {
|
||||
$this->sharedStringsHelper->cleanup();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\XLSX\Helper\CellHelper;
|
||||
use Box\Spout\Reader\XLSX\Helper\CellValueFormatter;
|
||||
use Box\Spout\Reader\XLSX\Helper\StyleHelper;
|
||||
use Box\Spout\Reader\Common\XMLProcessor;
|
||||
|
||||
/**
|
||||
* Class RowIterator
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX
|
||||
*/
|
||||
class RowIterator implements IteratorInterface
|
||||
{
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_DIMENSION = 'dimension';
|
||||
const XML_NODE_WORKSHEET = 'worksheet';
|
||||
const XML_NODE_ROW = 'row';
|
||||
const XML_NODE_CELL = 'c';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_REF = 'ref';
|
||||
const XML_ATTRIBUTE_SPANS = 'spans';
|
||||
const XML_ATTRIBUTE_ROW_INDEX = 'r';
|
||||
const XML_ATTRIBUTE_CELL_INDEX = 'r';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
/** @var string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml */
|
||||
protected $sheetDataXMLFilePath;
|
||||
|
||||
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||
protected $xmlReader;
|
||||
|
||||
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
|
||||
protected $xmlProcessor;
|
||||
|
||||
/** @var Helper\CellValueFormatter Helper to format cell values */
|
||||
protected $cellValueFormatter;
|
||||
|
||||
/** @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;
|
||||
|
||||
/** @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 */
|
||||
protected $rowDataBuffer = null;
|
||||
|
||||
/** @var bool Indicates whether all rows have been read */
|
||||
protected $hasReachedEndOfFile = false;
|
||||
|
||||
/** @var int The number of columns the sheet has (0 meaning undefined) */
|
||||
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 $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
|
||||
*/
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
|
||||
|
||||
$this->xmlReader = new XMLReader();
|
||||
|
||||
$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']);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
||||
* @return string Path of the XML file containing the sheet data,
|
||||
* without the leading slash.
|
||||
*/
|
||||
protected function normalizeSheetDataXMLFilePath($sheetDataXMLFilePath)
|
||||
{
|
||||
return ltrim($sheetDataXMLFilePath, '/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element.
|
||||
* Initializes the XMLReader object that reads the associated sheet data.
|
||||
* The XMLReader is configured to be safe from billion laughs attack.
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data XML cannot be read
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
|
||||
if ($this->xmlReader->openFileInZip($this->filePath, $this->sheetDataXMLFilePath) === false) {
|
||||
throw new IOException("Could not open \"{$this->sheetDataXMLFilePath}\".");
|
||||
}
|
||||
|
||||
$this->numReadRows = 0;
|
||||
$this->lastRowIndexProcessed = 0;
|
||||
$this->nextRowIndexToBeProcessed = 0;
|
||||
$this->rowDataBuffer = null;
|
||||
$this->hasReachedEndOfFile = false;
|
||||
$this->numColumns = 0;
|
||||
|
||||
$this->next();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return (!$this->hasReachedEndOfFile);
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element. Reads data describing the next unprocessed row.
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @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
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$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 {
|
||||
$this->xmlProcessor->readUntilStopped();
|
||||
} 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
|
||||
$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)) {
|
||||
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($matches[1]) + 1;
|
||||
}
|
||||
|
||||
return XMLProcessor::PROCESSING_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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
|
||||
$numberOfColumnsForRow = $this->numColumns;
|
||||
$spans = $xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
|
||||
if ($spans) {
|
||||
list(, $numberOfColumnsForRow) = explode(':', $spans);
|
||||
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
||||
}
|
||||
|
||||
$this->currentlyProcessedRowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
||||
|
||||
return XMLProcessor::PROCESSING_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$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;
|
||||
}
|
||||
|
||||
$this->numReadRows++;
|
||||
|
||||
// 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
|
||||
$this->hasReachedEndOfFile = true;
|
||||
|
||||
return XMLProcessor::PROCESSING_STOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return string|int|float|bool|\DateTime|null The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
protected function getCellValue($node)
|
||||
{
|
||||
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $rowData
|
||||
* @return bool Whether the given row is empty
|
||||
*/
|
||||
protected function isEmptyRow($rowData)
|
||||
{
|
||||
return (count($rowData) === 1 && key($rowData) === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element, either an empty row or from the buffer.
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
$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. Here, the row index.
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX;
|
||||
|
||||
use Box\Spout\Reader\SheetInterface;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* Represents a sheet within a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX
|
||||
*/
|
||||
class Sheet implements SheetInterface
|
||||
{
|
||||
/** @var \Box\Spout\Reader\XLSX\RowIterator To iterate over sheet's rows */
|
||||
protected $rowIterator;
|
||||
|
||||
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||
protected $index;
|
||||
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/** @var bool Whether the sheet was the active one */
|
||||
protected $isActive;
|
||||
|
||||
/**
|
||||
* @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 int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param string $sheetName Name of the sheet
|
||||
* @param bool $isSheetActive Whether the sheet was defined as active
|
||||
* @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, $sheetIndex, $sheetName, $isSheetActive, $options, $sharedStringsHelper)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
$this->isActive = $isSheetActive;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return \Box\Spout\Reader\XLSX\RowIterator
|
||||
*/
|
||||
public function getRowIterator()
|
||||
{
|
||||
return $this->rowIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return string Name of the sheet
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Whether the sheet was defined as active
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX;
|
||||
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\XLSX\Helper\SheetHelper;
|
||||
use Box\Spout\Reader\Exception\NoSheetsFoundException;
|
||||
|
||||
/**
|
||||
* Class SheetIterator
|
||||
* Iterate over XLSX sheet.
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX
|
||||
*/
|
||||
class SheetIterator implements IteratorInterface
|
||||
{
|
||||
/** @var \Box\Spout\Reader\XLSX\Sheet[] The list of sheet present in the file */
|
||||
protected $sheets;
|
||||
|
||||
/** @var int The index of the sheet being read (zero-based) */
|
||||
protected $currentSheetIndex;
|
||||
|
||||
/**
|
||||
* @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\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
|
||||
*/
|
||||
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
{
|
||||
// Fetch all available sheets
|
||||
$sheetHelper = new SheetHelper($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper);
|
||||
$this->sheets = $sheetHelper->getSheets();
|
||||
|
||||
if (count($this->sheets) === 0) {
|
||||
throw new NoSheetsFoundException('The file must contain at least one sheet.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rewind the Iterator to the first element
|
||||
* @link http://php.net/manual/en/iterator.rewind.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function rewind()
|
||||
{
|
||||
$this->currentSheetIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
return ($this->currentSheetIndex < count($this->sheets));
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (isset($this->sheets[$this->currentSheetIndex])) {
|
||||
$currentSheet = $this->sheets[$this->currentSheetIndex];
|
||||
$currentSheet->getRowIterator()->end();
|
||||
|
||||
$this->currentSheetIndex++;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return \Box\Spout\Reader\XLSX\Sheet
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->sheets[$this->currentSheetIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->currentSheetIndex + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cleans up what was created to iterate over the object.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function end()
|
||||
{
|
||||
// make sure we are not leaking memory in case the iteration stopped before the end
|
||||
foreach ($this->sheets as $sheet) {
|
||||
$sheet->getRowIterator()->end();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer;
|
||||
|
||||
use Box\Spout\Writer\Exception\WriterNotOpenedException;
|
||||
|
||||
/**
|
||||
* Class AbstractMultiSheetsWriter
|
||||
*
|
||||
* @package Box\Spout\Writer
|
||||
* @abstract
|
||||
*/
|
||||
abstract class AbstractMultiSheetsWriter extends AbstractWriter
|
||||
{
|
||||
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
|
||||
protected $shouldCreateNewSheetsAutomatically = true;
|
||||
|
||||
/**
|
||||
* @return Common\Internal\WorkbookInterface The workbook representing the file to be written
|
||||
*/
|
||||
abstract protected function getWorkbook();
|
||||
|
||||
/**
|
||||
* Sets whether new sheets should be automatically created when the max rows limit per sheet is reached.
|
||||
* This must be set before opening the writer.
|
||||
*
|
||||
* @api
|
||||
* @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached
|
||||
* @return AbstractMultiSheetsWriter
|
||||
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
|
||||
*/
|
||||
public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically)
|
||||
{
|
||||
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
|
||||
|
||||
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns all the workbook's sheets
|
||||
*
|
||||
* @api
|
||||
* @return Common\Sheet[] All the workbook's sheets
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
|
||||
*/
|
||||
public function getSheets()
|
||||
{
|
||||
$this->throwIfBookIsNotAvailable();
|
||||
|
||||
$externalSheets = [];
|
||||
$worksheets = $this->getWorkbook()->getWorksheets();
|
||||
|
||||
/** @var Common\Internal\WorksheetInterface $worksheet */
|
||||
foreach ($worksheets as $worksheet) {
|
||||
$externalSheets[] = $worksheet->getExternalSheet();
|
||||
}
|
||||
|
||||
return $externalSheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sheet and make it the current sheet. The data will now be written to this sheet.
|
||||
*
|
||||
* @api
|
||||
* @return Common\Sheet The created sheet
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
|
||||
*/
|
||||
public function addNewSheetAndMakeItCurrent()
|
||||
{
|
||||
$this->throwIfBookIsNotAvailable();
|
||||
$worksheet = $this->getWorkbook()->addNewSheetAndMakeItCurrent();
|
||||
|
||||
return $worksheet->getExternalSheet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the current sheet
|
||||
*
|
||||
* @api
|
||||
* @return Common\Sheet The current sheet
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
|
||||
*/
|
||||
public function getCurrentSheet()
|
||||
{
|
||||
$this->throwIfBookIsNotAvailable();
|
||||
return $this->getWorkbook()->getCurrentWorksheet()->getExternalSheet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given sheet as the current one. New data will be written to this sheet.
|
||||
* The writing will resume where it stopped (i.e. data won't be truncated).
|
||||
*
|
||||
* @api
|
||||
* @param Common\Sheet $sheet The sheet to set as current
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
|
||||
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
|
||||
*/
|
||||
public function setCurrentSheet($sheet)
|
||||
{
|
||||
$this->throwIfBookIsNotAvailable();
|
||||
$this->getWorkbook()->setCurrentSheet($sheet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the book has been created. Throws an exception if not created yet.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
|
||||
*/
|
||||
protected function throwIfBookIsNotAvailable()
|
||||
{
|
||||
if (!$this->getWorkbook()) {
|
||||
throw new WriterNotOpenedException('The writer must be opened before performing this action.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,11 @@ namespace Box\Spout\Writer;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
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\WriterNotOpenedException;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
|
||||
/**
|
||||
* Class AbstractWriter
|
||||
@@ -26,6 +30,12 @@ abstract class AbstractWriter implements WriterInterface
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
protected $globalFunctionsHelper;
|
||||
|
||||
/** @var Style\Style Style to be applied to the next written row(s) */
|
||||
protected $rowStyle;
|
||||
|
||||
/** @var Style\Style Default row style. Each writer can have its own default style */
|
||||
protected $defaultRowStyle;
|
||||
|
||||
/** @var string Content-Type value for the header - to be defined by child class */
|
||||
protected static $headerContentType;
|
||||
|
||||
@@ -39,13 +49,13 @@ abstract class AbstractWriter implements WriterInterface
|
||||
|
||||
/**
|
||||
* Adds data to the currently openned writer.
|
||||
* The data must be UTF-8 encoded.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be streamed.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @param Style\Style $style Style to be applied to the written row
|
||||
* @return void
|
||||
*/
|
||||
abstract protected function addRowToWriter(array $dataRow);
|
||||
abstract protected function addRowToWriter(array $dataRow, $style);
|
||||
|
||||
/**
|
||||
* Closes the streamer, preventing any additional writing.
|
||||
@@ -55,7 +65,31 @@ abstract class AbstractWriter implements WriterInterface
|
||||
abstract protected function closeWriter();
|
||||
|
||||
/**
|
||||
* @param $globalFunctionsHelper
|
||||
*
|
||||
*/
|
||||
public function __construct()
|
||||
{
|
||||
$this->defaultRowStyle = $this->getDefaultRowStyle();
|
||||
$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
|
||||
* @return AbstractWriter
|
||||
*/
|
||||
public function setGlobalFunctionsHelper($globalFunctionsHelper)
|
||||
@@ -68,8 +102,9 @@ abstract class AbstractWriter implements WriterInterface
|
||||
* Inits the writer and opens it to accept data.
|
||||
* By using this method, the data will be written to a file.
|
||||
*
|
||||
* @api
|
||||
* @param string $outputFilePath Path of the output file that will contain the data
|
||||
* @return \Box\Spout\Writer\AbstractWriter
|
||||
* @return AbstractWriter
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened or if the given path is not writable
|
||||
*/
|
||||
public function openToFile($outputFilePath)
|
||||
@@ -91,8 +126,9 @@ abstract class AbstractWriter implements WriterInterface
|
||||
*
|
||||
* @codeCoverageIgnore
|
||||
*
|
||||
* @api
|
||||
* @param string $outputFileName Name of the output file that will contain the data. If a path is passed in, only the file name will be kept
|
||||
* @return \Box\Spout\Writer\AbstractWriter
|
||||
* @return AbstractWriter
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened
|
||||
*/
|
||||
public function openToBrowser($outputFileName)
|
||||
@@ -102,6 +138,10 @@ abstract class AbstractWriter implements WriterInterface
|
||||
$this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
|
||||
$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
|
||||
$this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType);
|
||||
$this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"');
|
||||
@@ -136,24 +176,48 @@ abstract class AbstractWriter implements WriterInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the writer has already been opened, since some actions must be done before it gets opened.
|
||||
* Throws an exception if already opened.
|
||||
*
|
||||
* @param string $message Error message
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened and must not be.
|
||||
*/
|
||||
protected function throwIfWriterAlreadyOpened($message)
|
||||
{
|
||||
if ($this->isWriterOpened) {
|
||||
throw new WriterAlreadyOpenedException($message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Write given data to the output. New data will be appended to end of stream.
|
||||
* The data must be UTF-8 encoded.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be streamed.
|
||||
* If empty, no data is added (i.e. not even as a blank row)
|
||||
* Example: $dataRow = ['data1', 1234, null, '', 'data5', false];
|
||||
*
|
||||
* @return \Box\Spout\Writer\AbstractWriter
|
||||
* @api
|
||||
* @return AbstractWriter
|
||||
* @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\SpoutException If anything else goes wrong while writing data
|
||||
*/
|
||||
public function addRow(array $dataRow)
|
||||
{
|
||||
if ($this->isWriterOpened) {
|
||||
// empty $dataRow should not add an empty line
|
||||
if (!empty($dataRow)) {
|
||||
$this->addRowToWriter($dataRow);
|
||||
try {
|
||||
$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 {
|
||||
throw new WriterNotOpenedException('The writer needs to be opened before adding row.');
|
||||
@@ -163,17 +227,41 @@ abstract class AbstractWriter implements WriterInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Write given data to the output. New data will be appended to end of stream.
|
||||
* The data must be UTF-8 encoded.
|
||||
* Write given data to the output and apply the given style.
|
||||
* @see addRow
|
||||
*
|
||||
* @api
|
||||
* @param array $dataRow Array of array containing data to be streamed.
|
||||
* @param Style\Style $style Style to be applied to the row.
|
||||
* @return AbstractWriter
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
|
||||
* @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
|
||||
*/
|
||||
public function addRowWithStyle(array $dataRow, $style)
|
||||
{
|
||||
if (!$style instanceof Style\Style) {
|
||||
throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.');
|
||||
}
|
||||
|
||||
$this->setRowStyle($style);
|
||||
$this->addRow($dataRow);
|
||||
$this->resetRowStyleToDefault();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write given data to the output. New data will be appended to end of stream.
|
||||
*
|
||||
* @api
|
||||
* @param array $dataRows Array of array containing data to be streamed.
|
||||
* If a row is empty, it won't be added (i.e. not even as a blank row)
|
||||
* Example: $dataRows = [
|
||||
* ['data11', 12, , '', 'data13'],
|
||||
* ['data21', 'data22', null, false],
|
||||
* ];
|
||||
*
|
||||
* @return \Box\Spout\Writer\AbstractWriter
|
||||
* @return AbstractWriter
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
|
||||
* @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
|
||||
@@ -181,7 +269,8 @@ abstract class AbstractWriter implements WriterInterface
|
||||
public function addRows(array $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');
|
||||
}
|
||||
|
||||
@@ -193,16 +282,103 @@ abstract class AbstractWriter implements WriterInterface
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Write given data to the output and apply the given style.
|
||||
* @see addRows
|
||||
*
|
||||
* @api
|
||||
* @param array $dataRows Array of array containing data to be streamed.
|
||||
* @param Style\Style $style Style to be applied to the rows.
|
||||
* @return AbstractWriter
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
|
||||
* @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
|
||||
*/
|
||||
public function addRowsWithStyle(array $dataRows, $style)
|
||||
{
|
||||
if (!$style instanceof Style\Style) {
|
||||
throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.');
|
||||
}
|
||||
|
||||
$this->setRowStyle($style);
|
||||
$this->addRows($dataRows);
|
||||
$this->resetRowStyleToDefault();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default style to be applied to rows.
|
||||
* Can be overriden by children to have a custom style.
|
||||
*
|
||||
* @return Style\Style
|
||||
*/
|
||||
protected function getDefaultRowStyle()
|
||||
{
|
||||
return (new StyleBuilder())->build();
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the style to be applied to the next written rows
|
||||
* until it is changed or reset.
|
||||
*
|
||||
* @param Style\Style $style
|
||||
* @return void
|
||||
*/
|
||||
private function setRowStyle($style)
|
||||
{
|
||||
// Merge given style with the default one to inherit custom properties
|
||||
$this->rowStyle = $style->mergeWith($this->defaultRowStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resets the style to be applied to the next written rows.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
private function resetRowStyleToDefault()
|
||||
{
|
||||
$this->rowStyle = $this->defaultRowStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer. This will close the streamer as well, preventing new data
|
||||
* to be written to the file.
|
||||
*
|
||||
* @api
|
||||
* @return void
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!$this->isWriterOpened) {
|
||||
return;
|
||||
}
|
||||
|
||||
$this->closeWriter();
|
||||
|
||||
if (is_resource($this->filePointer)) {
|
||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||
}
|
||||
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,21 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer;
|
||||
namespace Box\Spout\Writer\CSV;
|
||||
|
||||
use Box\Spout\Writer\AbstractWriter;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Common\Helper\EncodingHelper;
|
||||
|
||||
/**
|
||||
* Class CSV
|
||||
* Class Writer
|
||||
* This class provides support to write data to CSV files
|
||||
*
|
||||
* @package Box\Spout\Writer
|
||||
* @package Box\Spout\Writer\CSV
|
||||
*/
|
||||
class CSV extends AbstractWriter
|
||||
class Writer extends AbstractWriter
|
||||
{
|
||||
/** Number of rows to write before flushing */
|
||||
const FLUSH_THRESHOLD = 500;
|
||||
const UTF8_BOM = "\xEF\xBB\xBF";
|
||||
|
||||
/** @var string Content-Type value for the header */
|
||||
protected static $headerContentType = 'text/csv; charset=UTF-8';
|
||||
@@ -28,11 +29,15 @@ class CSV extends AbstractWriter
|
||||
/** @var int */
|
||||
protected $lastWrittenRowIndex = 0;
|
||||
|
||||
/** @var bool */
|
||||
protected $shouldAddBOM = true;
|
||||
|
||||
/**
|
||||
* Sets the field delimiter for the CSV
|
||||
*
|
||||
* @api
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @return CSV
|
||||
* @return Writer
|
||||
*/
|
||||
public function setFieldDelimiter($fieldDelimiter)
|
||||
{
|
||||
@@ -43,8 +48,9 @@ class CSV extends AbstractWriter
|
||||
/**
|
||||
* Sets the field enclosure for the CSV
|
||||
*
|
||||
* @api
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @return CSV
|
||||
* @return Writer
|
||||
*/
|
||||
public function setFieldEnclosure($fieldEnclosure)
|
||||
{
|
||||
@@ -52,6 +58,18 @@ class CSV extends AbstractWriter
|
||||
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.
|
||||
*
|
||||
@@ -59,8 +77,10 @@ class CSV extends AbstractWriter
|
||||
*/
|
||||
protected function openWriter()
|
||||
{
|
||||
// Adds UTF-8 BOM for Unicode compatibility
|
||||
$this->globalFunctionsHelper->fputs($this->filePointer, self::UTF8_BOM);
|
||||
if ($this->shouldAddBOM) {
|
||||
// Adds UTF-8 BOM for Unicode compatibility
|
||||
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -68,10 +88,11 @@ class CSV extends AbstractWriter
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @param \Box\Spout\Writer\Style\Style $style Ignored here since CSV does not support styling.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
|
||||
*/
|
||||
protected function addRowToWriter(array $dataRow)
|
||||
protected function addRowToWriter(array $dataRow, $style)
|
||||
{
|
||||
$wasWriteSuccessful = $this->globalFunctionsHelper->fputcsv($this->filePointer, $dataRow, $this->fieldDelimiter, $this->fieldEnclosure);
|
||||
if ($wasWriteSuccessful === false) {
|
||||
@@ -92,10 +113,6 @@ class CSV extends AbstractWriter
|
||||
*/
|
||||
protected function closeWriter()
|
||||
{
|
||||
if ($this->filePointer) {
|
||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||
}
|
||||
|
||||
$this->lastWrittenRowIndex = 0;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Common\Helper;
|
||||
|
||||
/**
|
||||
* Class AbstractStyleHelper
|
||||
* This class provides helper functions to manage styles
|
||||
*
|
||||
* @package Box\Spout\Writer\Common\Helper
|
||||
*/
|
||||
abstract class AbstractStyleHelper
|
||||
{
|
||||
/** @var array [SERIALIZED_STYLE] => [STYLE_ID] mapping table, keeping track of the registered styles */
|
||||
protected $serializedStyleToStyleIdMappingTable = [];
|
||||
|
||||
/** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */
|
||||
protected $styleIdToStyleMappingTable = [];
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Writer\Style\Style $defaultStyle
|
||||
*/
|
||||
public function __construct($defaultStyle)
|
||||
{
|
||||
// This ensures that the default style is the first one to be registered
|
||||
$this->registerStyle($defaultStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers the given style as a used style.
|
||||
* Duplicate styles won't be registered more than once.
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
|
||||
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
|
||||
*/
|
||||
public function registerStyle($style)
|
||||
{
|
||||
$serializedStyle = $style->serialize();
|
||||
|
||||
if (!$this->hasStyleAlreadyBeenRegistered($style)) {
|
||||
$nextStyleId = count($this->serializedStyleToStyleIdMappingTable);
|
||||
$style->setId($nextStyleId);
|
||||
|
||||
$this->serializedStyleToStyleIdMappingTable[$serializedStyle] = $nextStyleId;
|
||||
$this->styleIdToStyleMappingTable[$nextStyleId] = $style;
|
||||
}
|
||||
|
||||
return $this->getStyleFromSerializedStyle($serializedStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given style has already been registered.
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
* @return bool
|
||||
*/
|
||||
protected function hasStyleAlreadyBeenRegistered($style)
|
||||
{
|
||||
$serializedStyle = $style->serialize();
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the registered style associated to the given serialization.
|
||||
*
|
||||
* @param string $serializedStyle The serialized style from which the actual style should be fetched from
|
||||
* @return \Box\Spout\Writer\Style\Style
|
||||
*/
|
||||
protected function getStyleFromSerializedStyle($serializedStyle)
|
||||
{
|
||||
$styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle];
|
||||
return $this->styleIdToStyleMappingTable[$styleId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Writer\Style\Style[] List of registered styles
|
||||
*/
|
||||
protected function getRegisteredStyles()
|
||||
{
|
||||
return array_values($this->styleIdToStyleMappingTable);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the default style
|
||||
*
|
||||
* @return \Box\Spout\Writer\Style\Style Default style
|
||||
*/
|
||||
protected function getDefaultStyle()
|
||||
{
|
||||
// By construction, the default style has ID 0
|
||||
return $this->styleIdToStyleMappingTable[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply additional styles if the given row needs it.
|
||||
* Typically, set "wrap text" if a cell contains a new line.
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style The original style
|
||||
* @param array $dataRow The row the style will be applied to
|
||||
* @return \Box\Spout\Writer\Style\Style The updated style
|
||||
*/
|
||||
public function applyExtraStylesIfNeeded($style, $dataRow)
|
||||
{
|
||||
$updatedStyle = $this->applyWrapTextIfCellContainsNewLine($style, $dataRow);
|
||||
return $updatedStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the "wrap text" option if a cell of the given row contains a new line.
|
||||
*
|
||||
* @NOTE: There is a bug on the Mac version of Excel (2011 and below) where new lines
|
||||
* are ignored even when the "wrap text" option is set. This only occurs with
|
||||
* inline strings (shared strings do work fine).
|
||||
* A workaround would be to encode "\n" as "_x000D_" but it does not work
|
||||
* on the Windows version of Excel...
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style The original style
|
||||
* @param array $dataRow The row the style will be applied to
|
||||
* @return \Box\Spout\Writer\Style\Style The eventually updated style
|
||||
*/
|
||||
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
|
||||
{
|
||||
// if the "wrap text" option is already set, no-op
|
||||
if ($style->hasSetWrapText()) {
|
||||
return $style;
|
||||
}
|
||||
|
||||
foreach ($dataRow as $cell) {
|
||||
if (is_string($cell) && strpos($cell, "\n") !== false) {
|
||||
$style->setShouldWrapText();
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return $style;
|
||||
}
|
||||
}
|
||||
+31
-11
@@ -1,15 +1,18 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Helper\XLSX;
|
||||
namespace Box\Spout\Writer\Common\Helper;
|
||||
|
||||
/**
|
||||
* Class CellHelper
|
||||
* This class provides helper functions when working with cells
|
||||
*
|
||||
* @package Box\Spout\Writer\Helper\XLSX
|
||||
* @package Box\Spout\Writer\Common\Helper
|
||||
*/
|
||||
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.
|
||||
* Excel uses A to Z letters for column indexing, where A is the 1st column,
|
||||
@@ -21,18 +24,35 @@ class CellHelper
|
||||
*/
|
||||
public static function getCellIndexFromColumnIndex($columnIndex)
|
||||
{
|
||||
$cellIndex = '';
|
||||
$capitalAAsciiValue = ord('A');
|
||||
$originalColumnIndex = $columnIndex;
|
||||
|
||||
do {
|
||||
$modulus = $columnIndex % 26;
|
||||
$cellIndex = chr($capitalAAsciiValue + $modulus) . $cellIndex;
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (!isset(self::$columnIndexToCellIndexCache[$originalColumnIndex])) {
|
||||
$cellIndex = '';
|
||||
$capitalAAsciiValue = ord('A');
|
||||
|
||||
// substracting 1 because it's zero-based
|
||||
$columnIndex = intval($columnIndex / 26) - 1;
|
||||
} while ($columnIndex >= 0);
|
||||
do {
|
||||
$modulus = $columnIndex % 26;
|
||||
$cellIndex = chr($capitalAAsciiValue + $modulus) . $cellIndex;
|
||||
|
||||
return $cellIndex;
|
||||
// substracting 1 because it's zero-based
|
||||
$columnIndex = intval($columnIndex / 26) - 1;
|
||||
|
||||
} while ($columnIndex >= 0);
|
||||
|
||||
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 === '');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -0,0 +1,217 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Common\Helper;
|
||||
|
||||
/**
|
||||
* Class ZipHelper
|
||||
* This class provides helper functions to create zip files
|
||||
*
|
||||
* @package Box\Spout\Writer\Common\Helper
|
||||
*/
|
||||
class ZipHelper
|
||||
{
|
||||
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;
|
||||
|
||||
/**
|
||||
* @param string $tmpFolderPath Path of the temp folder where the zip file will be created
|
||||
*/
|
||||
public function __construct($tmpFolderPath)
|
||||
{
|
||||
$this->tmpFolderPath = $tmpFolderPath;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Path where the zip file of the given folder will be created
|
||||
*/
|
||||
public function getZipFilePath()
|
||||
{
|
||||
return $this->tmpFolderPath . self::ZIP_EXTENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the given file, located under the given root folder to the archive.
|
||||
* The file will be compressed.
|
||||
*
|
||||
* Example of use:
|
||||
* 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
|
||||
*/
|
||||
public function addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
|
||||
{
|
||||
$this->addFileToArchiveWithCompressionMethod(
|
||||
$rootFolderPath,
|
||||
$localFilePath,
|
||||
$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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool Whether it is possible to choose the desired compression method to be used
|
||||
*/
|
||||
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
|
||||
*/
|
||||
public function addFolderToArchive($folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
|
||||
{
|
||||
$zip = $this->createOrGetZip();
|
||||
|
||||
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
|
||||
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
|
||||
|
||||
foreach ($itemIterator as $itemInfo) {
|
||||
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
|
||||
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
|
||||
|
||||
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
|
||||
$zip->addFile($itemRealPath, $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.
|
||||
*
|
||||
* @param string $path Path to normalize
|
||||
* @return string Normalized and canonicalized path
|
||||
*/
|
||||
protected function getNormalizedRealPath($path)
|
||||
{
|
||||
$realPath = realpath($path);
|
||||
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
|
||||
*
|
||||
* @param resource $pointer Pointer to the stream to copy the zip
|
||||
* @return void
|
||||
*/
|
||||
protected function copyZipToStream($pointer)
|
||||
{
|
||||
$zipFilePointer = fopen($this->getZipFilePath(), 'r');
|
||||
stream_copy_to_stream($zipFilePointer, $pointer);
|
||||
fclose($zipFilePointer);
|
||||
}
|
||||
}
|
||||
+47
-90
@@ -1,87 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Internal\XLSX;
|
||||
namespace Box\Spout\Writer\Common\Internal;
|
||||
|
||||
use Box\Spout\Writer\Exception\SheetNotFoundException;
|
||||
use Box\Spout\Writer\Helper\XLSX\FileSystemHelper;
|
||||
use Box\Spout\Writer\Helper\XLSX\SharedStringsHelper;
|
||||
use Box\Spout\Writer\Sheet;
|
||||
|
||||
/**
|
||||
* Class Book
|
||||
* Represents a workbook within a XLSX file.
|
||||
* Class Workbook
|
||||
* Represents a workbook within a spreadsheet file.
|
||||
* It provides the functions to work with worksheets.
|
||||
*
|
||||
* @package Box\Spout\Writer\Internal\XLSX
|
||||
* @package Box\Spout\Writer\Common
|
||||
*/
|
||||
class Workbook
|
||||
abstract class AbstractWorkbook implements WorkbookInterface
|
||||
{
|
||||
/**
|
||||
* Maximum number of rows a XLSX sheet can contain
|
||||
* @see http://office.microsoft.com/en-us/excel-help/excel-specifications-and-limits-HP010073849.aspx
|
||||
*/
|
||||
protected static $maxRowsPerWorksheet = 1048576;
|
||||
|
||||
/** @var bool Whether inline or shared strings should be used */
|
||||
protected $shouldUseInlineStrings;
|
||||
|
||||
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
|
||||
protected $shouldCreateNewSheetsAutomatically;
|
||||
|
||||
/** @var \Box\Spout\Writer\Helper\XLSX\FileSystemHelper Helper to perform file system operations */
|
||||
protected $fileSystemHelper;
|
||||
/** @var string Timestamp based unique ID identifying the workbook */
|
||||
protected $internalId;
|
||||
|
||||
/** @var \Box\Spout\Writer\Helper\XLSX\SharedStringsHelper Helper to write shared strings */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var Worksheet[] Array containing the workbook's sheets */
|
||||
/** @var WorksheetInterface[] Array containing the workbook's sheets */
|
||||
protected $worksheets = [];
|
||||
|
||||
/** @var Worksheet The worksheet where data will be written to */
|
||||
/** @var WorksheetInterface The worksheet where data will be written to */
|
||||
protected $currentWorksheet;
|
||||
|
||||
/**
|
||||
* @param string $tempFolder
|
||||
* @param bool $shouldUseInlineStrings
|
||||
* @param bool $shouldCreateNewSheetsAutomatically
|
||||
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
|
||||
*/
|
||||
public function __construct($tempFolder, $shouldUseInlineStrings, $shouldCreateNewSheetsAutomatically)
|
||||
public function __construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle)
|
||||
{
|
||||
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
|
||||
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
|
||||
|
||||
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
|
||||
$this->fileSystemHelper->createBaseFilesAndFolders();
|
||||
|
||||
// This helper will be shared by all sheets
|
||||
$xlFolder = $this->fileSystemHelper->getXlFolder();
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($xlFolder);
|
||||
$this->internalId = uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Writer\Common\Helper\AbstractStyleHelper The specific style helper
|
||||
*/
|
||||
abstract protected function getStyleHelper();
|
||||
|
||||
/**
|
||||
* @return int Maximum number of rows/columns a sheet can contain
|
||||
*/
|
||||
abstract protected function getMaxRowsPerWorksheet();
|
||||
|
||||
/**
|
||||
* Creates a new sheet in the workbook. The current sheet remains unchanged.
|
||||
*
|
||||
* @return Worksheet The created sheet
|
||||
* @return WorksheetInterface The created sheet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
|
||||
*/
|
||||
public function addNewSheet()
|
||||
{
|
||||
$newSheetIndex = count($this->worksheets);
|
||||
$sheet = new Sheet($newSheetIndex);
|
||||
|
||||
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
|
||||
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->shouldUseInlineStrings);
|
||||
$this->worksheets[] = $worksheet;
|
||||
|
||||
return $worksheet;
|
||||
}
|
||||
abstract public function addNewSheet();
|
||||
|
||||
/**
|
||||
* Creates a new sheet in the workbook and make it the current sheet.
|
||||
* The writing will resume where it stopped (i.e. data won't be truncated).
|
||||
*
|
||||
* @return Worksheet The created sheet
|
||||
* @return WorksheetInterface The created sheet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
|
||||
*/
|
||||
public function addNewSheetAndMakeItCurrent()
|
||||
@@ -93,7 +70,7 @@ class Workbook
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Worksheet[] All the workbook's sheets
|
||||
* @return WorksheetInterface[] All the workbook's sheets
|
||||
*/
|
||||
public function getWorksheets()
|
||||
{
|
||||
@@ -103,7 +80,7 @@ class Workbook
|
||||
/**
|
||||
* Returns the current sheet
|
||||
*
|
||||
* @return Worksheet The current sheet
|
||||
* @return WorksheetInterface The current sheet
|
||||
*/
|
||||
public function getCurrentWorksheet()
|
||||
{
|
||||
@@ -114,7 +91,7 @@ class Workbook
|
||||
* Sets the given sheet as the current one. New data will be written to this sheet.
|
||||
* The writing will resume where it stopped (i.e. data won't be truncated).
|
||||
*
|
||||
* @param \Box\Spout\Writer\Sheet $sheet The "external" sheet to set as current
|
||||
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
|
||||
*/
|
||||
@@ -129,7 +106,7 @@ class Workbook
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Worksheet $worksheet
|
||||
* @param WorksheetInterface $worksheet
|
||||
* @return void
|
||||
*/
|
||||
protected function setCurrentWorksheet($worksheet)
|
||||
@@ -140,15 +117,15 @@ class Workbook
|
||||
/**
|
||||
* Returns the worksheet associated to the given external sheet.
|
||||
*
|
||||
* @param \Box\Spout\Writer\Sheet $sheet
|
||||
* @return Worksheet|null The worksheet associated to the given external sheet or null if not found.
|
||||
* @param \Box\Spout\Writer\Common\Sheet $sheet
|
||||
* @return WorksheetInterface|null The worksheet associated to the given external sheet or null if not found.
|
||||
*/
|
||||
protected function getWorksheetFromExternalSheet($sheet)
|
||||
{
|
||||
$worksheetFound = null;
|
||||
|
||||
foreach ($this->worksheets as $worksheet) {
|
||||
if ($worksheet->getExternalSheet() == $sheet) {
|
||||
if ($worksheet->getExternalSheet() === $sheet) {
|
||||
$worksheetFound = $worksheet;
|
||||
break;
|
||||
}
|
||||
@@ -162,28 +139,35 @@ class Workbook
|
||||
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
|
||||
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* @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.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
|
||||
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
|
||||
*/
|
||||
public function addRowToCurrentWorksheet($dataRow)
|
||||
public function addRowToCurrentWorksheet($dataRow, $style)
|
||||
{
|
||||
$currentWorksheet = $this->getCurrentWorksheet();
|
||||
$hasReachedMaxRows = $this->hasCurrentWorkseetReachedMaxRows();
|
||||
$styleHelper = $this->getStyleHelper();
|
||||
|
||||
// if we reached the maximum number of rows for the current sheet...
|
||||
if ($hasReachedMaxRows) {
|
||||
// ... continue writing in a new sheet if option set
|
||||
if ($this->shouldCreateNewSheetsAutomatically) {
|
||||
$currentWorksheet = $this->addNewSheetAndMakeItCurrent();
|
||||
$currentWorksheet->addRow($dataRow);
|
||||
|
||||
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
|
||||
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
|
||||
$currentWorksheet->addRow($dataRow, $registeredStyle);
|
||||
} else {
|
||||
// otherwise, do nothing as the data won't be read anyways
|
||||
}
|
||||
} else {
|
||||
$currentWorksheet->addRow($dataRow);
|
||||
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
|
||||
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
|
||||
$currentWorksheet->addRow($dataRow, $registeredStyle);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,43 +177,16 @@ class Workbook
|
||||
protected function hasCurrentWorkseetReachedMaxRows()
|
||||
{
|
||||
$currentWorksheet = $this->getCurrentWorksheet();
|
||||
return ($currentWorksheet->getLastWrittenRowIndex() >= self::$maxRowsPerWorksheet);
|
||||
return ($currentWorksheet->getLastWrittenRowIndex() >= $this->getMaxRowsPerWorksheet());
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the workbook and all its associated sheets.
|
||||
* All the necessary files are written to disk and zipped together to create the XLSX file.
|
||||
* All the necessary files are written to disk and zipped together to create the ODS file.
|
||||
* All the temporary files are then deleted.
|
||||
*
|
||||
* @param resource $finalFilePointer Pointer to the XLSX that will be created
|
||||
* @param resource $finalFilePointer Pointer to the ODS that will be created
|
||||
* @return void
|
||||
*/
|
||||
public function close($finalFilePointer)
|
||||
{
|
||||
foreach ($this->worksheets as $worksheet) {
|
||||
$worksheet->close();
|
||||
}
|
||||
|
||||
$this->sharedStringsHelper->close();
|
||||
|
||||
// Finish creating all the necessary files before zipping everything together
|
||||
$this->fileSystemHelper
|
||||
->createContentTypesFile($this->worksheets)
|
||||
->createWorkbookFile($this->worksheets)
|
||||
->createWorkbookRelsFile($this->worksheets)
|
||||
->zipRootFolderAndCopyToStream($finalFilePointer);
|
||||
|
||||
$this->cleanupTempFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the root folder created in the temp folder and all its contents.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function cleanupTempFolder()
|
||||
{
|
||||
$xlsxRootFolder = $this->fileSystemHelper->getRootFolder();
|
||||
$this->fileSystemHelper->deleteFolderRecursively($xlsxRootFolder);
|
||||
}
|
||||
abstract public function close($finalFilePointer);
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Common\Internal;
|
||||
|
||||
/**
|
||||
* Interface WorkbookInterface
|
||||
*
|
||||
* @package Box\Spout\Writer\Common\Internal
|
||||
*/
|
||||
interface WorkbookInterface
|
||||
{
|
||||
/**
|
||||
* Creates a new sheet in the workbook. The current sheet remains unchanged.
|
||||
*
|
||||
* @return WorksheetInterface The created sheet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
|
||||
*/
|
||||
public function addNewSheet();
|
||||
|
||||
/**
|
||||
* Creates a new sheet in the workbook and make it the current sheet.
|
||||
* The writing will resume where it stopped (i.e. data won't be truncated).
|
||||
*
|
||||
* @return WorksheetInterface The created sheet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
|
||||
*/
|
||||
public function addNewSheetAndMakeItCurrent();
|
||||
|
||||
/**
|
||||
* @return WorksheetInterface[] All the workbook's sheets
|
||||
*/
|
||||
public function getWorksheets();
|
||||
|
||||
/**
|
||||
* Returns the current sheet
|
||||
*
|
||||
* @return WorksheetInterface The current sheet
|
||||
*/
|
||||
public function getCurrentWorksheet();
|
||||
|
||||
/**
|
||||
* Sets the given sheet as the current one. New data will be written to this sheet.
|
||||
* The writing will resume where it stopped (i.e. data won't be truncated).
|
||||
*
|
||||
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
|
||||
*/
|
||||
public function setCurrentSheet($sheet);
|
||||
|
||||
/**
|
||||
* Adds data to the current sheet.
|
||||
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
|
||||
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
|
||||
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
|
||||
*/
|
||||
public function addRowToCurrentWorksheet($dataRow, $style);
|
||||
|
||||
/**
|
||||
* Closes the workbook and all its associated sheets.
|
||||
* All the necessary files are written to disk and zipped together to create the ODS file.
|
||||
* All the temporary files are then deleted.
|
||||
*
|
||||
* @param resource $finalFilePointer Pointer to the ODS that will be created
|
||||
* @return void
|
||||
*/
|
||||
public function close($finalFilePointer);
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Common\Internal;
|
||||
|
||||
/**
|
||||
* Interface WorksheetInterface
|
||||
*
|
||||
* @package Box\Spout\Writer\Common\Internal
|
||||
*/
|
||||
interface WorksheetInterface
|
||||
{
|
||||
/**
|
||||
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
|
||||
*/
|
||||
public function getExternalSheet();
|
||||
|
||||
/**
|
||||
* @return int The index of the last written row
|
||||
*/
|
||||
public function getLastWrittenRowIndex();
|
||||
|
||||
/**
|
||||
* Adds data to the worksheet.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* 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
|
||||
*/
|
||||
public function addRow($dataRow, $style);
|
||||
|
||||
/**
|
||||
* Closes the worksheet
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function close();
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Common;
|
||||
|
||||
use Box\Spout\Common\Helper\StringHelper;
|
||||
use Box\Spout\Writer\Exception\InvalidSheetNameException;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* External representation of a worksheet
|
||||
*
|
||||
* @package Box\Spout\Writer\Common
|
||||
*/
|
||||
class Sheet
|
||||
{
|
||||
const DEFAULT_SHEET_NAME_PREFIX = 'Sheet';
|
||||
|
||||
/** Sheet name should not exceed 31 characters */
|
||||
const MAX_LENGTH_SHEET_NAME = 31;
|
||||
|
||||
/** @var array Invalid characters that cannot be contained in the sheet name */
|
||||
private static $INVALID_CHARACTERS_IN_SHEET_NAME = ['\\', '/', '?', '*', ':', '[', ']'];
|
||||
|
||||
/** @var array Associative array [WORKBOOK_ID] => [[SHEET_INDEX] => [SHEET_NAME]] keeping track of sheets' name to enforce uniqueness per workbook */
|
||||
protected static $SHEETS_NAME_USED = [];
|
||||
|
||||
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
|
||||
protected $index;
|
||||
|
||||
/** @var string ID of the sheet's associated workbook. Used to restrict sheet name uniqueness enforcement to a single workbook */
|
||||
protected $associatedWorkbookId;
|
||||
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\StringHelper */
|
||||
protected $stringHelper;
|
||||
|
||||
/**
|
||||
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param string $associatedWorkbookId ID of the sheet's associated workbook
|
||||
*/
|
||||
public function __construct($sheetIndex, $associatedWorkbookId)
|
||||
{
|
||||
$this->index = $sheetIndex;
|
||||
$this->associatedWorkbookId = $associatedWorkbookId;
|
||||
if (!isset(self::$SHEETS_NAME_USED[$associatedWorkbookId])) {
|
||||
self::$SHEETS_NAME_USED[$associatedWorkbookId] = [];
|
||||
}
|
||||
|
||||
$this->stringHelper = new StringHelper();
|
||||
$this->setName(self::DEFAULT_SHEET_NAME_PREFIX . ($sheetIndex + 1));
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return int Index of the sheet, based on order in the workbook (zero-based)
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return string Name of the sheet
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the name of the sheet. Note that Excel has some restrictions on the name:
|
||||
* - it should not be blank
|
||||
* - it should not exceed 31 characters
|
||||
* - it should not contain these characters: \ / ? * : [ or ]
|
||||
* - it should be unique
|
||||
*
|
||||
* @api
|
||||
* @param string $name Name of the sheet
|
||||
* @return Sheet
|
||||
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
|
||||
*/
|
||||
public function setName($name)
|
||||
{
|
||||
$this->throwIfNameIsInvalid($name);
|
||||
|
||||
$this->name = $name;
|
||||
self::$SHEETS_NAME_USED[$this->associatedWorkbookId][$this->index] = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Throws an exception if the given sheet's name is not valid.
|
||||
* @see Sheet::setName for validity rules.
|
||||
*
|
||||
* @param string $name
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
|
||||
*/
|
||||
protected function throwIfNameIsInvalid($name)
|
||||
{
|
||||
if (!is_string($name)) {
|
||||
$actualType = gettype($name);
|
||||
$errorMessage = "The sheet's name is invalid. It must be a string ($actualType given).";
|
||||
throw new InvalidSheetNameException($errorMessage);
|
||||
}
|
||||
|
||||
$failedRequirements = [];
|
||||
$nameLength = $this->stringHelper->getStringLength($name);
|
||||
|
||||
if (!$this->isNameUnique($name)) {
|
||||
$failedRequirements[] = 'It should be unique';
|
||||
} else {
|
||||
if ($nameLength === 0) {
|
||||
$failedRequirements[] = 'It should not be blank';
|
||||
} else {
|
||||
if ($nameLength > self::MAX_LENGTH_SHEET_NAME) {
|
||||
$failedRequirements[] = 'It should not exceed 31 characters';
|
||||
}
|
||||
|
||||
if ($this->doesContainInvalidCharacters($name)) {
|
||||
$failedRequirements[] = 'It should not contain these characters: \\ / ? * : [ or ]';
|
||||
}
|
||||
|
||||
if ($this->doesStartOrEndWithSingleQuote($name)) {
|
||||
$failedRequirements[] = 'It should not start or end with a single quote';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (count($failedRequirements) !== 0) {
|
||||
$errorMessage = "The sheet's name (\"$name\") is invalid. It did not respect these rules:\n - ";
|
||||
$errorMessage .= implode("\n - ", $failedRequirements);
|
||||
throw new InvalidSheetNameException($errorMessage);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given name contains at least one invalid character.
|
||||
* @see Sheet::$INVALID_CHARACTERS_IN_SHEET_NAME for the full list.
|
||||
*
|
||||
* @param string $name
|
||||
* @return bool TRUE if the name contains invalid characters, FALSE otherwise.
|
||||
*/
|
||||
protected function doesContainInvalidCharacters($name)
|
||||
{
|
||||
return (str_replace(self::$INVALID_CHARACTERS_IN_SHEET_NAME, '', $name) !== $name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given name starts or ends with a single quote
|
||||
*
|
||||
* @param string $name
|
||||
* @return bool TRUE if the name starts or ends with a single quote, FALSE otherwise.
|
||||
*/
|
||||
protected function doesStartOrEndWithSingleQuote($name)
|
||||
{
|
||||
$startsWithSingleQuote = ($this->stringHelper->getCharFirstOccurrencePosition('\'', $name) === 0);
|
||||
$endsWithSingleQuote = ($this->stringHelper->getCharLastOccurrencePosition('\'', $name) === ($this->stringHelper->getStringLength($name) - 1));
|
||||
|
||||
return ($startsWithSingleQuote || $endsWithSingleQuote);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given name is unique.
|
||||
*
|
||||
* @param string $name
|
||||
* @return bool TRUE if the name is unique, FALSE otherwise.
|
||||
*/
|
||||
protected function isNameUnique($name)
|
||||
{
|
||||
foreach (self::$SHEETS_NAME_USED[$this->associatedWorkbookId] as $sheetIndex => $sheetName) {
|
||||
if ($sheetIndex !== $this->index && $sheetName === $name) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -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,13 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception;
|
||||
|
||||
/**
|
||||
* Class InvalidColorException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Writer\Exception
|
||||
*/
|
||||
class InvalidColorException extends WriterException
|
||||
{
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception;
|
||||
|
||||
/**
|
||||
* Class InvalidSheetNameException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Writer\Exception
|
||||
*/
|
||||
class InvalidSheetNameException extends WriterException
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
|
||||
/**
|
||||
* Class SheetNotFoundException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Writer\Exception
|
||||
*/
|
||||
class SheetNotFoundException extends WriterException
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception;
|
||||
|
||||
/**
|
||||
* Class WriterAlreadyOpenedException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Writer\Exception
|
||||
*/
|
||||
class WriterAlreadyOpenedException extends WriterException
|
||||
{
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
|
||||
/**
|
||||
* Class WriterNotOpenedException
|
||||
*
|
||||
* @api
|
||||
* @package Box\Spout\Writer\Exception
|
||||
*/
|
||||
class WriterNotOpenedException extends WriterException
|
||||
|
||||
@@ -1,50 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Helper\XLSX;
|
||||
|
||||
/**
|
||||
* Class ZipHelper
|
||||
* This class provides helper functions to create zip files
|
||||
*
|
||||
* @package Box\Spout\Writer\Helper\XLSX
|
||||
*/
|
||||
class ZipHelper
|
||||
{
|
||||
/**
|
||||
* Zips the given folder
|
||||
*
|
||||
* @param string $folderPath Path of the folder to be zipped
|
||||
* @param string $destinationPath Path where the zip file will be created
|
||||
* @return void
|
||||
*/
|
||||
public function zipFolder($folderPath, $destinationPath)
|
||||
{
|
||||
$zip = new \ZipArchive();
|
||||
if ($zip->open($destinationPath, \ZipArchive::CREATE)) {
|
||||
$this->addFolderToZip($zip, $folderPath);
|
||||
$zip->close();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \ZipArchive $zip
|
||||
* @param string $folderPath Path of the folder to add to the zip
|
||||
* @return void
|
||||
*/
|
||||
protected function addFolderToZip($zip, $folderPath)
|
||||
{
|
||||
$folderRealPath = realpath($folderPath) . DIRECTORY_SEPARATOR;
|
||||
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
|
||||
|
||||
foreach ($itemIterator as $itemInfo) {
|
||||
$itemRealPath = realpath($itemInfo->getPathname());
|
||||
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
|
||||
|
||||
if ($itemInfo->isFile()) {
|
||||
$zip->addFile($itemInfo->getPathname(), $itemLocalPath);
|
||||
} else if ($itemInfo->isDir()) {
|
||||
$zip->addEmptyDir($itemLocalPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,177 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Internal\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\InvalidArgumentException;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Writer\Helper\XLSX\CellHelper;
|
||||
|
||||
/**
|
||||
* Class Worksheet
|
||||
* Represents a worksheet within a XLSX file. The difference with the Sheet object is
|
||||
* that this class provides an interface to write data
|
||||
*
|
||||
* @package Box\Spout\Writer\Internal\XLSX
|
||||
*/
|
||||
class Worksheet
|
||||
{
|
||||
const SHEET_XML_FILE_HEADER = <<<EOD
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
|
||||
EOD;
|
||||
|
||||
/** @var \Box\Spout\Writer\Sheet The "external" sheet */
|
||||
protected $externalSheet;
|
||||
|
||||
/** @var string Path to the XML file that will contain the sheet data */
|
||||
protected $worksheetFilePath;
|
||||
|
||||
/** @var \Box\Spout\Writer\Helper\XLSX\SharedStringsHelper Helper to write shared strings */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var bool Whether inline or shared strings should be used */
|
||||
protected $shouldUseInlineStrings;
|
||||
|
||||
/** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */
|
||||
protected $stringsEscaper;
|
||||
|
||||
/** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */
|
||||
protected $sheetFilePointer;
|
||||
|
||||
/** @var int */
|
||||
protected $lastWrittenRowIndex = 0;
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Writer\Sheet $externalSheet The associated "external" sheet
|
||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
||||
* @param \Box\Spout\Writer\Helper\XLSX\SharedStringsHelper $sharedStringsHelper Helper for shared strings
|
||||
* @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
|
||||
*/
|
||||
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $shouldUseInlineStrings)
|
||||
{
|
||||
$this->externalSheet = $externalSheet;
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
|
||||
|
||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
|
||||
$this->worksheetFilePath = $worksheetFilesFolder . DIRECTORY_SEPARATOR . strtolower($this->externalSheet->getName()) . '.xml';
|
||||
$this->startSheet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the worksheet to accept data
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
protected function startSheet()
|
||||
{
|
||||
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
|
||||
$this->throwIfSheetFilePointerIsNotAvailable();
|
||||
|
||||
fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER . PHP_EOL);
|
||||
fwrite($this->sheetFilePointer, ' <sheetData>' . PHP_EOL);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Writer\Sheet The "external" sheet
|
||||
*/
|
||||
public function getExternalSheet()
|
||||
{
|
||||
return $this->externalSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int The index of the last written row
|
||||
*/
|
||||
public function getLastWrittenRowIndex()
|
||||
{
|
||||
return $this->lastWrittenRowIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int The ID of the worksheet
|
||||
*/
|
||||
public function getId()
|
||||
{
|
||||
// sheet index is zero-based, while ID is 1-based
|
||||
return $this->externalSheet->getIndex() + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the book has been created. Throws an exception if not created yet.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
protected function throwIfSheetFilePointerIsNotAvailable()
|
||||
{
|
||||
if (!$this->sheetFilePointer) {
|
||||
throw new IOException('Unable to open sheet for writing.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds data to the worksheet.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @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
|
||||
*/
|
||||
public function addRow($dataRow)
|
||||
{
|
||||
$cellNumber = 0;
|
||||
$rowIndex = $this->lastWrittenRowIndex + 1;
|
||||
$numCells = count($dataRow);
|
||||
|
||||
$data = ' <row r="' . $rowIndex . '" spans="1:' . $numCells . '">' . PHP_EOL;
|
||||
|
||||
foreach($dataRow as $cellValue) {
|
||||
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
||||
$data .= ' <c r="' . $columnIndex . $rowIndex . '"';
|
||||
|
||||
if (CellHelper::isNonEmptyString($cellValue)) {
|
||||
if ($this->shouldUseInlineStrings) {
|
||||
$data .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>' . PHP_EOL;
|
||||
} else {
|
||||
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
|
||||
$data .= ' t="s"><v>' . $sharedStringId . '</v></c>' . PHP_EOL;
|
||||
}
|
||||
} else if (CellHelper::isNumeric($cellValue) || CellHelper::isBoolean($cellValue)) {
|
||||
$data .= '><v>' . $cellValue . '</v></c>' . PHP_EOL;
|
||||
} else if (empty($cellValue)) {
|
||||
$data .= '/>' . PHP_EOL;
|
||||
} else {
|
||||
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
||||
}
|
||||
|
||||
$cellNumber++;
|
||||
}
|
||||
|
||||
$data .= ' </row>' . PHP_EOL;
|
||||
|
||||
$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++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the worksheet
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
fwrite($this->sheetFilePointer, ' </sheetData>' . PHP_EOL);
|
||||
fwrite($this->sheetFilePointer, '</worksheet>');
|
||||
fclose($this->sheetFilePointer);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS\Helper;
|
||||
|
||||
use Box\Spout\Writer\Common\Helper\ZipHelper;
|
||||
use Box\Spout\Writer\ODS\Internal\Worksheet;
|
||||
|
||||
/**
|
||||
* Class FileSystemHelper
|
||||
* This class provides helper functions to help with the file system operations
|
||||
* like files/folders creation & deletion for ODS files
|
||||
*
|
||||
* @package Box\Spout\Writer\ODS\Helper
|
||||
*/
|
||||
class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
||||
{
|
||||
const APP_NAME = 'Spout';
|
||||
const MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet';
|
||||
|
||||
const META_INF_FOLDER_NAME = 'META-INF';
|
||||
const SHEETS_CONTENT_TEMP_FOLDER_NAME = 'worksheets-temp';
|
||||
|
||||
const MANIFEST_XML_FILE_NAME = 'manifest.xml';
|
||||
const CONTENT_XML_FILE_NAME = 'content.xml';
|
||||
const META_XML_FILE_NAME = 'meta.xml';
|
||||
const MIMETYPE_FILE_NAME = 'mimetype';
|
||||
const STYLES_XML_FILE_NAME = 'styles.xml';
|
||||
|
||||
/** @var string Path to the root folder inside the temp folder where the files to create the ODS will be stored */
|
||||
protected $rootFolder;
|
||||
|
||||
/** @var string Path to the "META-INF" folder inside the root folder */
|
||||
protected $metaInfFolder;
|
||||
|
||||
/** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */
|
||||
protected $sheetsContentTempFolder;
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getRootFolder()
|
||||
{
|
||||
return $this->rootFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getSheetsContentTempFolder()
|
||||
{
|
||||
return $this->sheetsContentTempFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates all the folders needed to create a ODS file, as well as the files that won't change.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
|
||||
*/
|
||||
public function createBaseFilesAndFolders()
|
||||
{
|
||||
$this
|
||||
->createRootFolder()
|
||||
->createMetaInfoFolderAndFile()
|
||||
->createSheetsContentTempFolder()
|
||||
->createMetaFile()
|
||||
->createMimetypeFile();
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the folder that will be used as root
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
|
||||
*/
|
||||
protected function createRootFolder()
|
||||
{
|
||||
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "META-INF" folder under the root folder as well as the "manifest.xml" file in it
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or the "manifest.xml" file
|
||||
*/
|
||||
protected function createMetaInfoFolderAndFile()
|
||||
{
|
||||
$this->metaInfFolder = $this->createFolder($this->rootFolder, self::META_INF_FOLDER_NAME);
|
||||
|
||||
$this->createManifestFile();
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "manifest.xml" file under the "META-INF" folder (under root)
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
|
||||
*/
|
||||
protected function createManifestFile()
|
||||
{
|
||||
$manifestXmlFileContents = <<<EOD
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<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="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="meta.xml" manifest:media-type="text/xml"/>
|
||||
</manifest:manifest>
|
||||
EOD;
|
||||
|
||||
$this->createFileWithContents($this->metaInfFolder, self::MANIFEST_XML_FILE_NAME, $manifestXmlFileContents);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the temp folder where specific sheets content will be written to.
|
||||
* This folder is not part of the final ODS file and is only used to be able to jump between sheets.
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
|
||||
*/
|
||||
protected function createSheetsContentTempFolder()
|
||||
{
|
||||
$this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, self::SHEETS_CONTENT_TEMP_FOLDER_NAME);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "meta.xml" file under the root folder
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
|
||||
*/
|
||||
protected function createMetaFile()
|
||||
{
|
||||
$appName = self::APP_NAME;
|
||||
$createdDate = (new \DateTime())->format(\DateTime::W3C);
|
||||
|
||||
$metaXmlFileContents = <<<EOD
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<office:document-meta office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
<office:meta>
|
||||
<dc:creator>$appName</dc:creator>
|
||||
<meta:creation-date>$createdDate</meta:creation-date>
|
||||
<dc:date>$createdDate</dc:date>
|
||||
</office:meta>
|
||||
</office:document-meta>
|
||||
EOD;
|
||||
|
||||
$this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "mimetype" file under the root folder
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
|
||||
*/
|
||||
protected function createMimetypeFile()
|
||||
{
|
||||
$this->createFileWithContents($this->rootFolder, self::MIMETYPE_FILE_NAME, self::MIMETYPE);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the "content.xml" file under the root folder
|
||||
*
|
||||
* @param Worksheet[] $worksheets
|
||||
* @param StyleHelper $styleHelper
|
||||
* @return FileSystemHelper
|
||||
*/
|
||||
public function createContentFile($worksheets, $styleHelper)
|
||||
{
|
||||
$contentXmlFileContents = <<<EOD
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<office:document-content office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
EOD;
|
||||
|
||||
$contentXmlFileContents .= $styleHelper->getContentXmlFontFaceSectionContent();
|
||||
$contentXmlFileContents .= $styleHelper->getContentXmlAutomaticStylesSectionContent(count($worksheets));
|
||||
|
||||
$contentXmlFileContents .= '<office:body><office:spreadsheet>';
|
||||
|
||||
$this->createFileWithContents($this->rootFolder, self::CONTENT_XML_FILE_NAME, $contentXmlFileContents);
|
||||
|
||||
// Append sheets content to "content.xml"
|
||||
$contentXmlFilePath = $this->rootFolder . '/' . self::CONTENT_XML_FILE_NAME;
|
||||
$contentXmlHandle = fopen($contentXmlFilePath, 'a');
|
||||
|
||||
foreach ($worksheets as $worksheet) {
|
||||
// write the "<table:table>" node, with the final sheet's name
|
||||
fwrite($contentXmlHandle, $worksheet->getTableElementStartAsString());
|
||||
|
||||
$worksheetFilePath = $worksheet->getWorksheetFilePath();
|
||||
$this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle);
|
||||
|
||||
fwrite($contentXmlHandle, '</table:table>');
|
||||
}
|
||||
|
||||
$contentXmlFileContents = '</office:spreadsheet></office:body></office:document-content>';
|
||||
|
||||
fwrite($contentXmlHandle, $contentXmlFileContents);
|
||||
fclose($contentXmlHandle);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Streams the content of the file at the given path into the target resource.
|
||||
* Depending on which mode the target resource was created with, it will truncate then copy
|
||||
* or append the content to the target file.
|
||||
*
|
||||
* @param string $sourceFilePath Path of the file whose content will be copied
|
||||
* @param resource $targetResource Target resource that will receive the content
|
||||
* @return void
|
||||
*/
|
||||
protected function copyFileContentsToTarget($sourceFilePath, $targetResource)
|
||||
{
|
||||
$sourceHandle = fopen($sourceFilePath, 'r');
|
||||
stream_copy_to_stream($sourceHandle, $targetResource);
|
||||
fclose($sourceHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the temporary folder where sheets content was stored.
|
||||
*
|
||||
* @return FileSystemHelper
|
||||
*/
|
||||
public function deleteWorksheetTempFolder()
|
||||
{
|
||||
$this->deleteFolderRecursively($this->sheetsContentTempFolder);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Creates the "styles.xml" file under the root folder
|
||||
*
|
||||
* @param StyleHelper $styleHelper
|
||||
* @param int $numWorksheets Number of created worksheets
|
||||
* @return FileSystemHelper
|
||||
*/
|
||||
public function createStylesFile($styleHelper, $numWorksheets)
|
||||
{
|
||||
$stylesXmlFileContents = $styleHelper->getStylesXMLFileContent($numWorksheets);
|
||||
$this->createFileWithContents($this->rootFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Zips the root folder and streams the contents of the zip into the given stream
|
||||
*
|
||||
* @param resource $streamPointer Pointer to the stream to copy the zip
|
||||
* @return void
|
||||
*/
|
||||
public function zipRootFolderAndCopyToStream($streamPointer)
|
||||
{
|
||||
$zipHelper = new ZipHelper($this->rootFolder);
|
||||
|
||||
// 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
|
||||
$this->deleteFile($zipHelper->getZipFilePath());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,356 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS\Helper;
|
||||
|
||||
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
/**
|
||||
* Class StyleHelper
|
||||
* This class provides helper functions to manage styles
|
||||
*
|
||||
* @package Box\Spout\Writer\ODS\Helper
|
||||
*/
|
||||
class StyleHelper extends AbstractStyleHelper
|
||||
{
|
||||
/** @var string[] [FONT_NAME] => [] Map whose keys contain all the fonts used */
|
||||
protected $usedFontsSet = [];
|
||||
|
||||
/**
|
||||
* Registers the given style as a used style.
|
||||
* Duplicate styles won't be registered more than once.
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
|
||||
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
|
||||
*/
|
||||
public function registerStyle($style)
|
||||
{
|
||||
$this->usedFontsSet[$style->getFontName()] = true;
|
||||
return parent::registerStyle($style);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string[] List of used fonts name
|
||||
*/
|
||||
protected function getUsedFonts()
|
||||
{
|
||||
return array_keys($this->usedFontsSet);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the "styles.xml" file, given a list of styles.
|
||||
*
|
||||
* @param int $numWorksheets Number of worksheets created
|
||||
* @return string
|
||||
*/
|
||||
public function getStylesXMLFileContent($numWorksheets)
|
||||
{
|
||||
$content = <<<EOD
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
|
||||
<office:document-styles office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
|
||||
EOD;
|
||||
|
||||
$content .= $this->getFontFaceSectionContent();
|
||||
$content .= $this->getStylesSectionContent();
|
||||
$content .= $this->getAutomaticStylesSectionContent($numWorksheets);
|
||||
$content .= $this->getMasterStylesSectionContent($numWorksheets);
|
||||
|
||||
$content .= <<<EOD
|
||||
</office:document-styles>
|
||||
EOD;
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the "<office:font-face-decls>" section, inside "styles.xml" file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getFontFaceSectionContent()
|
||||
{
|
||||
$content = '<office:font-face-decls>';
|
||||
foreach ($this->getUsedFonts() as $fontName) {
|
||||
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
|
||||
}
|
||||
$content .= '</office:font-face-decls>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the "<office:styles>" section, inside "styles.xml" file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function getStylesSectionContent()
|
||||
{
|
||||
$defaultStyle = $this->getDefaultStyle();
|
||||
|
||||
return <<<EOD
|
||||
<office:styles>
|
||||
<number:number-style style:name="N0">
|
||||
<number:number number:min-integer-digits="1"/>
|
||||
</number:number-style>
|
||||
<style:style style:data-style-name="N0" style:family="table-cell" style:name="Default">
|
||||
<style:table-cell-properties fo:background-color="transparent" style:vertical-align="automatic"/>
|
||||
<style:text-properties fo:color="#{$defaultStyle->getFontColor()}"
|
||||
fo:font-size="{$defaultStyle->getFontSize()}pt" style:font-size-asian="{$defaultStyle->getFontSize()}pt" style:font-size-complex="{$defaultStyle->getFontSize()}pt"
|
||||
style:font-name="{$defaultStyle->getFontName()}" style:font-name-asian="{$defaultStyle->getFontName()}" style:font-name-complex="{$defaultStyle->getFontName()}"/>
|
||||
</style:style>
|
||||
</office:styles>
|
||||
EOD;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the "<office:automatic-styles>" section, inside "styles.xml" file.
|
||||
*
|
||||
* @param int $numWorksheets Number of worksheets created
|
||||
* @return string
|
||||
*/
|
||||
protected function getAutomaticStylesSectionContent($numWorksheets)
|
||||
{
|
||||
$content = '<office:automatic-styles>';
|
||||
|
||||
for ($i = 1; $i <= $numWorksheets; $i++) {
|
||||
$content .= <<<EOD
|
||||
<style:page-layout style:name="pm$i">
|
||||
<style:page-layout-properties style:first-page-number="continue" style:print="objects charts drawings" style:table-centering="none"/>
|
||||
<style:header-style/>
|
||||
<style:footer-style/>
|
||||
</style:page-layout>
|
||||
EOD;
|
||||
}
|
||||
|
||||
$content .= '</office:automatic-styles>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the content of the "<office:master-styles>" section, inside "styles.xml" file.
|
||||
*
|
||||
* @param int $numWorksheets Number of worksheets created
|
||||
* @return string
|
||||
*/
|
||||
protected function getMasterStylesSectionContent($numWorksheets)
|
||||
{
|
||||
$content = '<office:master-styles>';
|
||||
|
||||
for ($i = 1; $i <= $numWorksheets; $i++) {
|
||||
$content .= <<<EOD
|
||||
<style:master-page style:name="mp$i" style:page-layout-name="pm$i">
|
||||
<style:header/>
|
||||
<style:header-left style:display="false"/>
|
||||
<style:footer/>
|
||||
<style:footer-left style:display="false"/>
|
||||
</style:master-page>
|
||||
EOD;
|
||||
}
|
||||
|
||||
$content .= '</office:master-styles>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the contents of the "<office:font-face-decls>" section, inside "content.xml" file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function getContentXmlFontFaceSectionContent()
|
||||
{
|
||||
$content = '<office:font-face-decls>';
|
||||
foreach ($this->getUsedFonts() as $fontName) {
|
||||
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
|
||||
}
|
||||
$content .= '</office:font-face-decls>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of the "<office:automatic-styles>" section, inside "content.xml" file.
|
||||
*
|
||||
* @param int $numWorksheets Number of worksheets created
|
||||
* @return string
|
||||
*/
|
||||
public function getContentXmlAutomaticStylesSectionContent($numWorksheets)
|
||||
{
|
||||
$content = '<office:automatic-styles>';
|
||||
|
||||
foreach ($this->getRegisteredStyles() as $style) {
|
||||
$content .= $this->getStyleSectionContent($style);
|
||||
}
|
||||
|
||||
$content .= <<<EOD
|
||||
<style:style style:family="table-column" style:name="co1">
|
||||
<style:table-column-properties fo:break-before="auto"/>
|
||||
</style:style>
|
||||
<style:style style:family="table-row" style:name="ro1">
|
||||
<style:table-row-properties fo:break-before="auto" style:row-height="15pt" style:use-optimal-row-height="true"/>
|
||||
</style:style>
|
||||
EOD;
|
||||
|
||||
for ($i = 1; $i <= $numWorksheets; $i++) {
|
||||
$content .= <<<EOD
|
||||
<style:style style:family="table" style:master-page-name="mp$i" style:name="ta$i">
|
||||
<style:table-properties style:writing-mode="lr-tb" table:display="true"/>
|
||||
</style:style>
|
||||
EOD;
|
||||
}
|
||||
|
||||
$content .= '</office:automatic-styles>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of the "<style:style>" section, inside "<office:automatic-styles>" section
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
* @return string
|
||||
*/
|
||||
protected function getStyleSectionContent($style)
|
||||
{
|
||||
$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 .= $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()) {
|
||||
$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();
|
||||
if ($fontColor !== $defaultStyle->getFontColor()) {
|
||||
$content .= ' fo:color="#' . $fontColor . '"';
|
||||
}
|
||||
|
||||
$fontName = $style->getFontName();
|
||||
if ($fontName !== $defaultStyle->getFontName()) {
|
||||
$content .= ' style:font-name="' . $fontName . '" style:font-name-asian="' . $fontName . '" style:font-name-complex="' . $fontName . '"';
|
||||
}
|
||||
|
||||
$fontSize = $style->getFontSize();
|
||||
if ($fontSize !== $defaultStyle->getFontSize()) {
|
||||
$content .= ' fo:font-size="' . $fontSize . 'pt" style:font-size-asian="' . $fontSize . 'pt" style:font-size-complex="' . $fontSize . 'pt"';
|
||||
}
|
||||
|
||||
if ($style->isFontBold()) {
|
||||
$content .= ' fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"';
|
||||
}
|
||||
if ($style->isFontItalic()) {
|
||||
$content .= ' fo:font-style="italic" style:font-style-asian="italic" style:font-style-complex="italic"';
|
||||
}
|
||||
if ($style->isFontUnderline()) {
|
||||
$content .= ' style:text-underline-style="solid" style:text-underline-type="single"';
|
||||
}
|
||||
if ($style->isFontStrikethrough()) {
|
||||
$content .= ' style:text-line-through-style="solid"';
|
||||
}
|
||||
|
||||
$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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS\Internal;
|
||||
|
||||
use Box\Spout\Writer\Common\Internal\AbstractWorkbook;
|
||||
use Box\Spout\Writer\ODS\Helper\FileSystemHelper;
|
||||
use Box\Spout\Writer\ODS\Helper\StyleHelper;
|
||||
use Box\Spout\Writer\Common\Sheet;
|
||||
|
||||
/**
|
||||
* Class Workbook
|
||||
* Represents a workbook within a ODS file.
|
||||
* It provides the functions to work with worksheets.
|
||||
*
|
||||
* @package Box\Spout\Writer\ODS\Internal
|
||||
*/
|
||||
class Workbook extends AbstractWorkbook
|
||||
{
|
||||
/**
|
||||
* Maximum number of rows a ODS sheet can contain
|
||||
* @see https://ask.libreoffice.org/en/question/8631/upper-limit-to-number-of-rows-in-calc/
|
||||
*/
|
||||
protected static $maxRowsPerWorksheet = 1048576;
|
||||
|
||||
/** @var \Box\Spout\Writer\ODS\Helper\FileSystemHelper Helper to perform file system operations */
|
||||
protected $fileSystemHelper;
|
||||
|
||||
/** @var \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles */
|
||||
protected $styleHelper;
|
||||
|
||||
/**
|
||||
* @param string $tempFolder
|
||||
* @param bool $shouldCreateNewSheetsAutomatically
|
||||
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
|
||||
*/
|
||||
public function __construct($tempFolder, $shouldCreateNewSheetsAutomatically, $defaultRowStyle)
|
||||
{
|
||||
parent::__construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle);
|
||||
|
||||
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
|
||||
$this->fileSystemHelper->createBaseFilesAndFolders();
|
||||
|
||||
$this->styleHelper = new StyleHelper($defaultRowStyle);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles to ODS files
|
||||
*/
|
||||
protected function getStyleHelper()
|
||||
{
|
||||
return $this->styleHelper;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Maximum number of rows/columns a sheet can contain
|
||||
*/
|
||||
protected function getMaxRowsPerWorksheet()
|
||||
{
|
||||
return self::$maxRowsPerWorksheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new sheet in the workbook. The current sheet remains unchanged.
|
||||
*
|
||||
* @return Worksheet The created sheet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
|
||||
*/
|
||||
public function addNewSheet()
|
||||
{
|
||||
$newSheetIndex = count($this->worksheets);
|
||||
$sheet = new Sheet($newSheetIndex, $this->internalId);
|
||||
|
||||
$sheetsContentTempFolder = $this->fileSystemHelper->getSheetsContentTempFolder();
|
||||
$worksheet = new Worksheet($sheet, $sheetsContentTempFolder);
|
||||
$this->worksheets[] = $worksheet;
|
||||
|
||||
return $worksheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the workbook and all its associated sheets.
|
||||
* All the necessary files are written to disk and zipped together to create the ODS file.
|
||||
* All the temporary files are then deleted.
|
||||
*
|
||||
* @param resource $finalFilePointer Pointer to the ODS that will be created
|
||||
* @return void
|
||||
*/
|
||||
public function close($finalFilePointer)
|
||||
{
|
||||
/** @var Worksheet[] $worksheets */
|
||||
$worksheets = $this->worksheets;
|
||||
$numWorksheets = count($worksheets);
|
||||
|
||||
foreach ($worksheets as $worksheet) {
|
||||
$worksheet->close();
|
||||
}
|
||||
|
||||
// Finish creating all the necessary files before zipping everything together
|
||||
$this->fileSystemHelper
|
||||
->createContentFile($worksheets, $this->styleHelper)
|
||||
->deleteWorksheetTempFolder()
|
||||
->createStylesFile($this->styleHelper, $numWorksheets)
|
||||
->zipRootFolderAndCopyToStream($finalFilePointer);
|
||||
|
||||
$this->cleanupTempFolder();
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the root folder created in the temp folder and all its contents.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function cleanupTempFolder()
|
||||
{
|
||||
$xlsxRootFolder = $this->fileSystemHelper->getRootFolder();
|
||||
$this->fileSystemHelper->deleteFolderRecursively($xlsxRootFolder);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS\Internal;
|
||||
|
||||
use Box\Spout\Common\Exception\InvalidArgumentException;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Common\Helper\StringHelper;
|
||||
use Box\Spout\Writer\Common\Helper\CellHelper;
|
||||
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
|
||||
|
||||
/**
|
||||
* Class Worksheet
|
||||
* Represents a worksheet within a ODS file. The difference with the Sheet object is
|
||||
* that this class provides an interface to write data
|
||||
*
|
||||
* @package Box\Spout\Writer\ODS\Internal
|
||||
*/
|
||||
class Worksheet implements WorksheetInterface
|
||||
{
|
||||
/** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
|
||||
protected $externalSheet;
|
||||
|
||||
/** @var string Path to the XML file that will contain the sheet data */
|
||||
protected $worksheetFilePath;
|
||||
|
||||
/** @var \Box\Spout\Common\Escaper\ODS Strings escaper */
|
||||
protected $stringsEscaper;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\StringHelper To help with string manipulation */
|
||||
protected $stringHelper;
|
||||
|
||||
/** @var Resource Pointer to the temporary sheet data file (e.g. worksheets-temp/sheet1.xml) */
|
||||
protected $sheetFilePointer;
|
||||
|
||||
/** @var int Maximum number of columns among all the written rows */
|
||||
protected $maxNumColumns = 1;
|
||||
|
||||
/** @var int Index of the last written row */
|
||||
protected $lastWrittenRowIndex = 0;
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
||||
* @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
|
||||
*/
|
||||
public function __construct($externalSheet, $worksheetFilesFolder)
|
||||
{
|
||||
$this->externalSheet = $externalSheet;
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
|
||||
|
||||
$this->stringHelper = new StringHelper();
|
||||
|
||||
$this->startSheet();
|
||||
}
|
||||
|
||||
/**
|
||||
* Prepares the worksheet to accept data
|
||||
* The XML file does not contain the "<table:table>" node as it contains the sheet's name
|
||||
* which may change during the execution of the program. It will be added at the end.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
protected function startSheet()
|
||||
{
|
||||
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
|
||||
$this->throwIfSheetFilePointerIsNotAvailable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the book has been created. Throws an exception if not created yet.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
protected function throwIfSheetFilePointerIsNotAvailable()
|
||||
{
|
||||
if (!$this->sheetFilePointer) {
|
||||
throw new IOException('Unable to open sheet for writing.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Path to the temporary sheet content XML file
|
||||
*/
|
||||
public function getWorksheetFilePath()
|
||||
{
|
||||
return $this->worksheetFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the table XML root node as string.
|
||||
*
|
||||
* @return string <table> node as string
|
||||
*/
|
||||
public function getTableElementStartAsString()
|
||||
{
|
||||
$escapedSheetName = $this->stringsEscaper->escape($this->externalSheet->getName());
|
||||
$tableStyleName = 'ta' . ($this->externalSheet->getIndex() + 1);
|
||||
|
||||
$tableElement = '<table:table table:style-name="' . $tableStyleName . '" table:name="' . $escapedSheetName . '">';
|
||||
$tableElement .= '<table:table-column table:default-cell-style-name="ce1" table:style-name="co1" table:number-columns-repeated="' . $this->maxNumColumns . '"/>';
|
||||
|
||||
return $tableElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
|
||||
*/
|
||||
public function getExternalSheet()
|
||||
{
|
||||
return $this->externalSheet;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int The index of the last written row
|
||||
*/
|
||||
public function getLastWrittenRowIndex()
|
||||
{
|
||||
return $this->lastWrittenRowIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds data 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
|
||||
*/
|
||||
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
|
||||
$cellsCount = count($dataRow);
|
||||
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
|
||||
|
||||
$data = '<table:table-row table:style-name="ro1">';
|
||||
|
||||
$currentCellIndex = 0;
|
||||
$nextCellIndex = 1;
|
||||
|
||||
for ($i = 0; $i < $cellsCount; $i++) {
|
||||
$currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (!isset($dataRowWithNumericIndexes[$nextCellIndex]) ||
|
||||
$currentCellValue !== $dataRowWithNumericIndexes[$nextCellIndex]) {
|
||||
|
||||
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
|
||||
$data .= $this->getCellXML($currentCellValue, $styleIndex, $numTimesValueRepeated);
|
||||
|
||||
$currentCellIndex = $nextCellIndex;
|
||||
}
|
||||
|
||||
$nextCellIndex++;
|
||||
}
|
||||
|
||||
$data .= '</table:table-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++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell XML content, given its value.
|
||||
*
|
||||
* @param mixed $cellValue The value to be written
|
||||
* @param int $styleIndex Index of the used style
|
||||
* @param int $numTimesValueRepeated Number of times the value is consecutively repeated
|
||||
* @return string The cell XML content
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||
*/
|
||||
protected function getCellXML($cellValue, $styleIndex, $numTimesValueRepeated)
|
||||
{
|
||||
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
|
||||
|
||||
if ($numTimesValueRepeated !== 1) {
|
||||
$data .= ' table:number-columns-repeated="' . $numTimesValueRepeated . '"';
|
||||
}
|
||||
|
||||
if (CellHelper::isNonEmptyString($cellValue)) {
|
||||
$data .= ' office:value-type="string" calcext:value-type="string">';
|
||||
|
||||
$cellValueLines = explode("\n", $cellValue);
|
||||
foreach ($cellValueLines as $cellValueLine) {
|
||||
$data .= '<text:p>' . $this->stringsEscaper->escape($cellValueLine) . '</text:p>';
|
||||
}
|
||||
|
||||
$data .= '</table:table-cell>';
|
||||
} else if (CellHelper::isBoolean($cellValue)) {
|
||||
$data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="' . $cellValue . '">';
|
||||
$data .= '<text:p>' . $cellValue . '</text:p>';
|
||||
$data .= '</table:table-cell>';
|
||||
} else if (CellHelper::isNumeric($cellValue)) {
|
||||
$data .= ' office:value-type="float" calcext:value-type="float" office:value="' . $cellValue . '">';
|
||||
$data .= '<text:p>' . $cellValue . '</text:p>';
|
||||
$data .= '</table:table-cell>';
|
||||
} else if (empty($cellValue)) {
|
||||
$data .= '/>';
|
||||
} else {
|
||||
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
||||
}
|
||||
|
||||
return $data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the worksheet
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!is_resource($this->sheetFilePointer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fclose($this->sheetFilePointer);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS;
|
||||
|
||||
use Box\Spout\Writer\AbstractMultiSheetsWriter;
|
||||
use Box\Spout\Writer\Common;
|
||||
use Box\Spout\Writer\ODS\Internal\Workbook;
|
||||
|
||||
/**
|
||||
* Class Writer
|
||||
* This class provides base support to write data to ODS files
|
||||
*
|
||||
* @package Box\Spout\Writer\ODS
|
||||
*/
|
||||
class Writer extends AbstractMultiSheetsWriter
|
||||
{
|
||||
/** @var string Content-Type value for the header */
|
||||
protected static $headerContentType = 'application/vnd.oasis.opendocument.spreadsheet';
|
||||
|
||||
/** @var string Temporary folder where the files to create the ODS will be stored */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var Internal\Workbook The workbook for the ODS file */
|
||||
protected $book;
|
||||
|
||||
/**
|
||||
* Sets a custom temporary folder for creating intermediate files/folders.
|
||||
* This must be set before opening the writer.
|
||||
*
|
||||
* @api
|
||||
* @param string $tempFolder Temporary folder where the files to create the ODS will be stored
|
||||
* @return Writer
|
||||
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
|
||||
*/
|
||||
public function setTempFolder($tempFolder)
|
||||
{
|
||||
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
|
||||
|
||||
$this->tempFolder = $tempFolder;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Configures the write and sets the current sheet pointer to a new sheet.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to open the file for writing
|
||||
*/
|
||||
protected function openWriter()
|
||||
{
|
||||
$tempFolder = ($this->tempFolder) ? : sys_get_temp_dir();
|
||||
$this->book = new Workbook($tempFolder, $this->shouldCreateNewSheetsAutomatically, $this->defaultRowStyle);
|
||||
$this->book->addNewSheetAndMakeItCurrent();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Internal\Workbook The workbook representing the file to be written
|
||||
*/
|
||||
protected function getWorkbook()
|
||||
{
|
||||
return $this->book;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds data to the currently opened writer.
|
||||
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
|
||||
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
|
||||
*/
|
||||
protected function addRowToWriter(array $dataRow, $style)
|
||||
{
|
||||
$this->throwIfBookIsNotAvailable();
|
||||
$this->book->addRowToCurrentWorksheet($dataRow, $style);
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the writer, preventing any additional writing.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
protected function closeWriter()
|
||||
{
|
||||
if ($this->book) {
|
||||
$this->book->close($this->filePointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* Represents a worksheet within a XLSX file
|
||||
*
|
||||
* @package Box\Spout\Writer
|
||||
*/
|
||||
class Sheet
|
||||
{
|
||||
const DEFAULT_SHEET_NAME_PREFIX = 'Sheet';
|
||||
|
||||
/** @var int Index of the sheet, based on order of creation (zero-based) */
|
||||
protected $index;
|
||||
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/**
|
||||
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
|
||||
*/
|
||||
function __construct($sheetIndex)
|
||||
{
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = self::DEFAULT_SHEET_NAME_PREFIX . ($sheetIndex + 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int Index of the sheet, based on order of creation (zero-based)
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return $this->index;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Name of the sheet
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name Name of the sheet
|
||||
* @return \Box\Spout\Writer\Sheet
|
||||
*/
|
||||
public function setName($name)
|
||||
{
|
||||
$this->name = $name;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
Loaded 100 of 225 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user