Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
No files matched your search
@@ -0,0 +1,11 @@
|
||||
# Ignore all test and documentation for archive
|
||||
/tests export-ignore
|
||||
/.editorconfig export-ignore
|
||||
/.gitattributes export-ignore
|
||||
/.gitignore export-ignore
|
||||
/.scrutinizer.yml export-ignore
|
||||
/.travis.yml export-ignore
|
||||
/composer.lock export-ignore
|
||||
/CONTRIBUTING.md export-ignore
|
||||
/logo.png export-ignore
|
||||
/phpunit.xml export-ignore
|
||||
+6
-3
@@ -15,10 +15,13 @@ tools:
|
||||
php_pdepend: true
|
||||
php_loc:
|
||||
enabled: true
|
||||
excluded_dirs: [vendor, tests]
|
||||
php_cpd:
|
||||
filter:
|
||||
paths: ['src']
|
||||
php_cpd: false # Must be disabled to use php_sim instead
|
||||
php_sim:
|
||||
enabled: true
|
||||
excluded_dirs: [vendor, tests]
|
||||
filter:
|
||||
paths: ['src']
|
||||
|
||||
build_failure_conditions:
|
||||
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
|
||||
|
||||
+3
-2
@@ -16,7 +16,8 @@ install:
|
||||
|
||||
script:
|
||||
- mkdir -p build/logs
|
||||
- php vendor/bin/phpunit --coverage-clover build/logs/clover.xml
|
||||
- php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
|
||||
|
||||
after_script:
|
||||
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml; fi
|
||||
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi
|
||||
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover; fi
|
||||
@@ -3,12 +3,12 @@
|
||||
[](https://packagist.org/packages/box/spout)
|
||||
[](http://opensource.box.com/badges)
|
||||
[](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)
|
||||
[](https://packagist.org/packages/box/spout)
|
||||
|
||||
Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
|
||||
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 10MB).
|
||||
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 3MB).
|
||||
|
||||
Join the community and come discuss about Spout: [](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
|
||||
@@ -110,6 +110,7 @@ use Box\Spout\Common\Type;
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->setFieldDelimiter('|');
|
||||
$reader->setFieldEnclosure('@');
|
||||
$reader->setEndOfLineCharacter("\r");
|
||||
```
|
||||
|
||||
Additionally, if you need to read non UTF-8 files, you can specify the encoding of your file this way:
|
||||
@@ -117,14 +118,22 @@ Additionally, if you need to read non UTF-8 files, you can specify the encoding
|
||||
$reader->setEncoding('UTF-16LE');
|
||||
```
|
||||
|
||||
The writer always generate CSV files encoded in UTF-8, with a BOM.
|
||||
By default, the writer generates CSV files encoded in UTF-8, with a BOM.
|
||||
It is however possible to not include the BOM:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
$writer->setShouldAddBOM(false);
|
||||
```
|
||||
|
||||
|
||||
### Configuring the XLSX and ODS writers
|
||||
### Configuring the XLSX and ODS readers and writers
|
||||
|
||||
#### Row styling
|
||||
|
||||
It is possible to apply some formatting options to a row. Spout supports fonts as well as alignment styles.
|
||||
It is possible to apply some formatting options to a row. Spout supports fonts, background, borders as well as alignment styles.
|
||||
|
||||
```php
|
||||
use Box\Spout\Common\Type;
|
||||
@@ -137,6 +146,7 @@ $style = (new StyleBuilder())
|
||||
->setFontSize(15)
|
||||
->setFontColor(Color::BLUE)
|
||||
->setShouldWrapText()
|
||||
->setBackgroundColor(Color::YELLOW)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
@@ -149,19 +159,57 @@ $writer->addRowsWithStyle($multipleRows, $style); // style will be applied to al
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
Adding borders to a row requires a ```Border``` object.
|
||||
|
||||
```php
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
|
||||
$border = (new BorderBuilder())
|
||||
->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED)
|
||||
->build();
|
||||
|
||||
$style = (new StyleBuilder())
|
||||
->setBorder($border)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->openToFile($filePath);
|
||||
|
||||
$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style);
|
||||
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
Spout will use a default style for all created rows. This style can be overridden this way:
|
||||
|
||||
```php
|
||||
$defaultStyle = (new StyleBuilder())
|
||||
->setFontName('Arial')
|
||||
->setFontSize(11)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setDefaultRowStyle($defaultStyle)
|
||||
->openToFile($filePath);
|
||||
```
|
||||
|
||||
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
|
||||
|
||||
Category | Property | API
|
||||
----------|---------------|---------------------------------------
|
||||
Font | Bold | `StyleBuilder::setFontBold()`
|
||||
| Italic | `StyleBuilder::setFontItalic()`
|
||||
| Underline | `StyleBuilder::setFontUnderline()`
|
||||
| Strikethrough | `StyleBuilder::setFontStrikethrough()`
|
||||
| Font name | `StyleBuilder::setFontName('Arial')`
|
||||
| Font size | `StyleBuilder::setFontSize(14)`
|
||||
| Font color | `StyleBuilder::setFontSize(Color::BLUE)`<br>`StyleBuilder::setFontSize(Color::rgb(0, 128, 255))`
|
||||
Alignment | Wrap text | `StyleBuilder::setShouldWrapText()`
|
||||
|
||||
| Category | Property | API
|
||||
|-----------|---------------|---------------------------------------
|
||||
| Font | Bold | `StyleBuilder::setFontBold()`
|
||||
| | Italic | `StyleBuilder::setFontItalic()`
|
||||
| | Underline | `StyleBuilder::setFontUnderline()`
|
||||
| | Strikethrough | `StyleBuilder::setFontStrikethrough()`
|
||||
| | Font name | `StyleBuilder::setFontName('Arial')`
|
||||
| | Font size | `StyleBuilder::setFontSize(14)`
|
||||
| | Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))`
|
||||
| Alignment | Wrap text | `StyleBuilder::setShouldWrapText(true|false)`
|
||||
|
||||
#### New sheet creation
|
||||
|
||||
@@ -207,6 +255,20 @@ $writer->setShouldUseInlineStrings(false); // will use shared strings
|
||||
> Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings!
|
||||
|
||||
|
||||
#### Date/Time formatting
|
||||
|
||||
When reading a spreadsheet containing dates or times, Spout returns the values by default as DateTime objects.
|
||||
It is possible to change this behavior and have a formatted date returned instead (e.g. "2016-11-29 1:22 AM"). The format of the date corresponds to what is specified in the spreadsheet.
|
||||
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->setShouldFormatDates(false); // default value
|
||||
$reader->setShouldFormatDates(true); // will return formatted dates
|
||||
```
|
||||
|
||||
### Playing with sheets
|
||||
|
||||
When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet:
|
||||
@@ -287,7 +349,7 @@ For information, the performance tests take about 30 minutes to run (processing
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
#### How can Spout handle such large data sets and still use less than 10MB of memory?
|
||||
#### How can Spout handle such large data sets and still use less than 3MB of memory?
|
||||
|
||||
When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed.
|
||||
|
||||
|
||||
+2
-3
@@ -18,8 +18,7 @@
|
||||
"ext-simplexml": "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": ">=3.7",
|
||||
"scrutinizer/ocular": "~1.1"
|
||||
"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)",
|
||||
@@ -32,7 +31,7 @@
|
||||
},
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "2.5.x-dev"
|
||||
"dev-master": "2.8.x-dev"
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Generated
+74
-741
File diff suppressed because it is too large.
Load diff
@@ -2,6 +2,8 @@
|
||||
|
||||
namespace Box\Spout\Common\Escaper;
|
||||
|
||||
use Box\Spout\Common\Singleton;
|
||||
|
||||
/**
|
||||
* Class ODS
|
||||
* Provides functions to escape and unescape data for ODS files
|
||||
@@ -10,6 +12,8 @@ namespace Box\Spout\Common\Escaper;
|
||||
*/
|
||||
class ODS implements EscaperInterface
|
||||
{
|
||||
use Singleton;
|
||||
|
||||
/**
|
||||
* Escapes the given string to make it compatible with XLSX
|
||||
*
|
||||
@@ -18,7 +22,26 @@ class ODS implements EscaperInterface
|
||||
*/
|
||||
public function escape($string)
|
||||
{
|
||||
return htmlspecialchars($string, ENT_QUOTES);
|
||||
if (defined('ENT_DISALLOWED')) {
|
||||
// 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
|
||||
// Otherwise 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_QUOTES | ENT_DISALLOWED);
|
||||
} else {
|
||||
// We are on hhvm or any other engine that does not support ENT_DISALLOWED
|
||||
$escapedString = htmlspecialchars($string, ENT_QUOTES);
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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,15 +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;
|
||||
|
||||
/** @var string[] Map containing control characters to be escaped (value) and their escaped value (key) */
|
||||
protected $controlCharactersEscapingReverseMap;
|
||||
|
||||
/**
|
||||
*
|
||||
* Initializes the singleton instance
|
||||
*/
|
||||
public function __construct()
|
||||
protected function init()
|
||||
{
|
||||
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
|
||||
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
|
||||
$this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -49,6 +61,20 @@ class XLSX implements EscaperInterface
|
||||
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.
|
||||
@@ -62,14 +88,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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -92,7 +118,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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -122,6 +156,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);
|
||||
|
||||
@@ -59,7 +59,7 @@ class EncodingHelper
|
||||
{
|
||||
$byteOffsetToSkipBom = 0;
|
||||
|
||||
if ($this->hasBom($filePointer, $encoding)) {
|
||||
if ($this->hasBOM($filePointer, $encoding)) {
|
||||
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
|
||||
|
||||
// we skip the N first bytes
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -252,6 +252,19 @@ 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()
|
||||
@@ -281,32 +294,14 @@ class GlobalFunctionsHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function stream_get_line()
|
||||
* @see stream_get_line()
|
||||
* Wrapper around global function stream_get_wrappers()
|
||||
* @see stream_get_wrappers()
|
||||
*
|
||||
* @param resource $handle
|
||||
* @param int $length
|
||||
* @param string|void $ending
|
||||
* @return string|bool
|
||||
*/
|
||||
public function stream_get_line($handle, $length, $ending = null)
|
||||
{
|
||||
return stream_get_line($handle, $length, $ending);
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrapper around global function str_getcsv()
|
||||
* @see str_getcsv()
|
||||
*
|
||||
* @param string $input
|
||||
* @param string|void $delimiter
|
||||
* @param string|void $enclosure
|
||||
* @param string|void $escape
|
||||
* @return array
|
||||
*/
|
||||
public function str_getcsv($input, $delimiter = null, $enclosure = null, $escape = null)
|
||||
public function stream_get_wrappers()
|
||||
{
|
||||
return str_getcsv($input, $delimiter, $enclosure, $escape);
|
||||
return stream_get_wrappers();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common;
|
||||
|
||||
/**
|
||||
* Class Singleton
|
||||
* Defines a class as a singleton.
|
||||
*
|
||||
* @package Box\Spout\Common
|
||||
*/
|
||||
trait Singleton
|
||||
{
|
||||
protected static $instance;
|
||||
|
||||
/**
|
||||
* @return static
|
||||
*/
|
||||
final public static function getInstance()
|
||||
{
|
||||
return isset(static::$instance)
|
||||
? static::$instance
|
||||
: static::$instance = new static;
|
||||
}
|
||||
|
||||
/**
|
||||
* Singleton constructor.
|
||||
*/
|
||||
final private function __construct()
|
||||
{
|
||||
$this->init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializes the singleton
|
||||
* @return void
|
||||
*/
|
||||
protected function init() {}
|
||||
|
||||
final private function __wakeup() {}
|
||||
final private function __clone() {}
|
||||
}
|
||||
@@ -19,6 +19,23 @@ abstract class AbstractReader implements ReaderInterface
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
|
||||
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
|
||||
*
|
||||
@@ -32,7 +49,7 @@ abstract class AbstractReader implements ReaderInterface
|
||||
*
|
||||
* @return \Iterator To iterate over sheets
|
||||
*/
|
||||
abstract public function getConcreteSheetIterator();
|
||||
abstract protected function getConcreteSheetIterator();
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
@@ -42,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)
|
||||
@@ -51,6 +68,32 @@ 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.
|
||||
@@ -62,6 +105,10 @@ abstract class AbstractReader implements ReaderInterface
|
||||
*/
|
||||
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)) {
|
||||
@@ -72,8 +119,7 @@ abstract class AbstractReader implements ReaderInterface
|
||||
}
|
||||
|
||||
try {
|
||||
// Need to use realpath to fix "Can't open file" on some Windows setup
|
||||
$fileRealPath = realpath($filePath);
|
||||
$fileRealPath = $this->getFileRealPath($filePath);
|
||||
$this->openReader($fileRealPath);
|
||||
$this->isStreamOpened = true;
|
||||
} catch (\Exception $exception) {
|
||||
@@ -81,6 +127,67 @@ abstract class AbstractReader implements ReaderInterface
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the real path of the given path.
|
||||
* If the given path is a valid stream wrapper, returns the path unchanged.
|
||||
*
|
||||
* @param string $filePath
|
||||
* @return string
|
||||
*/
|
||||
protected function getFileRealPath($filePath)
|
||||
{
|
||||
if ($this->isSupportedStreamWrapper($filePath)) {
|
||||
return $filePath;
|
||||
}
|
||||
|
||||
// Need to use realpath to fix "Can't open file" on some Windows setup
|
||||
return realpath($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the scheme of the custom stream wrapper, if the path indicates a stream wrapper is used.
|
||||
* For example, php://temp => php, s3://path/to/file => s3...
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return string|null The stream wrapper scheme or NULL if not a stream wrapper
|
||||
*/
|
||||
protected function getStreamWrapperScheme($filePath)
|
||||
{
|
||||
$streamScheme = null;
|
||||
if (preg_match('/^(\w+):\/\//', $filePath, $matches)) {
|
||||
$streamScheme = $matches[1];
|
||||
}
|
||||
return $streamScheme;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given path is an unsupported stream wrapper
|
||||
* (like local path, php://temp, mystream://foo/bar...).
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return bool Whether the given path is an unsupported stream wrapper
|
||||
*/
|
||||
protected function isStreamWrapper($filePath)
|
||||
{
|
||||
return ($this->getStreamWrapperScheme($filePath) !== null);
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the given path is an supported stream wrapper
|
||||
* (like php://temp, mystream://foo/bar...).
|
||||
* If the given path is a local path, returns true.
|
||||
*
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return bool Whether the given path is an supported stream wrapper
|
||||
*/
|
||||
protected function isSupportedStreamWrapper($filePath)
|
||||
{
|
||||
$streamScheme = $this->getStreamWrapperScheme($filePath);
|
||||
return ($streamScheme !== null) ?
|
||||
in_array($streamScheme, $this->globalFunctionsHelper->stream_get_wrappers()) :
|
||||
true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if a path is a PHP stream (like php://output, php://memory, ...)
|
||||
*
|
||||
@@ -89,7 +196,8 @@ abstract class AbstractReader implements ReaderInterface
|
||||
*/
|
||||
protected function isPhpStream($filePath)
|
||||
{
|
||||
return (strpos($filePath, 'php://') === 0);
|
||||
$streamScheme = $this->getStreamWrapperScheme($filePath);
|
||||
return ($streamScheme === 'php');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -4,7 +4,6 @@ namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Reader\AbstractReader;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Common\Helper\EncodingHelper;
|
||||
|
||||
/**
|
||||
* Class Reader
|
||||
@@ -20,14 +19,21 @@ class Reader extends AbstractReader
|
||||
/** @var SheetIterator To iterator over the CSV unique "sheet" */
|
||||
protected $sheetIterator;
|
||||
|
||||
/** @var string Defines the character used to delimit fields (one character only) */
|
||||
protected $fieldDelimiter = ',';
|
||||
/** @var string Original value for the "auto_detect_line_endings" INI value */
|
||||
protected $originalAutoDetectLineEndings;
|
||||
|
||||
/** @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;
|
||||
/**
|
||||
* 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.
|
||||
@@ -38,7 +44,7 @@ class Reader extends AbstractReader
|
||||
*/
|
||||
public function setFieldDelimiter($fieldDelimiter)
|
||||
{
|
||||
$this->fieldDelimiter = $fieldDelimiter;
|
||||
$this->getOptions()->setFieldDelimiter($fieldDelimiter);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -51,7 +57,7 @@ class Reader extends AbstractReader
|
||||
*/
|
||||
public function setFieldEnclosure($fieldEnclosure)
|
||||
{
|
||||
$this->fieldEnclosure = $fieldEnclosure;
|
||||
$this->getOptions()->setFieldEnclosure($fieldEnclosure);
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -64,10 +70,33 @@ class Reader extends AbstractReader
|
||||
*/
|
||||
public function setEncoding($encoding)
|
||||
{
|
||||
$this->encoding = $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.
|
||||
@@ -78,6 +107,9 @@ class Reader extends AbstractReader
|
||||
*/
|
||||
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.");
|
||||
@@ -85,9 +117,7 @@ class Reader extends AbstractReader
|
||||
|
||||
$this->sheetIterator = new SheetIterator(
|
||||
$this->filePointer,
|
||||
$this->fieldDelimiter,
|
||||
$this->fieldEnclosure,
|
||||
$this->encoding,
|
||||
$this->getOptions(),
|
||||
$this->globalFunctionsHelper
|
||||
);
|
||||
}
|
||||
@@ -97,7 +127,7 @@ class Reader extends AbstractReader
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
public function getConcreteSheetIterator()
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
@@ -113,5 +143,7 @@ class Reader extends AbstractReader
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -14,10 +14,9 @@ use Box\Spout\Common\Helper\EncodingHelper;
|
||||
class RowIterator implements IteratorInterface
|
||||
{
|
||||
/**
|
||||
* If no value is given to stream_get_line(), it defaults to 8192 (which may be too low).
|
||||
* Alignement with other functions like fgets() is discussed here: https://bugs.php.net/bug.php?id=48421
|
||||
* Value passed to fgetcsv. 0 means "unlimited" (slightly slower but accomodates for very long lines).
|
||||
*/
|
||||
const MAX_READ_BYTES_PER_LINE = 32768;
|
||||
const MAX_READ_BYTES_PER_LINE = 0;
|
||||
|
||||
/** @var resource Pointer to the CSV file to read */
|
||||
protected $filePointer;
|
||||
@@ -40,6 +39,12 @@ class RowIterator implements IteratorInterface
|
||||
/** @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;
|
||||
|
||||
@@ -51,17 +56,17 @@ class RowIterator implements IteratorInterface
|
||||
|
||||
/**
|
||||
* @param resource $filePointer Pointer to the CSV file to read
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @param string $encoding Encoding of the CSV file to be read
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->filePointer = $filePointer;
|
||||
$this->fieldDelimiter = $fieldDelimiter;
|
||||
$this->fieldEnclosure = $fieldEnclosure;
|
||||
$this->encoding = $encoding;
|
||||
$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);
|
||||
@@ -101,7 +106,7 @@ class RowIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
@@ -109,7 +114,7 @@ class RowIterator implements IteratorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element. Empty rows are skipped.
|
||||
* Move forward to next element. Reads data for the next unprocessed row.
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
@@ -119,48 +124,82 @@ class RowIterator implements IteratorInterface
|
||||
{
|
||||
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||
|
||||
if ($this->hasReachedEndOfFile) {
|
||||
return;
|
||||
}
|
||||
|
||||
do {
|
||||
$lineData = false;
|
||||
$utf8EncodedLineData = $this->getNextUTF8EncodedLine();
|
||||
if ($utf8EncodedLineData !== false) {
|
||||
$lineData = $this->globalFunctionsHelper->str_getcsv($utf8EncodedLineData, $this->fieldDelimiter, $this->fieldEnclosure);
|
||||
}
|
||||
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||
} while (($lineData === false && !$hasNowReachedEndOfFile) || $this->isEmptyLine($lineData));
|
||||
|
||||
if ($lineData !== false) {
|
||||
$this->rowDataBuffer = $lineData;
|
||||
$this->numReadRows++;
|
||||
} else {
|
||||
// If we reach this point, it means end of file was reached.
|
||||
// This happens when the last lines are empty lines.
|
||||
$this->hasReachedEndOfFile = $hasNowReachedEndOfFile;
|
||||
if (!$this->hasReachedEndOfFile) {
|
||||
$this->readDataForNextRow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next line, converted if necessary to UTF-8.
|
||||
* Neither fgets nor fgetcsv don't work with non UTF-8 data... so we need to do some things manually.
|
||||
*
|
||||
* @return string|false The next line for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||
*/
|
||||
protected function getNextUTF8EncodedLine()
|
||||
protected function readDataForNextRow()
|
||||
{
|
||||
// Read until the EOL delimiter or EOF is reached. The delimiter's encoding needs to match the CSV's encoding.
|
||||
$encodedEOLDelimiter = $this->getEncodedEOLDelimiter();
|
||||
$encodedLineData = $this->globalFunctionsHelper->stream_get_line($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $encodedEOLDelimiter);
|
||||
do {
|
||||
$rowData = $this->getNextUTF8EncodedRow();
|
||||
} while ($this->shouldReadNextRow($rowData));
|
||||
|
||||
// If the line could have been read, it can be converted to UTF-8
|
||||
$utf8EncodedLineData = ($encodedLineData !== false) ?
|
||||
$this->encodingHelper->attemptConversionToUTF8($encodedLineData, $this->encoding) :
|
||||
false;
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
return $utf8EncodedLineData;
|
||||
/**
|
||||
* @param array|bool $currentRowData
|
||||
* @return bool Whether the data for the current row can be returned or if we need to keep reading
|
||||
*/
|
||||
protected function shouldReadNextRow($currentRowData)
|
||||
{
|
||||
$hasSuccessfullyFetchedRowData = ($currentRowData !== false);
|
||||
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
|
||||
$isEmptyLine = $this->isEmptyLine($currentRowData);
|
||||
|
||||
return (
|
||||
(!$hasSuccessfullyFetchedRowData && !$hasNowReachedEndOfFile) ||
|
||||
(!$this->shouldPreserveEmptyRows && $isEmptyLine)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the next row, converted if necessary to UTF-8.
|
||||
* As fgetcsv() does not manage correctly encoding for non UTF-8 data,
|
||||
* we remove manually whitespace with ltrim or rtrim (depending on the order of the bytes)
|
||||
*
|
||||
* @return array|false The row for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
|
||||
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
|
||||
*/
|
||||
protected function getNextUTF8EncodedRow()
|
||||
{
|
||||
$encodedRowData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $this->fieldDelimiter, $this->fieldEnclosure);
|
||||
if ($encodedRowData === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
foreach ($encodedRowData as $cellIndex => $cellValue) {
|
||||
switch($this->encoding) {
|
||||
case EncodingHelper::ENCODING_UTF16_LE:
|
||||
case EncodingHelper::ENCODING_UTF32_LE:
|
||||
// remove whitespace from the beginning of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
|
||||
$cellValue = ltrim($cellValue);
|
||||
break;
|
||||
|
||||
case EncodingHelper::ENCODING_UTF16_BE:
|
||||
case EncodingHelper::ENCODING_UTF32_BE:
|
||||
// remove whitespace from the end of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
|
||||
$cellValue = rtrim($cellValue);
|
||||
break;
|
||||
}
|
||||
|
||||
$encodedRowData[$cellIndex] = $this->encodingHelper->attemptConversionToUTF8($cellValue, $this->encoding);
|
||||
}
|
||||
|
||||
return $encodedRowData;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -172,14 +211,14 @@ class RowIterator implements IteratorInterface
|
||||
protected function getEncodedEOLDelimiter()
|
||||
{
|
||||
if (!isset($this->encodedEOLDelimiter)) {
|
||||
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8("\n", $this->encoding);
|
||||
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8($this->inputEOLDelimiter, $this->encoding);
|
||||
}
|
||||
|
||||
return $this->encodedEOLDelimiter;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $lineData Array containing the cells value for the line
|
||||
* @param array|bool $lineData Array containing the cells value for the line
|
||||
* @return bool Whether the given line is empty
|
||||
*/
|
||||
protected function isEmptyLine($lineData)
|
||||
|
||||
@@ -16,14 +16,12 @@ class Sheet implements SheetInterface
|
||||
|
||||
/**
|
||||
* @param resource $filePointer Pointer to the CSV file to read
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @param string $encoding Encoding of the CSV file to be read
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
|
||||
$this->rowIterator = new RowIterator($filePointer, $options, $globalFunctionsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -20,14 +20,12 @@ class SheetIterator implements IteratorInterface
|
||||
|
||||
/**
|
||||
* @param resource $filePointer
|
||||
* @param string $fieldDelimiter Character that delimits fields
|
||||
* @param string $fieldEnclosure Character that enclose fields
|
||||
* @param string $encoding Encoding of the CSV file to be read
|
||||
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
|
||||
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
|
||||
*/
|
||||
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
|
||||
public function __construct($filePointer, $options, $globalFunctionsHelper)
|
||||
{
|
||||
$this->sheet = new Sheet($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
|
||||
$this->sheet = new Sheet($filePointer, $options, $globalFunctionsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -45,7 +43,7 @@ class SheetIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Common;
|
||||
|
||||
/**
|
||||
* Class ReaderOptions
|
||||
* Readers' common options
|
||||
*
|
||||
* @package Box\Spout\Reader\Common
|
||||
*/
|
||||
class ReaderOptions
|
||||
{
|
||||
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
|
||||
protected $shouldFormatDates = false;
|
||||
|
||||
/** @var bool Whether empty rows should be returned or skipped */
|
||||
protected $shouldPreserveEmptyRows = false;
|
||||
|
||||
/**
|
||||
* @return bool Whether date/time values should be returned as PHP objects or be formatted as strings.
|
||||
*/
|
||||
public function shouldFormatDates()
|
||||
{
|
||||
return $this->shouldFormatDates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
|
||||
*
|
||||
* @param bool $shouldFormatDates
|
||||
* @return ReaderOptions
|
||||
*/
|
||||
public function setShouldFormatDates($shouldFormatDates)
|
||||
{
|
||||
$this->shouldFormatDates = $shouldFormatDates;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool Whether empty rows should be returned or skipped.
|
||||
*/
|
||||
public function shouldPreserveEmptyRows()
|
||||
{
|
||||
return $this->shouldPreserveEmptyRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets whether empty rows should be returned or skipped.
|
||||
*
|
||||
* @param bool $shouldPreserveEmptyRows
|
||||
* @return ReaderOptions
|
||||
*/
|
||||
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
|
||||
{
|
||||
$this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Common;
|
||||
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
* Class XMLProcessor
|
||||
* Helps process XML files
|
||||
*
|
||||
* @package Box\Spout\Reader\Common
|
||||
*/
|
||||
class XMLProcessor
|
||||
{
|
||||
/* Node types */
|
||||
const NODE_TYPE_START = XMLReader::ELEMENT;
|
||||
const NODE_TYPE_END = XMLReader::END_ELEMENT;
|
||||
|
||||
/* Keys associated to reflection attributes to invoke a callback */
|
||||
const CALLBACK_REFLECTION_METHOD = 'reflectionMethod';
|
||||
const CALLBACK_REFLECTION_OBJECT = 'reflectionObject';
|
||||
|
||||
/* Values returned by the callbacks to indicate what the processor should do next */
|
||||
const PROCESSING_CONTINUE = 1;
|
||||
const PROCESSING_STOP = 2;
|
||||
|
||||
|
||||
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
|
||||
protected $xmlReader;
|
||||
|
||||
/** @var array Registered callbacks */
|
||||
private $callbacks = [];
|
||||
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object
|
||||
*/
|
||||
public function __construct($xmlReader)
|
||||
{
|
||||
$this->xmlReader = $xmlReader;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeName A callback may be triggered when a node with this name is read
|
||||
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||
* @param callable $callback Callback to execute when the read node has the given name and type
|
||||
* @return XMLProcessor
|
||||
*/
|
||||
public function registerCallback($nodeName, $nodeType, $callback)
|
||||
{
|
||||
$callbackKey = $this->getCallbackKey($nodeName, $nodeType);
|
||||
$this->callbacks[$callbackKey] = $this->getInvokableCallbackData($callback);
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeName Name of the node
|
||||
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||
* @return string Key used to store the associated callback
|
||||
*/
|
||||
private function getCallbackKey($nodeName, $nodeType)
|
||||
{
|
||||
return "$nodeName$nodeType";
|
||||
}
|
||||
|
||||
/**
|
||||
* Because the callback can be a "protected" function, we don't want to use call_user_func() directly
|
||||
* but instead invoke the callback using Reflection. This allows the invocation of "protected" functions.
|
||||
* Since some functions can be called a lot, we pre-process the callback to only return the elements that
|
||||
* will be needed to invoke the callback later.
|
||||
*
|
||||
* @param callable $callback Array reference to a callback: [OBJECT, METHOD_NAME]
|
||||
* @return array Associative array containing the elements needed to invoke the callback using Reflection
|
||||
*/
|
||||
private function getInvokableCallbackData($callback)
|
||||
{
|
||||
$callbackObject = $callback[0];
|
||||
$callbackMethodName = $callback[1];
|
||||
$reflectionMethod = new \ReflectionMethod(get_class($callbackObject), $callbackMethodName);
|
||||
$reflectionMethod->setAccessible(true);
|
||||
|
||||
return [
|
||||
self::CALLBACK_REFLECTION_METHOD => $reflectionMethod,
|
||||
self::CALLBACK_REFLECTION_OBJECT => $callbackObject,
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the reading of the XML file where it was left off.
|
||||
* Stops whenever a callback indicates that reading should stop or at the end of the file.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
|
||||
*/
|
||||
public function readUntilStopped()
|
||||
{
|
||||
while ($this->xmlReader->read()) {
|
||||
$nodeType = $this->xmlReader->nodeType;
|
||||
$nodeNamePossiblyWithPrefix = $this->xmlReader->name;
|
||||
$nodeNameWithoutPrefix = $this->xmlReader->localName;
|
||||
|
||||
$callbackData = $this->getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType);
|
||||
|
||||
if ($callbackData !== null) {
|
||||
$callbackResponse = $this->invokeCallback($callbackData, [$this->xmlReader]);
|
||||
|
||||
if ($callbackResponse === self::PROCESSING_STOP) {
|
||||
// stop reading
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeNamePossiblyWithPrefix Name of the node, possibly prefixed
|
||||
* @param string $nodeNameWithoutPrefix Name of the same node, un-prefixed
|
||||
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
|
||||
* @return array|null Callback data to be used for execution when a node of the given name/type is read or NULL if none found
|
||||
*/
|
||||
private function getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType)
|
||||
{
|
||||
// With prefixed nodes, we should match if (by order of preference):
|
||||
// 1. the callback was registered with the prefixed node name (e.g. "x:worksheet")
|
||||
// 2. the callback was registered with the un-prefixed node name (e.g. "worksheet")
|
||||
$callbackKeyForPossiblyPrefixedName = $this->getCallbackKey($nodeNamePossiblyWithPrefix, $nodeType);
|
||||
$callbackKeyForUnPrefixedName = $this->getCallbackKey($nodeNameWithoutPrefix, $nodeType);
|
||||
$hasPrefix = ($nodeNamePossiblyWithPrefix !== $nodeNameWithoutPrefix);
|
||||
|
||||
$callbackKeyToUse = $callbackKeyForUnPrefixedName;
|
||||
if ($hasPrefix && isset($this->callbacks[$callbackKeyForPossiblyPrefixedName])) {
|
||||
$callbackKeyToUse = $callbackKeyForPossiblyPrefixedName;
|
||||
}
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
return isset($this->callbacks[$callbackKeyToUse]) ? $this->callbacks[$callbackKeyToUse] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $callbackData Associative array containing data to invoke the callback using Reflection
|
||||
* @param array $args Arguments to pass to the callback
|
||||
* @return int Callback response
|
||||
*/
|
||||
private function invokeCallback($callbackData, $args)
|
||||
{
|
||||
$reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD];
|
||||
$callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT];
|
||||
|
||||
return $reflectionMethod->invokeArgs($callbackObject, $args);
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ class CellValueFormatter
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
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 attribute used to parse data */
|
||||
const XML_ATTRIBUTE_TYPE = 'office:value-type';
|
||||
@@ -33,16 +35,21 @@ class CellValueFormatter
|
||||
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()
|
||||
public function __construct($shouldFormatDates)
|
||||
{
|
||||
$this->shouldFormatDates = $shouldFormatDates;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = new \Box\Spout\Common\Escaper\ODS();
|
||||
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -98,6 +105,8 @@ class CellValueFormatter
|
||||
$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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -119,6 +128,7 @@ class CellValueFormatter
|
||||
{
|
||||
$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;
|
||||
}
|
||||
@@ -141,10 +151,21 @@ class CellValueFormatter
|
||||
* Returns the cell Date value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
|
||||
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
|
||||
*/
|
||||
protected function formatDateCellValue($node)
|
||||
{
|
||||
// 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);
|
||||
@@ -152,15 +173,27 @@ class CellValueFormatter
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Time value from the given node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @return \DateInterval|null The value associated with the cell or NULL if invalid time value
|
||||
* @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
|
||||
*/
|
||||
protected function formatTimeCellValue($node)
|
||||
{
|
||||
// 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);
|
||||
@@ -168,6 +201,7 @@ class CellValueFormatter
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the cell Currency value from the given node.
|
||||
|
||||
@@ -19,6 +19,29 @@ class Reader extends AbstractReader
|
||||
/** @var SheetIterator To iterator over the ODS sheets */
|
||||
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.
|
||||
*
|
||||
@@ -32,7 +55,7 @@ class Reader extends AbstractReader
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sheetIterator = new SheetIterator($filePath);
|
||||
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions());
|
||||
} else {
|
||||
throw new IOException("Could not open $filePath for reading.");
|
||||
}
|
||||
@@ -43,7 +66,7 @@ class Reader extends AbstractReader
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
public function getConcreteSheetIterator()
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
/**
|
||||
* Class ReaderOptions
|
||||
* This class is used to customize the reader's behavior
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
|
||||
{
|
||||
// No extra options
|
||||
}
|
||||
@@ -8,6 +8,7 @@ use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\Common\XMLProcessor;
|
||||
|
||||
/**
|
||||
* Class RowIterator
|
||||
@@ -20,21 +21,29 @@ class RowIterator implements IteratorInterface
|
||||
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 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;
|
||||
@@ -42,13 +51,41 @@ class RowIterator implements IteratorInterface
|
||||
/** @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)
|
||||
public function __construct($xmlReader, $options)
|
||||
{
|
||||
$this->xmlReader = $xmlReader;
|
||||
$this->cellValueFormatter = new CellValueFormatter();
|
||||
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
|
||||
$this->cellValueFormatter = new CellValueFormatter($options->shouldFormatDates());
|
||||
|
||||
// Register all callbacks to process different nodes when reading the XML file
|
||||
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
|
||||
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
|
||||
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
|
||||
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
|
||||
$this->xmlProcessor->registerCallback(self::XML_NODE_TABLE, XMLProcessor::NODE_TYPE_END, [$this, 'processTableEndingNode']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -69,7 +106,8 @@ class RowIterator implements IteratorInterface
|
||||
}
|
||||
|
||||
$this->hasAlreadyBeenRewound = true;
|
||||
$this->numReadRows = 0;
|
||||
$this->lastRowIndexProcessed = 0;
|
||||
$this->nextRowIndexToBeProcessed = 1;
|
||||
$this->rowDataBuffer = null;
|
||||
$this->hasReachedEndOfFile = false;
|
||||
|
||||
@@ -80,7 +118,7 @@ class RowIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
@@ -97,76 +135,155 @@ class RowIterator implements IteratorInterface
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$rowData = [];
|
||||
$cellValue = null;
|
||||
$numColumnsRepeated = 1;
|
||||
$numCellsRead = 0;
|
||||
$hasAlreadyReadOneCell = false;
|
||||
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 {
|
||||
while ($this->xmlReader->read()) {
|
||||
if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) {
|
||||
// Start of a cell description
|
||||
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode();
|
||||
|
||||
$node = $this->xmlReader->expand();
|
||||
$currentCellValue = $this->getCellValue($node);
|
||||
|
||||
// process cell N only after having read cell N+1 (see below why)
|
||||
if ($hasAlreadyReadOneCell) {
|
||||
for ($i = 0; $i < $numColumnsRepeated; $i++) {
|
||||
$rowData[] = $cellValue;
|
||||
}
|
||||
}
|
||||
|
||||
$cellValue = $currentCellValue;
|
||||
$numColumnsRepeated = $currentNumColumnsRepeated;
|
||||
|
||||
$numCellsRead++;
|
||||
$hasAlreadyReadOneCell = true;
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
|
||||
// End of the row description
|
||||
$isEmptyRow = ($numCellsRead <= 1 && empty($cellValue));
|
||||
if ($isEmptyRow) {
|
||||
// skip empty rows
|
||||
$this->next();
|
||||
return;
|
||||
}
|
||||
|
||||
// Only add value if the last read cell is not empty or does not need to repeat cell values.
|
||||
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
|
||||
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
|
||||
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with always 16384 cells.
|
||||
if (!empty($cellValue) || $numColumnsRepeated === 1) {
|
||||
for ($i = 0; $i < $numColumnsRepeated; $i++) {
|
||||
$rowData[] = $cellValue;
|
||||
}
|
||||
|
||||
$this->numReadRows++;
|
||||
}
|
||||
break;
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_TABLE)) {
|
||||
// The closing "</table:table>" marks the end of the file
|
||||
$this->hasReachedEndOfFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->xmlProcessor->readUntilStopped();
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$this->rowDataBuffer = $rowData;
|
||||
$this->rowDataBuffer = $this->currentlyProcessedRowData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
|
||||
* @return int A return code that indicates what action should the processor take next
|
||||
*/
|
||||
protected function processRowStartingNode($xmlReader)
|
||||
{
|
||||
// Reset data from current row
|
||||
$this->hasAlreadyReadOneCellInCurrentRow = false;
|
||||
$this->lastProcessedCellValue = null;
|
||||
$this->numColumnsRepeated = 1;
|
||||
$this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader);
|
||||
|
||||
return XMLProcessor::PROCESSING_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
|
||||
* @return int A return code that indicates what action should the processor take next
|
||||
*/
|
||||
protected function processCellStartingNode($xmlReader)
|
||||
{
|
||||
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
|
||||
|
||||
$node = $xmlReader->expand();
|
||||
$currentCellValue = $this->getCellValue($node);
|
||||
|
||||
// process cell N only after having read cell N+1 (see below why)
|
||||
if ($this->hasAlreadyReadOneCellInCurrentRow) {
|
||||
for ($i = 0; $i < $this->numColumnsRepeated; $i++) {
|
||||
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
|
||||
}
|
||||
}
|
||||
|
||||
$this->hasAlreadyReadOneCellInCurrentRow = true;
|
||||
$this->lastProcessedCellValue = $currentCellValue;
|
||||
$this->numColumnsRepeated = $currentNumColumnsRepeated;
|
||||
|
||||
return XMLProcessor::PROCESSING_CONTINUE;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int A return code that indicates what action should the processor take next
|
||||
*/
|
||||
protected function processRowEndingNode()
|
||||
{
|
||||
$isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRowData, $this->lastProcessedCellValue);
|
||||
|
||||
// if the fetched row is empty and we don't want to preserve it...
|
||||
if (!$this->shouldPreserveEmptyRows && $isEmptyRow) {
|
||||
// ... skip it
|
||||
return XMLProcessor::PROCESSING_CONTINUE;
|
||||
}
|
||||
|
||||
// if the row is empty, we don't want to return more than one cell
|
||||
$actualNumColumnsRepeated = (!$isEmptyRow) ? $this->numColumnsRepeated : 1;
|
||||
|
||||
// Only add the value if the last read cell is not a trailing empty cell repeater in Excel.
|
||||
// The current count of read columns is determined by counting the values in "$this->currentlyProcessedRowData".
|
||||
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
|
||||
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
|
||||
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with
|
||||
// always 16384 cells.
|
||||
if ((count($this->currentlyProcessedRowData) + $actualNumColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
|
||||
for ($i = 0; $i < $actualNumColumnsRepeated; $i++) {
|
||||
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
|
||||
}
|
||||
}
|
||||
|
||||
// If we are processing row N and the row is repeated M times,
|
||||
// then the next row to be processed will be row (N+M).
|
||||
$this->nextRowIndexToBeProcessed += $this->numRowsRepeated;
|
||||
|
||||
// at this point, we have all the data we need for the row
|
||||
// so that we can populate the buffer
|
||||
return XMLProcessor::PROCESSING_STOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int A return code that indicates what action should the processor take next
|
||||
*/
|
||||
protected function processTableEndingNode()
|
||||
{
|
||||
// The closing "</table:table>" marks the end of the file
|
||||
$this->hasReachedEndOfFile = true;
|
||||
|
||||
return XMLProcessor::PROCESSING_STOP;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
|
||||
* @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing
|
||||
*/
|
||||
protected function getNumRowsRepeatedForCurrentNode($xmlReader)
|
||||
{
|
||||
$numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED);
|
||||
return ($numRowsRepeated !== null) ? intval($numRowsRepeated) : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
|
||||
* @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
|
||||
*/
|
||||
protected function getNumColumnsRepeatedForCurrentNode()
|
||||
protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
|
||||
{
|
||||
$numColumnsRepeated = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
|
||||
$numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
|
||||
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
|
||||
}
|
||||
|
||||
@@ -181,6 +298,24 @@ class RowIterator implements IteratorInterface
|
||||
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
|
||||
@@ -200,7 +335,7 @@ class RowIterator implements IteratorInterface
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->numReadRows;
|
||||
return $this->lastRowIndexProcessed;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -28,11 +28,12 @@ class Sheet implements SheetInterface
|
||||
/**
|
||||
* @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 \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
* @param string $sheetName Name of the sheet
|
||||
*/
|
||||
public function __construct($xmlReader, $sheetIndex, $sheetName)
|
||||
public function __construct($xmlReader, $sheetIndex, $sheetName, $options)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($xmlReader);
|
||||
$this->rowIterator = new RowIterator($xmlReader, $options);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
}
|
||||
|
||||
@@ -15,6 +15,8 @@ use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
*/
|
||||
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';
|
||||
@@ -22,6 +24,9 @@ class SheetIterator implements IteratorInterface
|
||||
/** @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;
|
||||
|
||||
@@ -36,15 +41,17 @@ class SheetIterator implements IteratorInterface
|
||||
|
||||
/**
|
||||
* @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)
|
||||
public function __construct($filePath, $options)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->options = $options;
|
||||
$this->xmlReader = new XMLReader();
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = new \Box\Spout\Common\Escaper\ODS();
|
||||
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -58,8 +65,8 @@ class SheetIterator implements IteratorInterface
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
|
||||
$contentXmlFilePath = $this->filePath . '#content.xml';
|
||||
if ($this->xmlReader->open('zip://' . $contentXmlFilePath) === false) {
|
||||
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}\".");
|
||||
}
|
||||
|
||||
@@ -76,7 +83,7 @@ class SheetIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
@@ -109,7 +116,7 @@ class SheetIterator implements IteratorInterface
|
||||
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
|
||||
$sheetName = $this->escaper->unescape($escapedSheetName);
|
||||
|
||||
return new Sheet($this->xmlReader, $sheetName, $this->currentSheetIndex);
|
||||
return new Sheet($this->xmlReader, $this->currentSheetIndex, $sheetName, $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -40,8 +40,6 @@ class SimpleXMLElement
|
||||
}
|
||||
|
||||
$this->resetXMLInternalErrorsSetting();
|
||||
|
||||
return $this->simpleXMLElement;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -151,22 +149,6 @@ class SimpleXMLElement
|
||||
return $doesElementExist ? $this->wrapSimpleXMLElement($realElement) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the immediate children.
|
||||
*
|
||||
* @return array The children
|
||||
*/
|
||||
public function children()
|
||||
{
|
||||
$children = [];
|
||||
|
||||
foreach ($this->simpleXMLElement->children() as $child) {
|
||||
$children[] = $this->wrapSimpleXMLElement($child);
|
||||
}
|
||||
|
||||
return $children;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
|
||||
@@ -14,66 +14,44 @@ class XMLReader extends \XMLReader
|
||||
{
|
||||
use XMLInternalErrorsHelper;
|
||||
|
||||
const ZIP_WRAPPER = 'zip://';
|
||||
|
||||
/**
|
||||
* Set the URI containing the XML to parse
|
||||
* @see \XMLReader::open
|
||||
* Opens the XML Reader to read a file located inside a ZIP file.
|
||||
*
|
||||
* @param string $URI URI pointing to the document
|
||||
* @param string|null|void $encoding The document encoding
|
||||
* @param int $options A bitmask of the LIBXML_* constants
|
||||
* @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 open($URI, $encoding = null, $options = 0)
|
||||
public function openFileInZip($zipFilePath, $fileInsideZipPath)
|
||||
{
|
||||
$wasOpenSuccessful = false;
|
||||
$realPathURI = $this->convertURIToUseRealPath($URI);
|
||||
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
|
||||
|
||||
// HHVM does not check if file exists within zip file
|
||||
// @link https://github.com/facebook/hhvm/issues/5779
|
||||
if ($this->isRunningHHVM() && $this->isZipStream($realPathURI)) {
|
||||
if ($this->isRunningHHVM()) {
|
||||
if ($this->fileExistsWithinZip($realPathURI)) {
|
||||
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET);
|
||||
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||
}
|
||||
} else {
|
||||
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET);
|
||||
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||
}
|
||||
|
||||
return $wasOpenSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the given URI to use a real path.
|
||||
* This is to avoid issues on some Windows setup.
|
||||
* Returns the real path for the given path components.
|
||||
* This is useful to avoid issues on some Windows setup.
|
||||
*
|
||||
* @param string $URI URI
|
||||
* @return string The URI using a real path
|
||||
* @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
|
||||
*/
|
||||
protected function convertURIToUseRealPath($URI)
|
||||
public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
|
||||
{
|
||||
$realPathURI = $URI;
|
||||
|
||||
if ($this->isZipStream($URI)) {
|
||||
if (preg_match('/zip:\/\/(.*)#(.*)/', $URI, $matches)) {
|
||||
$documentPath = $matches[1];
|
||||
$documentInsideZipPath = $matches[2];
|
||||
$realPathURI = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
|
||||
}
|
||||
} else {
|
||||
$realPathURI = realpath($URI);
|
||||
}
|
||||
|
||||
return $realPathURI;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given URI is a zip stream.
|
||||
*
|
||||
* @param string $URI URI pointing to a document
|
||||
* @return bool TRUE if URI is a zip stream, FALSE otherwise
|
||||
*/
|
||||
protected function isZipStream($URI)
|
||||
{
|
||||
return (strpos($URI, 'zip://') === 0);
|
||||
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -138,9 +116,10 @@ class XMLReader extends \XMLReader
|
||||
*/
|
||||
public function readUntilNodeFound($nodeName)
|
||||
{
|
||||
while (($wasReadSuccessful = $this->read()) && ($this->nodeType !== \XMLReader::ELEMENT || $this->name !== $nodeName)) {
|
||||
// do nothing
|
||||
}
|
||||
do {
|
||||
$wasReadSuccessful = $this->read();
|
||||
$isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
|
||||
} while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
|
||||
|
||||
return $wasReadSuccessful;
|
||||
}
|
||||
@@ -170,7 +149,7 @@ class XMLReader extends \XMLReader
|
||||
*/
|
||||
public function isPositionedOnStartingNode($nodeName)
|
||||
{
|
||||
return ($this->nodeType === XMLReader::ELEMENT && $this->name === $nodeName);
|
||||
return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -179,6 +158,22 @@ class XMLReader extends \XMLReader
|
||||
*/
|
||||
public function isPositionedOnEndingNode($nodeName)
|
||||
{
|
||||
return ($this->nodeType === XMLReader::END_ELEMENT && $this->name === $nodeName);
|
||||
return $this->isPositionedOnNode($nodeName, XMLReader::END_ELEMENT);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $nodeName
|
||||
* @param int $nodeType
|
||||
* @return bool Whether the XML Reader is currently positioned on the node with given name and type
|
||||
*/
|
||||
private function isPositionedOnNode($nodeName, $nodeType)
|
||||
{
|
||||
// In some cases, the node has a prefix (for instance, "<sheet>" can also be "<x:sheet>").
|
||||
// So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
|
||||
// @see https://github.com/box/spout/issues/233
|
||||
$hasPrefix = (strpos($nodeName, ':') !== false);
|
||||
$currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
|
||||
|
||||
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,9 @@ class CellHelper
|
||||
*/
|
||||
public static function fillMissingArrayIndexes($dataArray, $fillValue = '')
|
||||
{
|
||||
if (empty($dataArray)) {
|
||||
return [];
|
||||
}
|
||||
$existingIndexes = array_keys($dataArray);
|
||||
|
||||
$newIndexes = array_fill_keys(range(0, max($existingIndexes)), $fillValue);
|
||||
|
||||
@@ -29,6 +29,8 @@ class CellValueFormatter
|
||||
|
||||
/** 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...
|
||||
@@ -42,20 +44,25 @@ class CellValueFormatter
|
||||
/** @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)
|
||||
public function __construct($sharedStringsHelper, $styleHelper, $shouldFormatDates)
|
||||
{
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->styleHelper = $styleHelper;
|
||||
$this->shouldFormatDates = $shouldFormatDates;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -118,8 +125,7 @@ class CellValueFormatter
|
||||
// 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);
|
||||
$escapedCellValue = trim($tNode->nodeValue);
|
||||
$cellValue = $this->escaper->unescape($escapedCellValue);
|
||||
$cellValue = $this->escaper->unescape($tNode->nodeValue);
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
@@ -167,7 +173,7 @@ class CellValueFormatter
|
||||
$shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId);
|
||||
|
||||
if ($shouldFormatAsDate) {
|
||||
return $this->formatExcelTimestampValue(floatval($nodeValue));
|
||||
return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId);
|
||||
} else {
|
||||
$nodeIntValue = intval($nodeValue);
|
||||
return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
|
||||
@@ -177,33 +183,86 @@ class CellValueFormatter
|
||||
/**
|
||||
* 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)
|
||||
protected function formatExcelTimestampValue($nodeValue, $cellStyleId)
|
||||
{
|
||||
// Fix for the erroneous leap year in Excel
|
||||
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
|
||||
--$nodeValue;
|
||||
}
|
||||
|
||||
// The value 1.0 represents 1900-01-01. Numbers below 1.0 are not valid Excel dates.
|
||||
if ($nodeValue < 1.0) {
|
||||
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 {
|
||||
$cellValue = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
|
||||
$cellValue->modify('+' . intval($nodeValue) . 'days');
|
||||
$cellValue->modify('+' . $secondsRemainder . 'seconds');
|
||||
$dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
|
||||
$dateObj->modify('+' . intval($nodeValue) . 'days');
|
||||
$dateObj->modify('+' . $secondsRemainder . 'seconds');
|
||||
|
||||
return $cellValue;
|
||||
if ($this->shouldFormatDates) {
|
||||
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
|
||||
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
|
||||
return $dateObj->format($phpDateFormat);
|
||||
} else {
|
||||
return $dateObj;
|
||||
}
|
||||
} catch (\Exception $e) {
|
||||
return null;
|
||||
}
|
||||
@@ -224,16 +283,16 @@ class CellValueFormatter
|
||||
|
||||
/**
|
||||
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
|
||||
* @see ECMA-376 Part 1 - §18.17.4
|
||||
*
|
||||
* @param string $nodeValue
|
||||
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
|
||||
* @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 {
|
||||
$cellValue = new \DateTime($nodeValue);
|
||||
return $cellValue;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -78,7 +78,7 @@ class CachingStrategyFactory
|
||||
* Returns the best caching strategy, given the number of unique shared strings
|
||||
* and the amount of memory available.
|
||||
*
|
||||
* @param int $sharedStringsUniqueCount Number of unique shared strings
|
||||
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||
* @return CachingStrategyInterface The best caching strategy
|
||||
*/
|
||||
@@ -95,11 +95,16 @@ class CachingStrategyFactory
|
||||
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings
|
||||
* and the amount of memory available.
|
||||
*
|
||||
* @param int $sharedStringsUniqueCount Number of unique shared strings
|
||||
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||
* @return bool
|
||||
*/
|
||||
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) {
|
||||
|
||||
@@ -34,7 +34,7 @@ class SharedStringsHelper
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the XLSX file being read
|
||||
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||
* @param string|null|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
|
||||
*/
|
||||
public function __construct($filePath, $tempFolder = null)
|
||||
{
|
||||
@@ -80,7 +80,7 @@ class SharedStringsHelper
|
||||
$xmlReader = new XMLReader();
|
||||
$sharedStringIndex = 0;
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
|
||||
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
||||
if ($xmlReader->open($sharedStringsFilePath) === false) {
|
||||
@@ -94,39 +94,19 @@ class SharedStringsHelper
|
||||
$xmlReader->readUntilNodeFound('si');
|
||||
|
||||
while ($xmlReader->name === 'si') {
|
||||
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
|
||||
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
|
||||
|
||||
// removes nodes that should not be read, like the pronunciation of the Kanji characters
|
||||
$cleanNode = $this->removeSuperfluousTextNodes($node);
|
||||
|
||||
// find all text nodes 't'; there can be multiple if the cell contains formatting
|
||||
$textNodes = $cleanNode->xpath('//ns:t');
|
||||
|
||||
$textValue = '';
|
||||
foreach ($textNodes as $textNode) {
|
||||
if ($this->shouldPreserveWhitespace($textNode)) {
|
||||
$textValue .= $textNode->__toString();
|
||||
} else {
|
||||
$textValue .= trim($textNode->__toString());
|
||||
}
|
||||
}
|
||||
|
||||
$unescapedTextValue = $escaper->unescape($textValue);
|
||||
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
|
||||
|
||||
$this->processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper);
|
||||
$sharedStringIndex++;
|
||||
|
||||
// jump to the next 'si' tag
|
||||
$xmlReader->next('si');
|
||||
}
|
||||
|
||||
$this->cachingStrategy->closeCache();
|
||||
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$this->cachingStrategy->closeCache();
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
@@ -142,7 +122,7 @@ class SharedStringsHelper
|
||||
* Returns the shared strings unique count, as specified in <sst> tag.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
|
||||
* @return int Number of unique shared strings in the sharedStrings.xml file
|
||||
* @return int|null Number of unique shared strings in the sharedStrings.xml file
|
||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
|
||||
*/
|
||||
protected function getSharedStringsUniqueCount($xmlReader)
|
||||
@@ -154,13 +134,21 @@ class SharedStringsHelper
|
||||
$xmlReader->read();
|
||||
}
|
||||
|
||||
return intval($xmlReader->getAttribute('uniqueCount'));
|
||||
$uniqueCount = $xmlReader->getAttribute('uniqueCount');
|
||||
|
||||
// some software do not add the "uniqueCount" attribute but only use the "count" one
|
||||
// @see https://github.com/box/spout/issues/254
|
||||
if ($uniqueCount === null) {
|
||||
$uniqueCount = $xmlReader->getAttribute('count');
|
||||
}
|
||||
|
||||
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the best shared strings caching strategy.
|
||||
*
|
||||
* @param int $sharedStringsUniqueCount
|
||||
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
|
||||
* @return CachingStrategyInterface
|
||||
*/
|
||||
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
|
||||
@@ -169,6 +157,31 @@ class SharedStringsHelper
|
||||
->getBestCachingStrategy($sharedStringsUniqueCount, $this->tempFolder);
|
||||
}
|
||||
|
||||
/**
|
||||
* Processes the shared strings item XML node which the given XML reader is positioned on.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader
|
||||
* @param int $sharedStringIndex Index of the processed shared strings item
|
||||
* @param \Box\Spout\Common\Escaper\XLSX $escaper Helper to escape values
|
||||
* @return void
|
||||
*/
|
||||
protected function processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper)
|
||||
{
|
||||
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
|
||||
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
|
||||
|
||||
// removes nodes that should not be read, like the pronunciation of the Kanji characters
|
||||
$cleanNode = $this->removeSuperfluousTextNodes($node);
|
||||
|
||||
// find all text nodes "t"; there can be multiple if the cell contains formatting
|
||||
$textNodes = $cleanNode->xpath('//ns:t');
|
||||
|
||||
$textValue = $this->extractTextValueForNodes($textNodes);
|
||||
$unescapedTextValue = $escaper->unescape($textValue);
|
||||
|
||||
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SimpleXMLElement node from the current node in the given XMLReader instance.
|
||||
* This is to simplify the parsing of the subtree.
|
||||
@@ -200,6 +213,8 @@ class SharedStringsHelper
|
||||
{
|
||||
$tagsToRemove = [
|
||||
'rPh', // Pronunciation of the text
|
||||
'pPr', // Paragraph Properties / Previous Paragraph Properties
|
||||
'rPr', // Run Properties for the Paragraph Mark / Previous Run Properties for the Paragraph Mark
|
||||
];
|
||||
|
||||
foreach ($tagsToRemove as $tagToRemove) {
|
||||
@@ -210,6 +225,24 @@ class SharedStringsHelper
|
||||
return $parentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $textNodes Text XML nodes ("<t>")
|
||||
* @return string The value associated with the given text node(s)
|
||||
*/
|
||||
protected function extractTextValueForNodes($textNodes)
|
||||
{
|
||||
$textValue = '';
|
||||
|
||||
foreach ($textNodes as $nodeIndex => $textNode) {
|
||||
$textNodeAsString = $textNode->__toString();
|
||||
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
|
||||
|
||||
$textValue .= ($shouldPreserveWhitespace) ? $textNodeAsString : trim($textNodeAsString);
|
||||
}
|
||||
|
||||
return $textValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
||||
*
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\XLSX\Sheet;
|
||||
|
||||
/**
|
||||
@@ -14,41 +14,31 @@ use Box\Spout\Reader\XLSX\Sheet;
|
||||
class SheetHelper
|
||||
{
|
||||
/** 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 sheets */
|
||||
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\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;
|
||||
|
||||
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml.rels file */
|
||||
protected $workbookXMLRelsAsXMLElement;
|
||||
|
||||
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml file */
|
||||
protected $workbookXMLAsXMLElement;
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the XLSX file being read
|
||||
* @param \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, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->options = $options;
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->globalFunctionsHelper = $globalFunctionsHelper;
|
||||
}
|
||||
@@ -62,119 +52,81 @@ class SheetHelper
|
||||
public function getSheets()
|
||||
{
|
||||
$sheets = [];
|
||||
$sheetIndex = 0;
|
||||
|
||||
$contentTypesAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
||||
self::CONTENT_TYPES_XML_FILE_PATH,
|
||||
self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML
|
||||
);
|
||||
|
||||
// find all nodes defining a sheet
|
||||
$sheetNodes = $contentTypesAsXMLElement->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]');
|
||||
$numSheetNodes = count($sheetNodes);
|
||||
|
||||
for ($i = 0; $i < $numSheetNodes; $i++) {
|
||||
$sheetNode = $sheetNodes[$i];
|
||||
$sheetDataXMLFilePath = $sheetNode->getAttribute('PartName');
|
||||
|
||||
$sheets[] = $this->getSheetFromXML($sheetDataXMLFilePath);
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->isPositionedOnStartingNode('sheet')) {
|
||||
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex);
|
||||
$sheetIndex++;
|
||||
} else if ($xmlReader->isPositionedOnEndingNode('sheets')) {
|
||||
// stop reading once all sheets have been read
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// make sure the sheets are sorted by index
|
||||
// (as the sheets are not necessarily in this order in the XML file)
|
||||
usort($sheets, function ($sheet1, $sheet2) {
|
||||
return ($sheet1->getIndex() - $sheet2->getIndex());
|
||||
});
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* 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 string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
||||
* @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)
|
||||
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
|
||||
*/
|
||||
protected function getSheetFromXML($sheetDataXMLFilePath)
|
||||
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased)
|
||||
{
|
||||
// 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 . '"]');
|
||||
$relationshipNode = $relationshipNodes[0];
|
||||
|
||||
$relationshipSheetId = $relationshipNode->getAttribute('Id');
|
||||
|
||||
$workbookXMLElement = $this->getWorkbookXMLAsXMLElement();
|
||||
$sheetNodes = $workbookXMLElement->xpath('//ns:sheet[@r:id="' . $relationshipSheetId . '"]');
|
||||
$sheetNode = $sheetNodes[0];
|
||||
|
||||
$escapedSheetName = $sheetNode->getAttribute('name');
|
||||
$sheetIdOneBased = $sheetNode->getAttribute('sheetId');
|
||||
$sheetIndexZeroBased = $sheetIdOneBased - 1;
|
||||
$sheetId = $xmlReaderOnSheetNode->getAttribute('r:id');
|
||||
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute('name');
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
$sheetName = $escaper->unescape($escapedSheetName);
|
||||
|
||||
return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $sheetIndexZeroBased, $sheetName);
|
||||
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
|
||||
|
||||
return new Sheet($this->filePath, $sheetDataXMLFilePath, $sheetIndexZeroBased, $sheetName, $this->options, $this->sharedStringsHelper);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a representation of the workbook.xml.rels file, ready to be parsed.
|
||||
* The returned value is cached.
|
||||
*
|
||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
|
||||
* @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 getWorkbookXMLRelsAsXMLElement()
|
||||
protected function getSheetDataXMLFilePathForSheetId($sheetId)
|
||||
{
|
||||
if (!$this->workbookXMLRelsAsXMLElement) {
|
||||
$this->workbookXMLRelsAsXMLElement = $this->getFileAsXMLElementWithNamespace(
|
||||
self::WORKBOOK_XML_RELS_FILE_PATH,
|
||||
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS
|
||||
);
|
||||
$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('Relationship')) {
|
||||
$relationshipSheetId = $xmlReader->getAttribute('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('Target');
|
||||
|
||||
// sometimes, the sheet data file path already contains "/xl/"...
|
||||
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
|
||||
$sheetDataXMLFilePath = '/xl/' . $sheetDataXMLFilePath;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $this->workbookXMLRelsAsXMLElement;
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a representation of the workbook.xml file, ready to be parsed.
|
||||
* The returned value is cached.
|
||||
*
|
||||
* @return \Box\Spout\Reader\Wrapper\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 \Box\Spout\Reader\Wrapper\SimpleXMLElement The XML element representing the file
|
||||
*/
|
||||
protected function getFileAsXMLElementWithNamespace($xmlFilePath, $mainNamespace)
|
||||
{
|
||||
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $xmlFilePath);
|
||||
|
||||
$xmlElement = new SimpleXMLElement($xmlContents);
|
||||
$xmlElement->registerXPathNamespace('ns', $mainNamespace);
|
||||
|
||||
return $xmlElement;
|
||||
return $sheetDataXMLFilePath;
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
@@ -30,21 +29,72 @@ class StyleHelper
|
||||
/** 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);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -57,18 +107,15 @@ class StyleHelper
|
||||
$this->customNumberFormats = [];
|
||||
$this->stylesAttributes = [];
|
||||
|
||||
$stylesXmlFilePath = $this->filePath .'#' . self::STYLES_XML_FILE_PATH;
|
||||
$xmlReader = new XMLReader();
|
||||
|
||||
if ($xmlReader->open('zip://' . $stylesXmlFilePath)) {
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::STYLES_XML_FILE_PATH)) {
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
|
||||
$numFmtsNode = new SimpleXMLElement($xmlReader->readOuterXml());
|
||||
$this->extractNumberFormats($numFmtsNode);
|
||||
$this->extractNumberFormats($xmlReader);
|
||||
|
||||
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
|
||||
$cellXfsNode = new SimpleXMLElement($xmlReader->readOuterXml());
|
||||
$this->extractStyleAttributes($cellXfsNode);
|
||||
$this->extractStyleAttributes($xmlReader);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,15 +128,20 @@ class StyleHelper
|
||||
* 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 SimpleXMLElement $numFmtsNode The "numFmts" node
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "numFmts" node
|
||||
* @return void
|
||||
*/
|
||||
protected function extractNumberFormats($numFmtsNode)
|
||||
protected function extractNumberFormats($xmlReader)
|
||||
{
|
||||
foreach ($numFmtsNode->children() as $numFmtNode) {
|
||||
$numFmtId = intval($numFmtNode->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
|
||||
$formatCode = $numFmtNode->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,16 +150,27 @@ class StyleHelper
|
||||
* 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 SimpleXMLElement $cellXfsNode The "cellXfs" node
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "cellXfs" node
|
||||
* @return void
|
||||
*/
|
||||
protected function extractStyleAttributes($cellXfsNode)
|
||||
protected function extractStyleAttributes($xmlReader)
|
||||
{
|
||||
foreach ($cellXfsNode->children() as $xfNode) {
|
||||
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 => intval($xfNode->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID)),
|
||||
self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => !!($xfNode->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT)),
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -136,74 +199,92 @@ class StyleHelper
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* @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
|
||||
*/
|
||||
public function shouldFormatNumericValueAsDate($styleId)
|
||||
protected function doesStyleIndicateDate($styleAttributes)
|
||||
{
|
||||
$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];
|
||||
|
||||
$applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
|
||||
if (!$applyNumberFormat) {
|
||||
$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;
|
||||
}
|
||||
|
||||
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
|
||||
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 timestamp
|
||||
* @return bool Whether the number format ID indicates that the number is a date
|
||||
*/
|
||||
protected function doesNumFmtIdIndicateDate($numFmtId)
|
||||
{
|
||||
return (
|
||||
if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) {
|
||||
$formatCode = $this->getFormatCodeForNumFmtId($numFmtId);
|
||||
|
||||
$this->numFmtIdToIsDateFormatCache[$numFmtId] = (
|
||||
$this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
|
||||
$this->isNumFmtIdCustomDateFormat($numFmtId)
|
||||
$this->isFormatCodeCustomDateFormat($formatCode)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $numFmtId
|
||||
* @return bool Whether the number format ID indicates that the number is a timestamp
|
||||
*/
|
||||
protected function isNumFmtIdBuiltInDateFormat($numFmtId)
|
||||
{
|
||||
$builtInDateFormatIds = [14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47];
|
||||
return in_array($numFmtId, $builtInDateFormatIds);
|
||||
return $this->numFmtIdToIsDateFormatCache[$numFmtId];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $numFmtId
|
||||
* @return bool Whether the number format ID indicates that the number is a timestamp
|
||||
* @return string|null The custom number format or NULL if none defined for the given numFmtId
|
||||
*/
|
||||
protected function isNumFmtIdCustomDateFormat($numFmtId)
|
||||
protected function getFormatCodeForNumFmtId($numFmtId)
|
||||
{
|
||||
$customNumberFormats = $this->getCustomNumberFormats();
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (!isset($customNumberFormats[$numFmtId])) {
|
||||
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;
|
||||
}
|
||||
|
||||
$customNumberFormat = $customNumberFormats[$numFmtId];
|
||||
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 = '((?<!\\\)\[.+?(?<!\\\)\])';
|
||||
$customNumberFormat = preg_replace($pattern, '', $customNumberFormat);
|
||||
$formatCode = preg_replace($pattern, '', $formatCode);
|
||||
|
||||
// custom date formats contain specific characters to represent the date:
|
||||
// e - yy - m - d - h - s
|
||||
@@ -212,10 +293,10 @@ class StyleHelper
|
||||
|
||||
$hasFoundDateFormatCharacter = false;
|
||||
foreach ($dateFormatCharacters as $dateFormatCharacter) {
|
||||
// character not preceded by "\"
|
||||
$pattern = '/(?<!\\\)' . $dateFormatCharacter . '/';
|
||||
// character not preceded by "\" (case insensitive)
|
||||
$pattern = '/(?<!\\\)' . $dateFormatCharacter . '/i';
|
||||
|
||||
if (preg_match($pattern, $customNumberFormat)) {
|
||||
if (preg_match($pattern, $formatCode)) {
|
||||
$hasFoundDateFormatCharacter = true;
|
||||
break;
|
||||
}
|
||||
@@ -223,4 +304,27 @@ class StyleHelper
|
||||
|
||||
return $hasFoundDateFormatCharacter;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the format as defined in "styles.xml" of the given style.
|
||||
* NOTE: It is assumed that the style DOES have a number format associated to it.
|
||||
*
|
||||
* @param int $styleId Zero-based style ID
|
||||
* @return string The number format code associated with the given style
|
||||
*/
|
||||
public function getNumberFormatCode($styleId)
|
||||
{
|
||||
$stylesAttributes = $this->getStylesAttributes();
|
||||
$styleAttributes = $stylesAttributes[$styleId];
|
||||
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
|
||||
|
||||
if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
|
||||
$numberFormatCode = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
|
||||
} else {
|
||||
$customNumberFormats = $this->getCustomNumberFormats();
|
||||
$numberFormatCode = $customNumberFormats[$numFmtId];
|
||||
}
|
||||
|
||||
return $numberFormatCode;
|
||||
}
|
||||
}
|
||||
@@ -14,9 +14,6 @@ use Box\Spout\Reader\XLSX\Helper\SharedStringsHelper;
|
||||
*/
|
||||
class Reader extends AbstractReader
|
||||
{
|
||||
/** @var string Temporary folder where the temporary files will be created */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var \ZipArchive */
|
||||
protected $zip;
|
||||
|
||||
@@ -27,16 +24,39 @@ class Reader extends AbstractReader
|
||||
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->tempFolder = $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
|
||||
@@ -52,14 +72,14 @@ class Reader extends AbstractReader
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->getOptions()->getTempFolder());
|
||||
|
||||
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
||||
// Extracts all the strings from the sheets for easy access in the future
|
||||
$this->sharedStringsHelper->extractSharedStrings();
|
||||
}
|
||||
|
||||
$this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper);
|
||||
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions(), $this->sharedStringsHelper, $this->globalFunctionsHelper);
|
||||
} else {
|
||||
throw new IOException("Could not open $filePath for reading.");
|
||||
}
|
||||
@@ -70,7 +90,7 @@ class Reader extends AbstractReader
|
||||
*
|
||||
* @return SheetIterator To iterate over sheets
|
||||
*/
|
||||
public function getConcreteSheetIterator()
|
||||
protected function getConcreteSheetIterator()
|
||||
{
|
||||
return $this->sheetIterator;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX;
|
||||
|
||||
/**
|
||||
* Class ReaderOptions
|
||||
* This class is used to customize the reader's behavior
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX
|
||||
*/
|
||||
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
|
||||
{
|
||||
/** @var string|null Temporary folder where the temporary files will be created */
|
||||
protected $tempFolder = null;
|
||||
|
||||
/**
|
||||
* @return string|null Temporary folder where the temporary files will be created
|
||||
*/
|
||||
public function getTempFolder()
|
||||
{
|
||||
return $this->tempFolder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|null $tempFolder Temporary folder where the temporary files will be created
|
||||
* @return ReaderOptions
|
||||
*/
|
||||
public function setTempFolder($tempFolder)
|
||||
{
|
||||
$this->tempFolder = $tempFolder;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -26,6 +27,7 @@ class RowIterator implements IteratorInterface
|
||||
/** 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 */
|
||||
@@ -37,15 +39,24 @@ class RowIterator implements IteratorInterface
|
||||
/** @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;
|
||||
|
||||
/** @var int Number of read rows */
|
||||
/**
|
||||
* 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;
|
||||
|
||||
@@ -55,12 +66,25 @@ class RowIterator implements IteratorInterface
|
||||
/** @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, $sharedStringsHelper)
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper)
|
||||
{
|
||||
$this->filePath = $filePath;
|
||||
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
|
||||
@@ -68,7 +92,17 @@ class RowIterator implements IteratorInterface
|
||||
$this->xmlReader = new XMLReader();
|
||||
|
||||
$this->styleHelper = new StyleHelper($filePath);
|
||||
$this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper);
|
||||
$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']);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,6 +134,8 @@ class RowIterator implements IteratorInterface
|
||||
}
|
||||
|
||||
$this->numReadRows = 0;
|
||||
$this->lastRowIndexProcessed = 0;
|
||||
$this->nextRowIndexToBeProcessed = 0;
|
||||
$this->rowDataBuffer = null;
|
||||
$this->hasReachedEndOfFile = false;
|
||||
$this->numColumns = 0;
|
||||
@@ -111,7 +147,7 @@ class RowIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
@@ -119,7 +155,7 @@ class RowIterator implements IteratorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Move forward to next element. Empty rows will be skipped.
|
||||
* Move forward to next element. Reads data describing the next unprocessed row.
|
||||
* @link http://php.net/manual/en/iterator.next.php
|
||||
*
|
||||
* @return void
|
||||
@@ -128,57 +164,171 @@ class RowIterator implements IteratorInterface
|
||||
*/
|
||||
public function next()
|
||||
{
|
||||
$rowData = [];
|
||||
$this->nextRowIndexToBeProcessed++;
|
||||
|
||||
if ($this->doesNeedDataForNextRowToBeProcessed()) {
|
||||
$this->readDataForNextRow();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether we need data for the next row to be processed.
|
||||
* We don't need to read data if:
|
||||
* we have already read at least one row
|
||||
* AND
|
||||
* we need to preserve empty rows
|
||||
* AND
|
||||
* the last row that was read is not the row that need to be processed
|
||||
* (i.e. if we need to return empty rows)
|
||||
*
|
||||
* @return bool Whether we need data for the next row to be processed.
|
||||
*/
|
||||
protected function doesNeedDataForNextRowToBeProcessed()
|
||||
{
|
||||
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
|
||||
|
||||
return (
|
||||
!$hasReadAtLeastOneRow ||
|
||||
!$this->shouldPreserveEmptyRows ||
|
||||
$this->lastRowIndexProcessed < $this->nextRowIndexToBeProcessed
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
|
||||
*/
|
||||
protected function readDataForNextRow()
|
||||
{
|
||||
$this->currentlyProcessedRowData = [];
|
||||
|
||||
try {
|
||||
while ($this->xmlReader->read()) {
|
||||
if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_DIMENSION)) {
|
||||
// Read dimensions of the sheet
|
||||
$dimensionRef = $this->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)) {
|
||||
$lastCellIndex = $matches[1];
|
||||
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
|
||||
}
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) {
|
||||
// Start of the row description
|
||||
|
||||
// Read spans info if present
|
||||
$numberOfColumnsForRow = $this->numColumns;
|
||||
$spans = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
|
||||
if ($spans) {
|
||||
list(, $numberOfColumnsForRow) = explode(':', $spans);
|
||||
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
||||
}
|
||||
$rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) {
|
||||
// Start of a cell description
|
||||
$currentCellIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
|
||||
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
|
||||
|
||||
$node = $this->xmlReader->expand();
|
||||
$rowData[$currentColumnIndex] = $this->getCellValue($node);
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
|
||||
// End of the row description
|
||||
// If needed, we fill the empty cells
|
||||
$rowData = ($this->numColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData);
|
||||
$this->numReadRows++;
|
||||
break;
|
||||
|
||||
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_WORKSHEET)) {
|
||||
// The closing "</worksheet>" marks the end of the file
|
||||
$this->hasReachedEndOfFile = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
$this->xmlProcessor->readUntilStopped();
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
|
||||
}
|
||||
|
||||
$this->rowDataBuffer = $rowData;
|
||||
$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);
|
||||
|
||||
$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;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -193,25 +343,53 @@ class RowIterator implements IteratorInterface
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element, from the buffer.
|
||||
* @param array $rowData
|
||||
* @return bool Whether the given row is empty
|
||||
*/
|
||||
protected function isEmptyRow($rowData)
|
||||
{
|
||||
return (count($rowData) === 1 && $rowData[0] === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current element, either an empty row or from the buffer.
|
||||
* @link http://php.net/manual/en/iterator.current.php
|
||||
*
|
||||
* @return array|null
|
||||
*/
|
||||
public function current()
|
||||
{
|
||||
return $this->rowDataBuffer;
|
||||
$rowDataForRowToBeProcessed = $this->rowDataBuffer;
|
||||
|
||||
if ($this->shouldPreserveEmptyRows) {
|
||||
// when we need to preserve empty rows, we will either return
|
||||
// an empty row or the last row read. This depends whether the
|
||||
// index of last row that was read matches the index of the last
|
||||
// row whose value should be returned.
|
||||
if ($this->lastRowIndexProcessed !== $this->nextRowIndexToBeProcessed) {
|
||||
// return empty row if mismatch between last processed row
|
||||
// and the row that needs to be returned
|
||||
$rowDataForRowToBeProcessed = [''];
|
||||
}
|
||||
}
|
||||
|
||||
return $rowDataForRowToBeProcessed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the key of the current element
|
||||
* Return the key of the current element. Here, the row index.
|
||||
* @link http://php.net/manual/en/iterator.key.php
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function key()
|
||||
{
|
||||
return $this->numReadRows;
|
||||
// TODO: This should return $this->nextRowIndexToBeProcessed
|
||||
// but to avoid a breaking change, the return value for
|
||||
// this function has been kept as the number of rows read.
|
||||
return $this->shouldPreserveEmptyRows ?
|
||||
$this->nextRowIndexToBeProcessed :
|
||||
$this->numReadRows;
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,13 +24,14 @@ class Sheet implements SheetInterface
|
||||
/**
|
||||
* @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 Helper\SharedStringsHelper Helper to work with shared strings
|
||||
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param string $sheetName Name of the sheet
|
||||
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||
* @param Helper\SharedStringsHelper Helper to work with shared strings
|
||||
*/
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $sheetIndex, $sheetName)
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $sheetIndex, $sheetName, $options, $sharedStringsHelper)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper);
|
||||
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
}
|
||||
|
||||
@@ -22,14 +22,15 @@ class SheetIterator implements IteratorInterface
|
||||
|
||||
/**
|
||||
* @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, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
|
||||
{
|
||||
// Fetch all available sheets
|
||||
$sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper);
|
||||
$sheetHelper = new SheetHelper($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper);
|
||||
$this->sheets = $sheetHelper->getSheets();
|
||||
|
||||
if (count($this->sheets) === 0) {
|
||||
@@ -52,7 +53,7 @@ class SheetIterator implements IteratorInterface
|
||||
* Checks if current position is valid
|
||||
* @link http://php.net/manual/en/iterator.valid.php
|
||||
*
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function valid()
|
||||
{
|
||||
|
||||
@@ -4,6 +4,8 @@ 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;
|
||||
@@ -71,6 +73,21 @@ abstract class AbstractWriter implements WriterInterface
|
||||
$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
|
||||
@@ -121,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 . '"');
|
||||
@@ -180,13 +201,23 @@ abstract class AbstractWriter implements WriterInterface
|
||||
* @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)) {
|
||||
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.');
|
||||
@@ -238,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');
|
||||
}
|
||||
|
||||
@@ -318,8 +350,35 @@ abstract class AbstractWriter implements WriterInterface
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,6 +29,9 @@ class Writer extends AbstractWriter
|
||||
/** @var int */
|
||||
protected $lastWrittenRowIndex = 0;
|
||||
|
||||
/** @var bool */
|
||||
protected $shouldAddBOM = true;
|
||||
|
||||
/**
|
||||
* Sets the field delimiter for the CSV
|
||||
*
|
||||
@@ -55,6 +58,18 @@ class Writer 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.
|
||||
*
|
||||
@@ -62,9 +77,11 @@ class Writer extends AbstractWriter
|
||||
*/
|
||||
protected function openWriter()
|
||||
{
|
||||
if ($this->shouldAddBOM) {
|
||||
// Adds UTF-8 BOM for Unicode compatibility
|
||||
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds data to the currently opened writer.
|
||||
@@ -96,10 +113,6 @@ class Writer extends AbstractWriter
|
||||
*/
|
||||
protected function closeWriter()
|
||||
{
|
||||
if ($this->filePointer) {
|
||||
$this->globalFunctionsHelper->fclose($this->filePointer);
|
||||
}
|
||||
|
||||
$this->lastWrittenRowIndex = 0;
|
||||
}
|
||||
}
|
||||
@@ -122,7 +122,7 @@ abstract class AbstractStyleHelper
|
||||
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
|
||||
{
|
||||
// if the "wrap text" option is already set, no-op
|
||||
if ($style->shouldWrapText()) {
|
||||
if ($style->hasSetWrapText()) {
|
||||
return $style;
|
||||
}
|
||||
|
||||
|
||||
@@ -46,6 +46,15 @@ class CellHelper
|
||||
return self::$columnIndexToCellIndexCache[$originalColumnIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $value
|
||||
* @return bool Whether the given value is considered "empty"
|
||||
*/
|
||||
public static function isEmpty($value)
|
||||
{
|
||||
return ($value === null || $value === '');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $value
|
||||
* @return bool Whether the given value is a non empty string
|
||||
|
||||
@@ -16,6 +16,9 @@ abstract class AbstractWorkbook implements WorkbookInterface
|
||||
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
|
||||
protected $shouldCreateNewSheetsAutomatically;
|
||||
|
||||
/** @var string Timestamp based unique ID identifying the workbook */
|
||||
protected $internalId;
|
||||
|
||||
/** @var WorksheetInterface[] Array containing the workbook's sheets */
|
||||
protected $worksheets = [];
|
||||
|
||||
@@ -30,6 +33,7 @@ abstract class AbstractWorkbook implements WorkbookInterface
|
||||
public function __construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle)
|
||||
{
|
||||
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
|
||||
$this->internalId = uniqid();
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -7,7 +7,7 @@ use Box\Spout\Writer\Exception\InvalidSheetNameException;
|
||||
|
||||
/**
|
||||
* Class Sheet
|
||||
* External representation of a worksheet within a ODS file
|
||||
* External representation of a worksheet
|
||||
*
|
||||
* @package Box\Spout\Writer\Common
|
||||
*/
|
||||
@@ -21,12 +21,15 @@ class Sheet
|
||||
/** @var array Invalid characters that cannot be contained in the sheet name */
|
||||
private static $INVALID_CHARACTERS_IN_SHEET_NAME = ['\\', '/', '?', '*', ':', '[', ']'];
|
||||
|
||||
/** @var array Associative array [SHEET_INDEX] => [SHEET_NAME] keeping track of sheets' name to enforce uniqueness */
|
||||
/** @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;
|
||||
|
||||
@@ -35,10 +38,16 @@ class Sheet
|
||||
|
||||
/**
|
||||
* @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)
|
||||
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));
|
||||
}
|
||||
@@ -75,43 +84,58 @@ class Sheet
|
||||
*/
|
||||
public function setName($name)
|
||||
{
|
||||
if (!$this->isNameValid($name)) {
|
||||
$errorMessage = "The sheet's name is invalid. It did not meet at least one of these requirements:\n";
|
||||
$errorMessage .= " - It should not be blank\n";
|
||||
$errorMessage .= " - It should not exceed 31 characters\n";
|
||||
$errorMessage .= " - It should not contain these characters: \\ / ? * : [ or ]\n";
|
||||
$errorMessage .= " - It should be unique";
|
||||
throw new InvalidSheetNameException($errorMessage);
|
||||
}
|
||||
$this->throwIfNameIsInvalid($name);
|
||||
|
||||
$this->name = $name;
|
||||
self::$SHEETS_NAME_USED[$this->index] = $name;
|
||||
self::$SHEETS_NAME_USED[$this->associatedWorkbookId][$this->index] = $name;
|
||||
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given sheet's name is valid.
|
||||
* Throws an exception if the given sheet's name is not valid.
|
||||
* @see Sheet::setName for validity rules.
|
||||
*
|
||||
* @param string $name
|
||||
* @return bool TRUE if the name is valid, FALSE otherwise.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
|
||||
*/
|
||||
protected function isNameValid($name)
|
||||
protected function throwIfNameIsInvalid($name)
|
||||
{
|
||||
if (!is_string($name)) {
|
||||
return false;
|
||||
$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);
|
||||
|
||||
return (
|
||||
$nameLength > 0 &&
|
||||
$nameLength <= self::MAX_LENGTH_SHEET_NAME &&
|
||||
!$this->doesContainInvalidCharacters($name) &&
|
||||
$this->isNameUnique($name) &&
|
||||
!$this->doesStartOrEndWithSingleQuote($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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -148,7 +172,7 @@ class Sheet
|
||||
*/
|
||||
protected function isNameUnique($name)
|
||||
{
|
||||
foreach (self::$SHEETS_NAME_USED as $sheetIndex => $sheetName) {
|
||||
foreach (self::$SHEETS_NAME_USED[$this->associatedWorkbookId] as $sheetIndex => $sheetName) {
|
||||
if ($sheetIndex !== $this->index && $sheetName === $name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception\Border;
|
||||
|
||||
use Box\Spout\Writer\Exception\WriterException;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
class InvalidNameException extends WriterException
|
||||
{
|
||||
public function __construct($name)
|
||||
{
|
||||
$msg = '%s is not a valid name identifier for a border. Valid identifiers are: %s.';
|
||||
|
||||
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedNames())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception\Border;
|
||||
|
||||
use Box\Spout\Writer\Exception\WriterException;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
class InvalidStyleException extends WriterException
|
||||
{
|
||||
public function __construct($name)
|
||||
{
|
||||
$msg = '%s is not a valid style identifier for a border. Valid identifiers are: %s.';
|
||||
|
||||
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedStyles())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Exception\Border;
|
||||
|
||||
use Box\Spout\Writer\Exception\WriterException;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
class InvalidWidthException extends WriterException
|
||||
{
|
||||
public function __construct($name)
|
||||
{
|
||||
$msg = '%s is not a valid width identifier for a border. Valid identifiers are: %s.';
|
||||
|
||||
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedWidths())));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\ODS\Helper;
|
||||
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
|
||||
/**
|
||||
* Class BorderHelper
|
||||
*
|
||||
* The fo:border, fo:border-top, fo:border-bottom, fo:border-left and fo:border-right attributes
|
||||
* specify border properties
|
||||
* http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1419780_253892949
|
||||
*
|
||||
* Example table-cell-properties
|
||||
*
|
||||
* <style:table-cell-properties
|
||||
* fo:border-bottom="0.74pt solid #ffc000" style:diagonal-bl-tr="none"
|
||||
* style:diagonal-tl-br="none" fo:border-left="none" fo:border-right="none"
|
||||
* style:rotation-align="none" fo:border-top="none"/>
|
||||
*/
|
||||
class BorderHelper
|
||||
{
|
||||
/**
|
||||
* Width mappings
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $widthMap = [
|
||||
Border::WIDTH_THIN => '0.75pt',
|
||||
Border::WIDTH_MEDIUM => '1.75pt',
|
||||
Border::WIDTH_THICK => '2.5pt',
|
||||
];
|
||||
|
||||
/**
|
||||
* Style mapping
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected static $styleMap = [
|
||||
Border::STYLE_SOLID => 'solid',
|
||||
Border::STYLE_DASHED => 'dashed',
|
||||
Border::STYLE_DOTTED => 'dotted',
|
||||
Border::STYLE_DOUBLE => 'double',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param BorderPart $borderPart
|
||||
* @return string
|
||||
*/
|
||||
public static function serializeBorderPart(BorderPart $borderPart)
|
||||
{
|
||||
$definition = 'fo:border-%s="%s"';
|
||||
|
||||
if ($borderPart->getStyle() === Border::STYLE_NONE) {
|
||||
$borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none');
|
||||
} else {
|
||||
$attributes = [
|
||||
self::$widthMap[$borderPart->getWidth()],
|
||||
self::$styleMap[$borderPart->getStyle()],
|
||||
'#' . $borderPart->getColor(),
|
||||
];
|
||||
$borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
|
||||
}
|
||||
|
||||
return $borderPartDefinition;
|
||||
}
|
||||
}
|
||||
@@ -75,7 +75,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
||||
*/
|
||||
protected function createRootFolder()
|
||||
{
|
||||
$this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('ods'));
|
||||
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
namespace Box\Spout\Writer\ODS\Helper;
|
||||
|
||||
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
/**
|
||||
* Class StyleHelper
|
||||
@@ -213,13 +214,46 @@ EOD;
|
||||
*/
|
||||
protected function getStyleSectionContent($style)
|
||||
{
|
||||
$defaultStyle = $this->getDefaultStyle();
|
||||
$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 .= '<style:text-properties';
|
||||
$content .= $this->getFontSectionContent($style);
|
||||
}
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
* @return string
|
||||
*/
|
||||
private function getFontSectionContent($style)
|
||||
{
|
||||
$defaultStyle = $this->getDefaultStyle();
|
||||
|
||||
$content = '<style:text-properties';
|
||||
|
||||
$fontColor = $style->getFontColor();
|
||||
if ($fontColor !== $defaultStyle->getFontColor()) {
|
||||
@@ -250,15 +284,73 @@ EOD;
|
||||
}
|
||||
|
||||
$content .= '/>';
|
||||
}
|
||||
|
||||
if ($style->shouldWrapText()) {
|
||||
$content .= '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
|
||||
}
|
||||
|
||||
$content .= '</style:style>';
|
||||
|
||||
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()
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -69,7 +69,7 @@ class Workbook extends AbstractWorkbook
|
||||
public function addNewSheet()
|
||||
{
|
||||
$newSheetIndex = count($this->worksheets);
|
||||
$sheet = new Sheet($newSheetIndex);
|
||||
$sheet = new Sheet($newSheetIndex, $this->internalId);
|
||||
|
||||
$sheetsContentTempFolder = $this->fileSystemHelper->getSheetsContentTempFolder();
|
||||
$worksheet = new Worksheet($sheet, $sheetsContentTempFolder);
|
||||
|
||||
@@ -40,14 +40,14 @@ class Worksheet implements WorksheetInterface
|
||||
|
||||
/**
|
||||
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the ODS will be stored
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
public function __construct($externalSheet, $worksheetFilesFolder)
|
||||
{
|
||||
$this->externalSheet = $externalSheet;
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\ODS();
|
||||
$this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
|
||||
|
||||
$this->stringHelper = new StringHelper();
|
||||
@@ -134,6 +134,10 @@ class Worksheet implements WorksheetInterface
|
||||
*/
|
||||
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);
|
||||
@@ -144,12 +148,14 @@ class Worksheet implements WorksheetInterface
|
||||
$nextCellIndex = 1;
|
||||
|
||||
for ($i = 0; $i < $cellsCount; $i++) {
|
||||
$currentCellValue = $dataRow[$currentCellIndex];
|
||||
$currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
|
||||
|
||||
// Using isset here because it is way faster than array_key_exists...
|
||||
if (!isset($dataRow[$nextCellIndex]) || $currentCellValue !== $dataRow[$nextCellIndex]) {
|
||||
if (!isset($dataRowWithNumericIndexes[$nextCellIndex]) ||
|
||||
$currentCellValue !== $dataRowWithNumericIndexes[$nextCellIndex]) {
|
||||
|
||||
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
|
||||
$data .= $this->getCellContent($currentCellValue, $styleIndex, $numTimesValueRepeated);
|
||||
$data .= $this->getCellXML($currentCellValue, $styleIndex, $numTimesValueRepeated);
|
||||
|
||||
$currentCellIndex = $nextCellIndex;
|
||||
}
|
||||
@@ -177,7 +183,7 @@ class Worksheet implements WorksheetInterface
|
||||
* @return string The cell XML content
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||
*/
|
||||
protected function getCellContent($cellValue, $styleIndex, $numTimesValueRepeated)
|
||||
protected function getCellXML($cellValue, $styleIndex, $numTimesValueRepeated)
|
||||
{
|
||||
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
|
||||
|
||||
@@ -218,6 +224,10 @@ class Worksheet implements WorksheetInterface
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!is_resource($this->sheetFilePointer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fclose($this->sheetFilePointer);
|
||||
}
|
||||
}
|
||||
@@ -20,7 +20,7 @@ class Writer extends AbstractMultiSheetsWriter
|
||||
/** @var string Temporary folder where the files to create the ODS will be stored */
|
||||
protected $tempFolder;
|
||||
|
||||
/** @var Internal\Workbook The workbook for the XLSX file */
|
||||
/** @var Internal\Workbook The workbook for the ODS file */
|
||||
protected $book;
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Style;
|
||||
|
||||
/**
|
||||
* Class Border
|
||||
*/
|
||||
class Border
|
||||
{
|
||||
const LEFT = 'left';
|
||||
const RIGHT = 'right';
|
||||
const TOP = 'top';
|
||||
const BOTTOM = 'bottom';
|
||||
|
||||
const STYLE_NONE = 'none';
|
||||
const STYLE_SOLID = 'solid';
|
||||
const STYLE_DASHED = 'dashed';
|
||||
const STYLE_DOTTED = 'dotted';
|
||||
const STYLE_DOUBLE = 'double';
|
||||
|
||||
const WIDTH_THIN = 'thin';
|
||||
const WIDTH_MEDIUM = 'medium';
|
||||
const WIDTH_THICK = 'thick';
|
||||
|
||||
/**
|
||||
* @var array A list of BorderPart objects for this border.
|
||||
*/
|
||||
protected $parts = [];
|
||||
|
||||
/**
|
||||
* @param array|void $borderParts
|
||||
*/
|
||||
public function __construct(array $borderParts = [])
|
||||
{
|
||||
$this->setParts($borderParts);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name The name of the border part
|
||||
* @return null|BorderPart
|
||||
*/
|
||||
public function getPart($name)
|
||||
{
|
||||
return $this->hasPart($name) ? $this->parts[$name] : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param $name The name of the border part
|
||||
* @return bool
|
||||
*/
|
||||
public function hasPart($name)
|
||||
{
|
||||
return isset($this->parts[$name]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function getParts()
|
||||
{
|
||||
return $this->parts;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set BorderParts
|
||||
* @param array $parts
|
||||
*/
|
||||
public function setParts($parts)
|
||||
{
|
||||
unset($this->parts);
|
||||
foreach ($parts as $part) {
|
||||
$this->addPart($part);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param BorderPart $borderPart
|
||||
* @return self
|
||||
*/
|
||||
public function addPart(BorderPart $borderPart)
|
||||
{
|
||||
$this->parts[$borderPart->getName()] = $borderPart;
|
||||
return $this;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Style;
|
||||
|
||||
/**
|
||||
* Class BorderBuilder
|
||||
*/
|
||||
class BorderBuilder
|
||||
{
|
||||
/**
|
||||
* @var Border
|
||||
*/
|
||||
protected $border;
|
||||
|
||||
public function __construct()
|
||||
{
|
||||
$this->border = new Border();
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|void $color Border A RGB color code
|
||||
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||
* @return BorderBuilder
|
||||
*/
|
||||
public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||
{
|
||||
$this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|void $color Border A RGB color code
|
||||
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||
* @return BorderBuilder
|
||||
*/
|
||||
public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||
{
|
||||
$this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|void $color Border A RGB color code
|
||||
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||
* @return BorderBuilder
|
||||
*/
|
||||
public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||
{
|
||||
$this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string|void $color Border A RGB color code
|
||||
* @param string|void $width Border width @see BorderPart::allowedWidths
|
||||
* @param string|void $style Border style @see BorderPart::allowedStyles
|
||||
* @return BorderBuilder
|
||||
*/
|
||||
public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||
{
|
||||
$this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style));
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return Border
|
||||
*/
|
||||
public function build()
|
||||
{
|
||||
return $this->border;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Style;
|
||||
|
||||
use Box\Spout\Writer\Exception\Border\InvalidNameException;
|
||||
use Box\Spout\Writer\Exception\Border\InvalidStyleException;
|
||||
use Box\Spout\Writer\Exception\Border\InvalidWidthException;
|
||||
|
||||
/**
|
||||
* Class BorderPart
|
||||
*/
|
||||
class BorderPart
|
||||
{
|
||||
/**
|
||||
* @var string The style of this border part.
|
||||
*/
|
||||
protected $style;
|
||||
|
||||
/**
|
||||
* @var string The name of this border part.
|
||||
*/
|
||||
protected $name;
|
||||
|
||||
/**
|
||||
* @var string The color of this border part.
|
||||
*/
|
||||
protected $color;
|
||||
|
||||
/**
|
||||
* @var string The width of this border part.
|
||||
*/
|
||||
protected $width;
|
||||
|
||||
/**
|
||||
* @var array Allowed style constants for parts.
|
||||
*/
|
||||
protected static $allowedStyles = [
|
||||
'none',
|
||||
'solid',
|
||||
'dashed',
|
||||
'dotted',
|
||||
'double'
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Allowed names constants for border parts.
|
||||
*/
|
||||
protected static $allowedNames = [
|
||||
'left',
|
||||
'right',
|
||||
'top',
|
||||
'bottom',
|
||||
];
|
||||
|
||||
/**
|
||||
* @var array Allowed width constants for border parts.
|
||||
*/
|
||||
protected static $allowedWidths = [
|
||||
'thin',
|
||||
'medium',
|
||||
'thick',
|
||||
];
|
||||
|
||||
/**
|
||||
* @param string $name @see BorderPart::$allowedNames
|
||||
* @param string $color A RGB color code
|
||||
* @param string $width @see BorderPart::$allowedWidths
|
||||
* @param string $style @see BorderPart::$allowedStyles
|
||||
* @throws InvalidNameException
|
||||
* @throws InvalidStyleException
|
||||
* @throws InvalidWidthException
|
||||
*/
|
||||
public function __construct($name, $color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
|
||||
{
|
||||
$this->setName($name);
|
||||
$this->setColor($color);
|
||||
$this->setWidth($width);
|
||||
$this->setStyle($style);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $name The name of the border part @see BorderPart::$allowedNames
|
||||
* @throws InvalidNameException
|
||||
* @return void
|
||||
*/
|
||||
public function setName($name)
|
||||
{
|
||||
if (!in_array($name, self::$allowedNames)) {
|
||||
throw new InvalidNameException($name);
|
||||
}
|
||||
$this->name = $name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getStyle()
|
||||
{
|
||||
return $this->style;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $style The style of the border part @see BorderPart::$allowedStyles
|
||||
* @throws InvalidStyleException
|
||||
* @return void
|
||||
*/
|
||||
public function setStyle($style)
|
||||
{
|
||||
if (!in_array($style, self::$allowedStyles)) {
|
||||
throw new InvalidStyleException($style);
|
||||
}
|
||||
$this->style = $style;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getColor()
|
||||
{
|
||||
return $this->color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $color The color of the border part @see Color::rgb()
|
||||
* @return void
|
||||
*/
|
||||
public function setColor($color)
|
||||
{
|
||||
$this->color = $color;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getWidth()
|
||||
{
|
||||
return $this->width;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $width The width of the border part @see BorderPart::$allowedWidths
|
||||
* @throws InvalidWidthException
|
||||
* @return void
|
||||
*/
|
||||
public function setWidth($width)
|
||||
{
|
||||
if (!in_array($width, self::$allowedWidths)) {
|
||||
throw new InvalidWidthException($width);
|
||||
}
|
||||
$this->width = $width;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllowedStyles()
|
||||
{
|
||||
return self::$allowedStyles;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllowedNames()
|
||||
{
|
||||
return self::$allowedNames;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public static function getAllowedWidths()
|
||||
{
|
||||
return self::$allowedWidths;
|
||||
}
|
||||
}
|
||||
@@ -61,6 +61,23 @@ class Style
|
||||
/** @var bool Whether the wrap text property was set */
|
||||
protected $hasSetWrapText = false;
|
||||
|
||||
/**
|
||||
* @var Border
|
||||
*/
|
||||
protected $border = null;
|
||||
|
||||
/**
|
||||
* @var bool Whether border properties should be applied
|
||||
*/
|
||||
protected $shouldApplyBorder = false;
|
||||
|
||||
/** @var string Background color */
|
||||
protected $backgroundColor = null;
|
||||
|
||||
/** @var bool */
|
||||
protected $hasSetBackgroundColor = false;
|
||||
|
||||
|
||||
/**
|
||||
* @return int|null
|
||||
*/
|
||||
@@ -80,7 +97,34 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
* @return Border
|
||||
*/
|
||||
public function getBorder()
|
||||
{
|
||||
return $this->border;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Border $border
|
||||
* @return Style
|
||||
*/
|
||||
public function setBorder(Border $border)
|
||||
{
|
||||
$this->shouldApplyBorder = true;
|
||||
$this->border = $border;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldApplyBorder()
|
||||
{
|
||||
return $this->shouldApplyBorder;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function isFontBold()
|
||||
{
|
||||
@@ -99,7 +143,7 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function isFontItalic()
|
||||
{
|
||||
@@ -118,7 +162,7 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function isFontUnderline()
|
||||
{
|
||||
@@ -137,7 +181,7 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function isFontStrikethrough()
|
||||
{
|
||||
@@ -218,7 +262,7 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @return boolean
|
||||
* @return bool
|
||||
*/
|
||||
public function shouldWrapText()
|
||||
{
|
||||
@@ -226,15 +270,24 @@ class Style
|
||||
}
|
||||
|
||||
/**
|
||||
* @param bool|void $shouldWrap Should the text be wrapped
|
||||
* @return Style
|
||||
*/
|
||||
public function setShouldWrapText()
|
||||
public function setShouldWrapText($shouldWrap = true)
|
||||
{
|
||||
$this->shouldWrapText = true;
|
||||
$this->shouldWrapText = $shouldWrap;
|
||||
$this->hasSetWrapText = true;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSetWrapText()
|
||||
{
|
||||
return $this->hasSetWrapText;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool Whether specific font properties should be applied
|
||||
*/
|
||||
@@ -243,6 +296,35 @@ class Style
|
||||
return $this->shouldApplyFont;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the background color
|
||||
* @param string $color ARGB color (@see Color)
|
||||
* @return Style
|
||||
*/
|
||||
public function setBackgroundColor($color)
|
||||
{
|
||||
$this->hasSetBackgroundColor = true;
|
||||
$this->backgroundColor = $color;
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function getBackgroundColor()
|
||||
{
|
||||
return $this->backgroundColor;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @return bool Whether the background color should be applied
|
||||
*/
|
||||
public function shouldApplyBackgroundColor()
|
||||
{
|
||||
return $this->hasSetBackgroundColor;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serializes the style for future comparison with other styles.
|
||||
* The ID is excluded from the comparison, as we only care about
|
||||
@@ -278,31 +360,67 @@ class Style
|
||||
{
|
||||
$mergedStyle = clone $this;
|
||||
|
||||
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
|
||||
$mergedStyle->setFontBold();
|
||||
}
|
||||
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
|
||||
$mergedStyle->setFontItalic();
|
||||
}
|
||||
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
|
||||
$mergedStyle->setFontUnderline();
|
||||
}
|
||||
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
|
||||
$mergedStyle->setFontStrikethrough();
|
||||
}
|
||||
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
|
||||
$mergedStyle->setFontSize($baseStyle->getFontSize());
|
||||
}
|
||||
if (!$this->hasSetFontColor && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
|
||||
$mergedStyle->setFontColor($baseStyle->getFontColor());
|
||||
}
|
||||
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
|
||||
$mergedStyle->setFontName($baseStyle->getFontName());
|
||||
}
|
||||
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
|
||||
$mergedStyle->setShouldWrapText();
|
||||
}
|
||||
$this->mergeFontStyles($mergedStyle, $baseStyle);
|
||||
$this->mergeOtherFontProperties($mergedStyle, $baseStyle);
|
||||
$this->mergeCellProperties($mergedStyle, $baseStyle);
|
||||
|
||||
return $mergedStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Style $styleToUpdate (passed as reference)
|
||||
* @param Style $baseStyle
|
||||
* @return void
|
||||
*/
|
||||
private function mergeFontStyles($styleToUpdate, $baseStyle)
|
||||
{
|
||||
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
|
||||
$styleToUpdate->setFontBold();
|
||||
}
|
||||
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
|
||||
$styleToUpdate->setFontItalic();
|
||||
}
|
||||
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
|
||||
$styleToUpdate->setFontUnderline();
|
||||
}
|
||||
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
|
||||
$styleToUpdate->setFontStrikethrough();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Style $styleToUpdate Style to update (passed as reference)
|
||||
* @param Style $baseStyle
|
||||
* @return void
|
||||
*/
|
||||
private function mergeOtherFontProperties($styleToUpdate, $baseStyle)
|
||||
{
|
||||
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
|
||||
$styleToUpdate->setFontSize($baseStyle->getFontSize());
|
||||
}
|
||||
if (!$this->hasSetFontColor && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
|
||||
$styleToUpdate->setFontColor($baseStyle->getFontColor());
|
||||
}
|
||||
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
|
||||
$styleToUpdate->setFontName($baseStyle->getFontName());
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param Style $styleToUpdate Style to update (passed as reference)
|
||||
* @param Style $baseStyle
|
||||
* @return void
|
||||
*/
|
||||
private function mergeCellProperties($styleToUpdate, $baseStyle)
|
||||
{
|
||||
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
|
||||
$styleToUpdate->setShouldWrapText();
|
||||
}
|
||||
if (!$this->getBorder() && $baseStyle->shouldApplyBorder()) {
|
||||
$styleToUpdate->setBorder($baseStyle->getBorder());
|
||||
}
|
||||
if (!$this->hasSetBackgroundColor && $baseStyle->shouldApplyBackgroundColor()) {
|
||||
$styleToUpdate->setBackgroundColor($baseStyle->getBackgroundColor());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -109,15 +109,40 @@ class StyleBuilder
|
||||
}
|
||||
|
||||
/**
|
||||
* Makes the text wrap in the cell if it's too long or
|
||||
* on multiple lines.
|
||||
* Makes the text wrap in the cell if requested
|
||||
*
|
||||
* @api
|
||||
* @param bool $shouldWrap Should the text be wrapped
|
||||
* @return StyleBuilder
|
||||
*/
|
||||
public function setShouldWrapText()
|
||||
public function setShouldWrapText($shouldWrap = true)
|
||||
{
|
||||
$this->style->setShouldWrapText();
|
||||
$this->style->setShouldWrapText($shouldWrap);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Set a border
|
||||
*
|
||||
* @param Border $border
|
||||
* @return $this
|
||||
*/
|
||||
public function setBorder(Border $border)
|
||||
{
|
||||
$this->style->setBorder($border);
|
||||
return $this;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a background color
|
||||
*
|
||||
* @api
|
||||
* @param string $color ARGB color (@see Color)
|
||||
* @return StyleBuilder
|
||||
*/
|
||||
public function setBackgroundColor($color)
|
||||
{
|
||||
$this->style->setBackgroundColor($color);
|
||||
return $this;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderPart;
|
||||
|
||||
class BorderHelper
|
||||
{
|
||||
public static $xlsxStyleMap = [
|
||||
Border::STYLE_SOLID => [
|
||||
Border::WIDTH_THIN => 'thin',
|
||||
Border::WIDTH_MEDIUM => 'medium',
|
||||
Border::WIDTH_THICK => 'thick'
|
||||
],
|
||||
Border::STYLE_DOTTED => [
|
||||
Border::WIDTH_THIN => 'dotted',
|
||||
Border::WIDTH_MEDIUM => 'dotted',
|
||||
Border::WIDTH_THICK => 'dotted',
|
||||
],
|
||||
Border::STYLE_DASHED => [
|
||||
Border::WIDTH_THIN => 'dashed',
|
||||
Border::WIDTH_MEDIUM => 'mediumDashed',
|
||||
Border::WIDTH_THICK => 'mediumDashed',
|
||||
],
|
||||
Border::STYLE_DOUBLE => [
|
||||
Border::WIDTH_THIN => 'double',
|
||||
Border::WIDTH_MEDIUM => 'double',
|
||||
Border::WIDTH_THICK => 'double',
|
||||
],
|
||||
Border::STYLE_NONE => [
|
||||
Border::WIDTH_THIN => 'none',
|
||||
Border::WIDTH_MEDIUM => 'none',
|
||||
Border::WIDTH_THICK => 'none',
|
||||
],
|
||||
];
|
||||
|
||||
/**
|
||||
* @param BorderPart $borderPart
|
||||
* @return string
|
||||
*/
|
||||
public static function serializeBorderPart(BorderPart $borderPart)
|
||||
{
|
||||
$borderStyle = self::getBorderStyle($borderPart);
|
||||
|
||||
$colorEl = $borderPart->getColor() ? sprintf('<color rgb="%s"/>', $borderPart->getColor()) : '';
|
||||
$partEl = sprintf(
|
||||
'<%s style="%s">%s</%s>',
|
||||
$borderPart->getName(),
|
||||
$borderStyle,
|
||||
$colorEl,
|
||||
$borderPart->getName()
|
||||
);
|
||||
|
||||
return $partEl . PHP_EOL;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the style definition from the style map
|
||||
*
|
||||
* @param BorderPart $borderPart
|
||||
* @return string
|
||||
*/
|
||||
protected static function getBorderStyle(BorderPart $borderPart)
|
||||
{
|
||||
return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()];
|
||||
}
|
||||
}
|
||||
@@ -94,7 +94,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
|
||||
*/
|
||||
protected function createRootFolder()
|
||||
{
|
||||
$this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('xlsx', true));
|
||||
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
|
||||
return $this;
|
||||
}
|
||||
|
||||
@@ -284,7 +284,7 @@ EOD;
|
||||
EOD;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
|
||||
/** @var Worksheet $worksheet */
|
||||
foreach ($worksheets as $worksheet) {
|
||||
|
||||
@@ -49,7 +49,7 @@ EOD;
|
||||
fwrite($this->sharedStringsFilePointer, $header);
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -88,6 +88,10 @@ EOD;
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!is_resource($this->sharedStringsFilePointer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite($this->sharedStringsFilePointer, '</sst>');
|
||||
|
||||
// Replace the default strings count with the actual number of shared strings in the file header
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Box\Spout\Writer\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\Style;
|
||||
|
||||
/**
|
||||
* Class StyleHelper
|
||||
@@ -13,6 +14,131 @@ use Box\Spout\Writer\Style\Color;
|
||||
*/
|
||||
class StyleHelper extends AbstractStyleHelper
|
||||
{
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $registeredFills = [];
|
||||
|
||||
/**
|
||||
* @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration
|
||||
*/
|
||||
protected $styleIdToFillMappingTable = [];
|
||||
|
||||
/**
|
||||
* Excel preserves two default fills with index 0 and 1
|
||||
* Since Excel is the dominant vendor - we play along here
|
||||
*
|
||||
* @var int The fill index counter for custom fills.
|
||||
*/
|
||||
protected $fillIndex = 2;
|
||||
|
||||
/**
|
||||
* @var array
|
||||
*/
|
||||
protected $registeredBorders = [];
|
||||
|
||||
/**
|
||||
* @var array [STYLE_ID] => [BORDER_ID] maps a style to a border declaration
|
||||
*/
|
||||
protected $styleIdToBorderMappingTable = [];
|
||||
|
||||
/**
|
||||
* XLSX specific operations on the registered styles
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
* @return \Box\Spout\Writer\Style\Style
|
||||
*/
|
||||
public function registerStyle($style)
|
||||
{
|
||||
$registeredStyle = parent::registerStyle($style);
|
||||
$this->registerFill($registeredStyle);
|
||||
$this->registerBorder($registeredStyle);
|
||||
return $registeredStyle;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a fill definition
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
*/
|
||||
protected function registerFill($style)
|
||||
{
|
||||
$styleId = $style->getId();
|
||||
|
||||
// Currently - only solid backgrounds are supported
|
||||
// so $backgroundColor is a scalar value (RGB Color)
|
||||
$backgroundColor = $style->getBackgroundColor();
|
||||
|
||||
if ($backgroundColor) {
|
||||
$isBackgroundColorRegistered = isset($this->registeredFills[$backgroundColor]);
|
||||
|
||||
// We need to track the already registered background definitions
|
||||
if ($isBackgroundColorRegistered) {
|
||||
$registeredStyleId = $this->registeredFills[$backgroundColor];
|
||||
$registeredFillId = $this->styleIdToFillMappingTable[$registeredStyleId];
|
||||
$this->styleIdToFillMappingTable[$styleId] = $registeredFillId;
|
||||
} else {
|
||||
$this->registeredFills[$backgroundColor] = $styleId;
|
||||
$this->styleIdToFillMappingTable[$styleId] = $this->fillIndex++;
|
||||
}
|
||||
|
||||
} else {
|
||||
// The fillId maps a style to a fill declaration
|
||||
// When there is no background color definition - we default to 0
|
||||
$this->styleIdToFillMappingTable[$styleId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a border definition
|
||||
*
|
||||
* @param \Box\Spout\Writer\Style\Style $style
|
||||
*/
|
||||
protected function registerBorder($style)
|
||||
{
|
||||
$styleId = $style->getId();
|
||||
|
||||
if ($style->shouldApplyBorder()) {
|
||||
$border = $style->getBorder();
|
||||
$serializedBorder = serialize($border);
|
||||
|
||||
$isBorderAlreadyRegistered = isset($this->registeredBorders[$serializedBorder]);
|
||||
|
||||
if ($isBorderAlreadyRegistered) {
|
||||
$registeredStyleId = $this->registeredBorders[$serializedBorder];
|
||||
$registeredBorderId = $this->styleIdToBorderMappingTable[$registeredStyleId];
|
||||
$this->styleIdToBorderMappingTable[$styleId] = $registeredBorderId;
|
||||
} else {
|
||||
$this->registeredBorders[$serializedBorder] = $styleId;
|
||||
$this->styleIdToBorderMappingTable[$styleId] = count($this->registeredBorders);
|
||||
}
|
||||
|
||||
} else {
|
||||
// If no border should be applied - the mapping is the default border: 0
|
||||
$this->styleIdToBorderMappingTable[$styleId] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* For empty cells, we can specify a style or not. If no style are specified,
|
||||
* then the software default will be applied. But sometimes, it may be useful
|
||||
* to override this default style, for instance if the cell should have a
|
||||
* background color different than the default one or some borders
|
||||
* (fonts property don't really matter here).
|
||||
*
|
||||
* @param int $styleId
|
||||
* @return bool Whether the cell should define a custom style
|
||||
*/
|
||||
public function shouldApplyStyleOnEmptyCell($styleId)
|
||||
{
|
||||
$hasStyleCustomFill = (isset($this->styleIdToFillMappingTable[$styleId]) && $this->styleIdToFillMappingTable[$styleId] !== 0);
|
||||
$hasStyleCustomBorders = (isset($this->styleIdToBorderMappingTable[$styleId]) && $this->styleIdToBorderMappingTable[$styleId] !== 0);
|
||||
|
||||
return ($hasStyleCustomFill || $hasStyleCustomBorders);
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns the content of the "styles.xml" file, given a list of styles.
|
||||
*
|
||||
@@ -84,13 +210,28 @@ EOD;
|
||||
*/
|
||||
protected function getFillsSectionContent()
|
||||
{
|
||||
return <<<EOD
|
||||
<fills count="1">
|
||||
<fill>
|
||||
<patternFill patternType="none"/>
|
||||
</fill>
|
||||
</fills>
|
||||
EOD;
|
||||
// Excel reserves two default fills
|
||||
$fillsCount = count($this->registeredFills) + 2;
|
||||
$content = sprintf('<fills count="%d">', $fillsCount);
|
||||
|
||||
$content .= '<fill><patternFill patternType="none"/></fill>';
|
||||
$content .= '<fill><patternFill patternType="gray125"/></fill>';
|
||||
|
||||
// The other fills are actually registered by setting a background color
|
||||
foreach ($this->registeredFills as $styleId) {
|
||||
/** @var Style $style */
|
||||
$style = $this->styleIdToStyleMappingTable[$styleId];
|
||||
|
||||
$backgroundColor = $style->getBackgroundColor();
|
||||
$content .= sprintf(
|
||||
'<fill><patternFill patternType="solid"><fgColor rgb="%s"/></patternFill></fill>',
|
||||
$backgroundColor
|
||||
);
|
||||
}
|
||||
|
||||
$content .= '</fills>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -100,17 +241,39 @@ EOD;
|
||||
*/
|
||||
protected function getBordersSectionContent()
|
||||
{
|
||||
return <<<EOD
|
||||
<borders count="1">
|
||||
<border>
|
||||
<left/>
|
||||
<right/>
|
||||
<top/>
|
||||
<bottom/>
|
||||
<diagonal/>
|
||||
</border>
|
||||
</borders>
|
||||
EOD;
|
||||
|
||||
// There is one default border with index 0
|
||||
$borderCount = count($this->registeredBorders) + 1;
|
||||
|
||||
$content = '<borders count="' . $borderCount . '">';
|
||||
|
||||
// Default border starting at index 0
|
||||
$content .= '<border><left/><right/><top/><bottom/></border>';
|
||||
|
||||
foreach ($this->registeredBorders as $styleId) {
|
||||
/** @var \Box\Spout\Writer\Style\Style $style */
|
||||
$style = $this->styleIdToStyleMappingTable[$styleId];
|
||||
$border = $style->getBorder();
|
||||
$content .= '<border>';
|
||||
|
||||
// @link https://github.com/box/spout/issues/271
|
||||
$sortOrder = ['left', 'right', 'top', 'bottom'];
|
||||
|
||||
foreach ($sortOrder as $partName) {
|
||||
if ($border->hasPart($partName)) {
|
||||
/** @var $part \Box\Spout\Writer\Style\BorderPart */
|
||||
$part = $border->getPart($partName);
|
||||
$content .= BorderHelper::serializeBorderPart($part);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
$content .= '</border>';
|
||||
}
|
||||
|
||||
$content .= '</borders>';
|
||||
|
||||
return $content;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -139,12 +302,18 @@ EOD;
|
||||
$content = '<cellXfs count="' . count($registeredStyles) . '">';
|
||||
|
||||
foreach ($registeredStyles as $style) {
|
||||
$content .= '<xf numFmtId="0" fontId="' . $style->getId() . '" fillId="0" borderId="0" xfId="0"';
|
||||
$styleId = $style->getId();
|
||||
$fillId = $this->styleIdToFillMappingTable[$styleId];
|
||||
$borderId = $this->styleIdToBorderMappingTable[$styleId];
|
||||
|
||||
$content .= '<xf numFmtId="0" fontId="' . $styleId . '" fillId="' . $fillId . '" borderId="' . $borderId . '" xfId="0"';
|
||||
|
||||
if ($style->shouldApplyFont()) {
|
||||
$content .= ' applyFont="1"';
|
||||
}
|
||||
|
||||
$content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0);
|
||||
|
||||
if ($style->shouldWrapText()) {
|
||||
$content .= ' applyAlignment="1">';
|
||||
$content .= '<alignment wrapText="1"/>';
|
||||
|
||||
@@ -83,10 +83,10 @@ class Workbook extends AbstractWorkbook
|
||||
public function addNewSheet()
|
||||
{
|
||||
$newSheetIndex = count($this->worksheets);
|
||||
$sheet = new Sheet($newSheetIndex);
|
||||
$sheet = new Sheet($newSheetIndex, $this->internalId);
|
||||
|
||||
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
|
||||
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->shouldUseInlineStrings);
|
||||
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->styleHelper, $this->shouldUseInlineStrings);
|
||||
$this->worksheets[] = $worksheet;
|
||||
|
||||
return $worksheet;
|
||||
|
||||
@@ -4,6 +4,7 @@ namespace Box\Spout\Writer\XLSX\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;
|
||||
|
||||
@@ -16,6 +17,14 @@ use Box\Spout\Writer\Common\Internal\WorksheetInterface;
|
||||
*/
|
||||
class Worksheet implements WorksheetInterface
|
||||
{
|
||||
/**
|
||||
* Maximum number of characters a cell can contain
|
||||
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-16c69c74-3d6a-4aaf-ba35-e6eb276e8eaa [Excel 2007]
|
||||
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3 [Excel 2010]
|
||||
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-ca36e2dc-1f09-4620-b726-67c00b05040f [Excel 2013/2016]
|
||||
*/
|
||||
const MAX_CHARACTERS_PER_CELL = 32767;
|
||||
|
||||
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">
|
||||
@@ -30,12 +39,18 @@ EOD;
|
||||
/** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
|
||||
protected $sharedStringsHelper;
|
||||
|
||||
/** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles */
|
||||
protected $styleHelper;
|
||||
|
||||
/** @var bool Whether inline or shared strings should be used */
|
||||
protected $shouldUseInlineStrings;
|
||||
|
||||
/** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */
|
||||
protected $stringsEscaper;
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\StringHelper String helper */
|
||||
protected $stringHelper;
|
||||
|
||||
/** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */
|
||||
protected $sheetFilePointer;
|
||||
|
||||
@@ -46,17 +61,20 @@ EOD;
|
||||
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
|
||||
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
|
||||
* @param \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper $sharedStringsHelper Helper for shared strings
|
||||
* @param \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles
|
||||
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
|
||||
*/
|
||||
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $shouldUseInlineStrings)
|
||||
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $styleHelper, $shouldUseInlineStrings)
|
||||
{
|
||||
$this->externalSheet = $externalSheet;
|
||||
$this->sharedStringsHelper = $sharedStringsHelper;
|
||||
$this->styleHelper = $styleHelper;
|
||||
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
$this->stringHelper = new StringHelper();
|
||||
|
||||
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
|
||||
$this->startSheet();
|
||||
@@ -126,6 +144,39 @@ EOD;
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||
*/
|
||||
public function addRow($dataRow, $style)
|
||||
{
|
||||
if (!$this->isEmptyRow($dataRow)) {
|
||||
$this->addNonEmptyRow($dataRow, $style);
|
||||
}
|
||||
|
||||
$this->lastWrittenRowIndex++;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the given row is empty
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written. Cannot be empty.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @return bool Whether the given row is empty
|
||||
*/
|
||||
private function isEmptyRow($dataRow)
|
||||
{
|
||||
$numCells = count($dataRow);
|
||||
// using "reset()" instead of "$dataRow[0]" because $dataRow can be an associative array
|
||||
return ($numCells === 1 && CellHelper::isEmpty(reset($dataRow)));
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds non empty row to the worksheet.
|
||||
*
|
||||
* @param array $dataRow Array containing data to be written. Cannot be empty.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||
*/
|
||||
private function addNonEmptyRow($dataRow, $style)
|
||||
{
|
||||
$cellNumber = 0;
|
||||
$rowIndex = $this->lastWrittenRowIndex + 1;
|
||||
@@ -134,29 +185,7 @@ EOD;
|
||||
$rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
|
||||
|
||||
foreach($dataRow as $cellValue) {
|
||||
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
||||
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
|
||||
$cellXML .= ' s="' . $style->getId() . '"';
|
||||
|
||||
if (CellHelper::isNonEmptyString($cellValue)) {
|
||||
if ($this->shouldUseInlineStrings) {
|
||||
$cellXML .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
|
||||
} else {
|
||||
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
|
||||
$cellXML .= ' t="s"><v>' . $sharedStringId . '</v></c>';
|
||||
}
|
||||
} else if (CellHelper::isBoolean($cellValue)) {
|
||||
$cellXML .= ' t="b"><v>' . $cellValue . '</v></c>';
|
||||
} else if (CellHelper::isNumeric($cellValue)) {
|
||||
$cellXML .= '><v>' . $cellValue . '</v></c>';
|
||||
} else if (empty($cellValue)) {
|
||||
// don't write empty cells (not appending to $cellXML is the right behavior!)
|
||||
$cellXML = '';
|
||||
} else {
|
||||
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
||||
}
|
||||
|
||||
$rowXML .= $cellXML;
|
||||
$rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style->getId());
|
||||
$cellNumber++;
|
||||
}
|
||||
|
||||
@@ -166,9 +195,66 @@ EOD;
|
||||
if ($wasWriteSuccessful === false) {
|
||||
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
|
||||
}
|
||||
}
|
||||
|
||||
// only update the count if the write worked
|
||||
$this->lastWrittenRowIndex++;
|
||||
/**
|
||||
* Build and return xml for a single cell.
|
||||
*
|
||||
* @param int $rowIndex
|
||||
* @param int $cellNumber
|
||||
* @param mixed $cellValue
|
||||
* @param int $styleId
|
||||
* @return string
|
||||
* @throws InvalidArgumentException If the given value cannot be processed
|
||||
*/
|
||||
private function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
|
||||
{
|
||||
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
||||
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
|
||||
$cellXML .= ' s="' . $styleId . '"';
|
||||
|
||||
if (CellHelper::isNonEmptyString($cellValue)) {
|
||||
$cellXML .= $this->getCellXMLFragmentForNonEmptyString($cellValue);
|
||||
} else if (CellHelper::isBoolean($cellValue)) {
|
||||
$cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
|
||||
} else if (CellHelper::isNumeric($cellValue)) {
|
||||
$cellXML .= '><v>' . $cellValue . '</v></c>';
|
||||
} else if (empty($cellValue)) {
|
||||
if ($this->styleHelper->shouldApplyStyleOnEmptyCell($styleId)) {
|
||||
$cellXML .= '/>';
|
||||
} else {
|
||||
// don't write empty cells that do no need styling
|
||||
// NOTE: not appending to $cellXML is the right behavior!!
|
||||
$cellXML = '';
|
||||
}
|
||||
} else {
|
||||
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
|
||||
}
|
||||
|
||||
return $cellXML;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the XML fragment for a cell containing a non empty string
|
||||
*
|
||||
* @param string $cellValue The cell value
|
||||
* @return string The XML fragment representing the cell
|
||||
* @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
|
||||
*/
|
||||
private function getCellXMLFragmentForNonEmptyString($cellValue)
|
||||
{
|
||||
if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
|
||||
throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
|
||||
}
|
||||
|
||||
if ($this->shouldUseInlineStrings) {
|
||||
$cellXMLFragment = ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
|
||||
} else {
|
||||
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
|
||||
$cellXMLFragment = ' t="s"><v>' . $sharedStringId . '</v></c>';
|
||||
}
|
||||
|
||||
return $cellXMLFragment;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -178,6 +264,10 @@ EOD;
|
||||
*/
|
||||
public function close()
|
||||
{
|
||||
if (!is_resource($this->sheetFilePointer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
fwrite($this->sheetFilePointer, '</sheetData>');
|
||||
fwrite($this->sheetFilePointer, '</worksheet>');
|
||||
fclose($this->sheetFilePointer);
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Common\Escaper;
|
||||
|
||||
/**
|
||||
* Class ODSTest
|
||||
*
|
||||
* @package Box\Spout\Common\Escaper
|
||||
*/
|
||||
class ODSTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestEscape()
|
||||
{
|
||||
return [
|
||||
['test', 'test'],
|
||||
['carl\'s "pokemon"', 'carl's "pokemon"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
["\v", "�"],
|
||||
["\f", "�"],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestEscape
|
||||
*
|
||||
* @param string $stringToEscape
|
||||
* @param string $expectedEscapedString
|
||||
* @return void
|
||||
*/
|
||||
public function testEscape($stringToEscape, $expectedEscapedString)
|
||||
{
|
||||
$escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
$escapedString = $escaper->escape($stringToEscape);
|
||||
|
||||
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
return [
|
||||
['test', 'test'],
|
||||
['adam\'s "car"', 'adam's "car"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
[chr(0), '_x0000_'],
|
||||
[chr(4), '_x0004_'],
|
||||
['_x0000_', '_x005F_x0000_'],
|
||||
[chr(21), '_x0015_'],
|
||||
['control '.chr(21).' character', 'control _x0015_ character'],
|
||||
@@ -35,7 +39,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
public function testEscape($stringToEscape, $expectedEscapedString)
|
||||
{
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
$escapedString = $escaper->escape($stringToEscape);
|
||||
|
||||
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
|
||||
@@ -49,7 +53,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
return [
|
||||
['test', 'test'],
|
||||
['adam's "car"', 'adam\'s "car"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
['_x0000_', chr(0)],
|
||||
['_x0004_', chr(4)],
|
||||
['_x005F_x0000_', '_x0000_'],
|
||||
['_x0015_', chr(21)],
|
||||
['control _x0015_ character', 'control '.chr(21).' character'],
|
||||
@@ -67,7 +75,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
public function testUnescape($stringToUnescape, $expectedUnescapedString)
|
||||
{
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
$unescapedString = $escaper->unescape($stringToUnescape);
|
||||
|
||||
$this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string');
|
||||
|
||||
@@ -115,29 +115,40 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
* @return void
|
||||
*/
|
||||
public function dataProviderForTestReadShouldSkipEmptyLines()
|
||||
public function testReadShouldSkipEmptyLinesIfShouldPreserveEmptyRowsNotSet()
|
||||
{
|
||||
return [
|
||||
['csv_with_empty_line.csv'],
|
||||
['csv_with_empty_last_line.csv'],
|
||||
$allRows = $this->getAllRowsForFile('csv_with_multiple_empty_lines.csv');
|
||||
|
||||
$expectedRows = [
|
||||
// skipped row here
|
||||
['csv--21', 'csv--22', 'csv--23'],
|
||||
// skipped row here
|
||||
['csv--41', 'csv--42', 'csv--43'],
|
||||
// skipped row here
|
||||
// last row empty
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestReadShouldSkipEmptyLines
|
||||
*
|
||||
* @param string $fileName
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSkipEmptyLines($fileName)
|
||||
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile($fileName);
|
||||
$allRows = $this->getAllRowsForFile(
|
||||
'csv_with_multiple_empty_lines.csv',
|
||||
',', '"', "\n", EncodingHelper::ENCODING_UTF8,
|
||||
$shouldPreserveEmptyRows = true
|
||||
);
|
||||
|
||||
$expectedRows = [
|
||||
['csv--11', 'csv--12', 'csv--13'],
|
||||
['csv--31', 'csv--32', 'csv--33'],
|
||||
[''],
|
||||
['csv--21', 'csv--22', 'csv--23'],
|
||||
[''],
|
||||
['csv--41', 'csv--42', 'csv--43'],
|
||||
[''],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
@@ -204,6 +215,30 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals('This is, a comma', $allRows[0][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadCustomEOLs()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('csv_with_CR_EOL.csv', ',', '"', "\r");
|
||||
|
||||
$expectedRows = [
|
||||
['csv--11', 'csv--12', 'csv--13'],
|
||||
['csv--21', 'csv--22', 'csv--23'],
|
||||
['csv--31', 'csv--32', 'csv--33'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldNotTruncateLineBreak()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('csv_with_line_breaks.csv', ',');
|
||||
$this->assertEquals("This is,\na comma", $allRows[0][0]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
@@ -227,7 +262,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testReadShouldSkipBom($fileName, $fileEncoding)
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile($fileName, ',', '"', $fileEncoding);
|
||||
$allRows = $this->getAllRowsForFile($fileName, ',', '"', "\n", $fileEncoding);
|
||||
|
||||
$expectedRows = [
|
||||
['csv--11', 'csv--12', 'csv--13'],
|
||||
@@ -266,6 +301,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$allRows = [];
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
|
||||
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper|\PHPUnit_Framework_MockObject_MockObject $helperStub */
|
||||
$helperStub = $this->getMockBuilder('\Box\Spout\Common\Helper\GlobalFunctionsHelper')
|
||||
->setMethods(['function_exists'])
|
||||
->getMock();
|
||||
@@ -343,18 +379,71 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldInludeRowsWithZerosOnly()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.csv');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['1', '2', '3'],
|
||||
['0', '0', '0']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.csv');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['0', '', ''],
|
||||
['1', '1', '']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/195
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldNotTrimCellValues()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.csv');
|
||||
|
||||
$expectedRows = [
|
||||
['A'],
|
||||
[' A '],
|
||||
["\n\tA\n\t"],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @param string|void $fieldDelimiter
|
||||
* @param string|void $fieldEnclosure
|
||||
* @param string|void $endOfLineCharacter
|
||||
* @param string|void $encoding
|
||||
* @param bool|void $shouldPreserveEmptyRows
|
||||
* @return array All the read rows the given file
|
||||
*/
|
||||
private function getAllRowsForFile(
|
||||
$fileName,
|
||||
$fieldDelimiter = ',',
|
||||
$fieldEnclosure = '"',
|
||||
$encoding = EncodingHelper::ENCODING_UTF8)
|
||||
$endOfLineCharacter = "\n",
|
||||
$encoding = EncodingHelper::ENCODING_UTF8,
|
||||
$shouldPreserveEmptyRows = false)
|
||||
{
|
||||
$allRows = [];
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
@@ -364,7 +453,9 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$reader
|
||||
->setFieldDelimiter($fieldDelimiter)
|
||||
->setFieldEnclosure($fieldEnclosure)
|
||||
->setEndOfLineCharacter($endOfLineCharacter)
|
||||
->setEncoding($encoding)
|
||||
->setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
|
||||
->open($resourcePath);
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||
@@ -377,4 +468,51 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
return $allRows;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadCustomStreamWrapper()
|
||||
{
|
||||
$allRows = [];
|
||||
$resourcePath = 'spout://csv_standard';
|
||||
|
||||
// register stream wrapper
|
||||
stream_wrapper_register('spout', SpoutTestStream::CLASS_NAME);
|
||||
|
||||
/** @var \Box\Spout\Reader\CSV\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->open($resourcePath);
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
$allRows[] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
$expectedRows = [
|
||||
['csv--11', 'csv--12', 'csv--13'],
|
||||
['csv--21', 'csv--22', 'csv--23'],
|
||||
['csv--31', 'csv--32', 'csv--33'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
|
||||
// cleanup
|
||||
stream_wrapper_unregister('spout');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||
{
|
||||
/** @var \Box\Spout\Reader\CSV\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->open('unsupported://foobar');
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
/**
|
||||
* Class SpoutTestStream
|
||||
* Custom stream that reads CSV files located in the tests/resources/csv folder.
|
||||
* For example: spout://foobar will point to tests/resources/csv/foobar.csv
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class SpoutTestStream
|
||||
{
|
||||
const CLASS_NAME = __CLASS__;
|
||||
|
||||
const PATH_TO_CSV_RESOURCES = 'tests/resources/csv/';
|
||||
const CSV_EXTENSION = '.csv';
|
||||
|
||||
/** @var int $position */
|
||||
private $position;
|
||||
|
||||
/** @var resource $fileHandle */
|
||||
private $fileHandle;
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @param int $flag
|
||||
* @return array
|
||||
*/
|
||||
public function url_stat($path, $flag)
|
||||
{
|
||||
$filePath = $this->getFilePathFromStreamPath($path);
|
||||
return stat($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $streamPath
|
||||
* @return string
|
||||
*/
|
||||
private function getFilePathFromStreamPath($streamPath)
|
||||
{
|
||||
$fileName = parse_url($streamPath, PHP_URL_HOST);
|
||||
return self::PATH_TO_CSV_RESOURCES . $fileName . self::CSV_EXTENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @param string $mode
|
||||
* @param int $options
|
||||
* @param string $opened_path
|
||||
* @return bool
|
||||
*/
|
||||
public function stream_open($path, $mode, $options, &$opened_path)
|
||||
{
|
||||
$this->position = 0;
|
||||
|
||||
// the path is like "spout://csv_name" so the actual file name correspond the name of the host.
|
||||
$filePath = $this->getFilePathFromStreamPath($path);
|
||||
$this->fileHandle = fopen($filePath, $mode);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $numBytes
|
||||
* @return string
|
||||
*/
|
||||
public function stream_read($numBytes)
|
||||
{
|
||||
$this->position += $numBytes;
|
||||
return fread($this->fileHandle, $numBytes);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return int
|
||||
*/
|
||||
public function stream_tell()
|
||||
{
|
||||
return $this->position;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param int $offset
|
||||
* @param int|void $whence
|
||||
* @return bool
|
||||
*/
|
||||
public function stream_seek($offset, $whence = SEEK_SET)
|
||||
{
|
||||
$result = fseek($this->fileHandle, $offset, $whence);
|
||||
if ($result === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ($whence === SEEK_SET) {
|
||||
$this->position = $offset;
|
||||
} else if ($whence === SEEK_CUR) {
|
||||
$this->position += $offset;
|
||||
} else {
|
||||
// not implemented
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function stream_close()
|
||||
{
|
||||
return fclose($this->fileHandle);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool
|
||||
*/
|
||||
public function stream_eof()
|
||||
{
|
||||
return feof($this->fileHandle);
|
||||
}
|
||||
}
|
||||
@@ -78,6 +78,19 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals([['foo']], $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportNumberRowsRepeated()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_number_rows_repeated.ods');
|
||||
$expectedRows = [
|
||||
['foo', 10.43],
|
||||
['foo', 10.43],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -164,6 +177,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportFormatDatesAndTimesIfSpecified()
|
||||
{
|
||||
$shouldFormatDates = true;
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $shouldFormatDates);
|
||||
|
||||
$expectedRows = [
|
||||
['05/19/2016', '5/19/16', '05/19/2016 16:39:00', '05/19/16 04:39 PM', '5/19/2016'],
|
||||
['11:29', '13:23:45', '01:23:45', '01:23:45 AM', '01:23:45 PM'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -196,15 +224,39 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSkipEmptyRow()
|
||||
public function testReadShouldSkipEmptyRowsIfShouldPreserveEmptyRowsNotSet()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_row.ods');
|
||||
$this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped');
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods');
|
||||
|
||||
$this->assertEquals(3, count($allRows), 'There should be only 3 rows, because the empty rows are skipped');
|
||||
|
||||
$expectedRows = [
|
||||
['ods--11', 'ods--12', 'ods--13'],
|
||||
// row skipped here
|
||||
// skipped row here
|
||||
['ods--21', 'ods--22', 'ods--23'],
|
||||
// skipped row here
|
||||
// skipped row here
|
||||
['ods--51', 'ods--52', 'ods--53'],
|
||||
['ods--61', 'ods--62', 'ods--63'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods', false, true);
|
||||
|
||||
$this->assertEquals(6, count($allRows), 'There should be 6 rows');
|
||||
|
||||
$expectedRows = [
|
||||
[''],
|
||||
['ods--21', 'ods--22', 'ods--23'],
|
||||
[''],
|
||||
[''],
|
||||
['ods--51', 'ods--52', 'ods--53'],
|
||||
['ods--61', 'ods--62', 'ods--63'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
@@ -363,16 +415,125 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||
{
|
||||
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::ODS);
|
||||
$reader->open('unsupported://foobar');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadWithSupportedCustomStreamWrapper()
|
||||
{
|
||||
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::ODS);
|
||||
$reader->open('php://memory');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldInludeRowsWithZerosOnly()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.ods');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['1', '2', '3'],
|
||||
['0', '0', '0']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.ods');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['0', '', ''],
|
||||
['1', '1', '']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/195
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldNotTrimCellValues()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.ods');
|
||||
|
||||
$expectedRows = [
|
||||
['A'],
|
||||
[' A '],
|
||||
["\n\tA\n\t"],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/218
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldReadTextInHyperlinks()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_hyperlinks.ods');
|
||||
|
||||
$expectedRows = [
|
||||
['email', 'text'],
|
||||
['1@example.com', 'text'],
|
||||
['2@example.com', 'text and https://github.com/box/spout/issues/218 and text'],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedRows, $allRows, 'Text in hyperlinks should be read');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldReadInlineFontFormattingAsText()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_inline_font_formatting.ods');
|
||||
|
||||
$expectedRows = [
|
||||
['I am a yellow bird']
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedRows, $allRows, 'Text formatted inline should be read');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @param bool|void $shouldFormatDates
|
||||
* @param bool|void $shouldPreserveEmptyRows
|
||||
* @return array All the read rows the given file
|
||||
*/
|
||||
private function getAllRowsForFile($fileName)
|
||||
private function getAllRowsForFile($fileName, $shouldFormatDates = false, $shouldPreserveEmptyRows = false)
|
||||
{
|
||||
$allRows = [];
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
|
||||
/** @var \Box\Spout\Reader\ODS\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::ODS);
|
||||
$reader->setShouldFormatDates($shouldFormatDates);
|
||||
$reader->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
|
||||
$reader->open($resourcePath);
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS;
|
||||
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\TestUsingResource;
|
||||
|
||||
/**
|
||||
* Class SheetTest
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS
|
||||
*/
|
||||
class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
use TestUsingResource;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testNextSheetShouldReturnCorrectSheetInfos()
|
||||
{
|
||||
$sheets = $this->openFileAndReturnSheets('two_sheets_with_custom_names.ods');
|
||||
|
||||
$this->assertEquals('Sheet First', $sheets[0]->getName());
|
||||
$this->assertEquals(0, $sheets[0]->getIndex());
|
||||
|
||||
$this->assertEquals('Sheet Last', $sheets[1]->getName());
|
||||
$this->assertEquals(1, $sheets[1]->getIndex());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @return Sheet[]
|
||||
*/
|
||||
private function openFileAndReturnSheets($fileName)
|
||||
{
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
$reader = ReaderFactory::create(Type::ODS);
|
||||
$reader->open($resourcePath);
|
||||
|
||||
$sheets = [];
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
$sheets[] = $sheet;
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
|
||||
return $sheets;
|
||||
}
|
||||
}
|
||||
@@ -20,12 +20,11 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
||||
public function testOpenShouldFailIfFileInsideZipDoesNotExist()
|
||||
{
|
||||
$resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx');
|
||||
$nonExistingXMLFilePath = 'zip://' . $resourcePath . '#path/to/fake/file.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
|
||||
// using "@" to prevent errors/warning to be displayed
|
||||
$wasOpenSuccessful = @$xmlReader->open($nonExistingXMLFilePath);
|
||||
$wasOpenSuccessful = @$xmlReader->openFileInZip($resourcePath, 'path/to/fake/file.xml');
|
||||
|
||||
$this->assertTrue($wasOpenSuccessful === false);
|
||||
}
|
||||
@@ -72,10 +71,9 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
||||
public function testReadShouldThrowExceptionOnError()
|
||||
{
|
||||
$resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx');
|
||||
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/worksheets/sheet1.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->open($sheetDataXMLFilePath) === false) {
|
||||
if ($xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml') === false) {
|
||||
$this->fail();
|
||||
}
|
||||
|
||||
@@ -95,43 +93,13 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
||||
// The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains
|
||||
// a doctype element that causes read errors
|
||||
$resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx');
|
||||
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/sharedStrings.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->open($sheetDataXMLFilePath) !== false) {
|
||||
if ($xmlReader->openFileInZip($resourcePath, 'xl/sharedStrings.xml') !== false) {
|
||||
@$xmlReader->next('sst');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestIsZipStream()
|
||||
{
|
||||
return [
|
||||
['/absolute/path/to/file.xlsx', false],
|
||||
['relative/path/to/file.xlsx', false],
|
||||
['php://temp', false],
|
||||
['zip:///absolute/path/to/file.xlsx', true],
|
||||
['zip://relative/path/to/file.xlsx', true],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestIsZipStream
|
||||
*
|
||||
* @param string $URI
|
||||
* @param bool $expectedResult
|
||||
* @return void
|
||||
*/
|
||||
public function testIsZipStream($URI, $expectedResult)
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
$isZipStream = \ReflectionHelper::callMethodOnObject($xmlReader, 'isZipStream', $URI);
|
||||
|
||||
$this->assertEquals($expectedResult, $isZipStream);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
@@ -167,35 +135,75 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestConvertURIToUseRealPath()
|
||||
public function dataProviderForTestGetRealPathURIForFileInZip()
|
||||
{
|
||||
$tempFolder = realpath(sys_get_temp_dir());
|
||||
$tempFolderName = basename($tempFolder);
|
||||
$expectedRealPathURI = 'zip://' . $tempFolder . '/test.xlsx#test.xml';
|
||||
|
||||
return [
|
||||
['/../../../' . $tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'],
|
||||
[$tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'],
|
||||
['zip://' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
|
||||
['zip:///../../../' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
|
||||
[$tempFolder, "$tempFolder/test.xlsx", 'test.xml', $expectedRealPathURI],
|
||||
[$tempFolder, "$tempFolder/../$tempFolderName/test.xlsx", 'test.xml', $expectedRealPathURI],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestConvertURIToUseRealPath
|
||||
* @dataProvider dataProviderForTestGetRealPathURIForFileInZip
|
||||
*
|
||||
* @param string $URI
|
||||
* @param string $expectedConvertedURI
|
||||
* @param string $tempFolder
|
||||
* @param string $zipFilePath
|
||||
* @param string $fileInsideZipPath
|
||||
* @param string $expectedRealPathURI
|
||||
* @return void
|
||||
*/
|
||||
public function testConvertURIToUseRealPath($URI, $expectedConvertedURI)
|
||||
public function testGetRealPathURIForFileInZip($tempFolder, $zipFilePath, $fileInsideZipPath, $expectedRealPathURI)
|
||||
{
|
||||
$tempFolder = sys_get_temp_dir();
|
||||
touch($tempFolder . '/test.xlsx');
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
$convertedURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'convertURIToUseRealPath', $URI);
|
||||
$realPathURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'getRealPathURIForFileInZip', $zipFilePath, $fileInsideZipPath);
|
||||
|
||||
$this->assertEquals($expectedConvertedURI, $convertedURI);
|
||||
// Normalizing path separators for Windows support
|
||||
$normalizedRealPathURI = str_replace('\\', '/', $realPathURI);
|
||||
$normalizedExpectedRealPathURI = str_replace('\\', '/', $expectedRealPathURI);
|
||||
|
||||
$this->assertEquals($normalizedExpectedRealPathURI, $normalizedRealPathURI);
|
||||
|
||||
unlink($tempFolder . '/test.xlsx');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestIsPositionedOnStartingAndEndingNode()
|
||||
{
|
||||
return [
|
||||
['<test></test>'], // not prefixed
|
||||
['<x:test xmlns:x="foo"></x:test>'], // prefixed
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestIsPositionedOnStartingAndEndingNode
|
||||
*
|
||||
* @param string $testXML
|
||||
* @return void
|
||||
*/
|
||||
public function testIsPositionedOnStartingAndEndingNode($testXML)
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->XML($testXML);
|
||||
|
||||
// the first read moves the pointer to "<test>"
|
||||
$xmlReader->read();
|
||||
$this->assertTrue($xmlReader->isPositionedOnStartingNode('test'));
|
||||
$this->assertFalse($xmlReader->isPositionedOnEndingNode('test'));
|
||||
|
||||
// the seconds read moves the pointer to "</test>"
|
||||
$xmlReader->read();
|
||||
$this->assertFalse($xmlReader->isPositionedOnStartingNode('test'));
|
||||
$this->assertTrue($xmlReader->isPositionedOnEndingNode('test'));
|
||||
|
||||
$xmlReader->close();
|
||||
}
|
||||
}
|
||||
@@ -10,14 +10,25 @@ namespace Box\Spout\Reader\XLSX\Helper;
|
||||
class CellHelperTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @return void
|
||||
* @return array
|
||||
*/
|
||||
public function testFillMissingArrayIndexes()
|
||||
public function dataProviderForTestFillMissingArrayIndexes()
|
||||
{
|
||||
$arrayToFill = [1 => 1, 3 => 3];
|
||||
$filledArray = CellHelper::fillMissingArrayIndexes($arrayToFill, 'FILL');
|
||||
return [
|
||||
[ null, [] ],
|
||||
[ [], [] ],
|
||||
[ [1 => 1, 3 => 3], ['FILL', 1, 'FILL', 3] ]
|
||||
];
|
||||
}
|
||||
|
||||
$expectedFilledArray = ['FILL', 1, 'FILL', 3];
|
||||
/**
|
||||
* @dataProvider dataProviderForTestFillMissingArrayIndexes
|
||||
* @param array $arrayToFill
|
||||
* @param array $expectedFilledArray
|
||||
*/
|
||||
public function testFillMissingArrayIndexes($arrayToFill, array $expectedFilledArray)
|
||||
{
|
||||
$filledArray = CellHelper::fillMissingArrayIndexes($arrayToFill, 'FILL');
|
||||
$this->assertEquals($expectedFilledArray, $filledArray);
|
||||
}
|
||||
|
||||
|
||||
@@ -18,8 +18,11 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 42429, '2016-02-29 00:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, -700, null],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0, null],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, null],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0, '1900-01-01 00:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.25, '1900-01-01 06:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, '1900-01-01 12:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.75, '1900-01-01 18:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.99999, '1900-01-01 23:59:59'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 1, '1900-01-01 00:00:00'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59'],
|
||||
[CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00'],
|
||||
@@ -68,7 +71,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||
->with(123)
|
||||
->will($this->returnValue(true));
|
||||
|
||||
$formatter = new CellValueFormatter(null, $styleHelperMock);
|
||||
$formatter = new CellValueFormatter(null, $styleHelperMock, false);
|
||||
$result = $formatter->extractAndFormatNodeValue($nodeMock);
|
||||
|
||||
if ($expectedDateAsString === null) {
|
||||
@@ -84,6 +87,10 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function dataProviderForTestFormatNumericCellValueWithNumbers()
|
||||
{
|
||||
// Some test values exceed PHP_INT_MAX on 32-bit PHP. They are
|
||||
// therefore converted to as doubles automatically by PHP.
|
||||
$expectedBigNumberType = (PHP_INT_SIZE < 8 ? 'double' : 'integer');
|
||||
|
||||
return [
|
||||
[42, 42, 'integer'],
|
||||
[42.5, 42.5, 'double'],
|
||||
@@ -91,8 +98,8 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||
[-42.5, -42.5, 'double'],
|
||||
['42', 42, 'integer'],
|
||||
['42.5', 42.5, 'double'],
|
||||
[865640023012945, 865640023012945, 'integer'],
|
||||
['865640023012945', 865640023012945, 'integer'],
|
||||
[865640023012945, 865640023012945, $expectedBigNumberType],
|
||||
['865640023012945', 865640023012945, $expectedBigNumberType],
|
||||
[865640023012945.5, 865640023012945.5, 'double'],
|
||||
['865640023012945.5', 865640023012945.5, 'double'],
|
||||
[PHP_INT_MAX, PHP_INT_MAX, 'integer'],
|
||||
@@ -117,10 +124,52 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
|
||||
->method('shouldFormatNumericValueAsDate')
|
||||
->will($this->returnValue(false));
|
||||
|
||||
$formatter = new CellValueFormatter(null, $styleHelperMock);
|
||||
$formatter = new CellValueFormatter(null, $styleHelperMock, false);
|
||||
$formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0);
|
||||
|
||||
$this->assertEquals($expectedFormattedValue, $formattedValue);
|
||||
$this->assertEquals($expectedType, gettype($formattedValue));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestFormatStringCellValue()
|
||||
{
|
||||
return [
|
||||
['A', 'A'],
|
||||
[' A ', ' A '],
|
||||
["\n\tA\n\t", "\n\tA\n\t"],
|
||||
[' ', ' '],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestFormatStringCellValue
|
||||
*
|
||||
* @param string $value
|
||||
* @param string $expectedFormattedValue
|
||||
* @return void
|
||||
*/
|
||||
public function testFormatInlineStringCellValue($value, $expectedFormattedValue)
|
||||
{
|
||||
$nodeListMock = $this->getMockBuilder('DOMNodeList')->disableOriginalConstructor()->getMock();
|
||||
$nodeListMock
|
||||
->expects($this->atLeastOnce())
|
||||
->method('item')
|
||||
->with(0)
|
||||
->will($this->returnValue((object)['nodeValue' => $value]));
|
||||
|
||||
$nodeMock = $this->getMockBuilder('DOMElement')->disableOriginalConstructor()->getMock();
|
||||
$nodeMock
|
||||
->expects($this->atLeastOnce())
|
||||
->method('getElementsByTagName')
|
||||
->with(CellValueFormatter::XML_NODE_INLINE_STRING_VALUE)
|
||||
->will($this->returnValue($nodeListMock));
|
||||
|
||||
$formatter = new CellValueFormatter(null, null, false);
|
||||
$formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatInlineStringCellValue', $nodeMock);
|
||||
|
||||
$this->assertEquals($expectedFormattedValue, $formattedValue);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
/**
|
||||
* Class DateFormatHelperTest
|
||||
*
|
||||
* @package Box\Spout\Reader\XLSX\Helper
|
||||
*/
|
||||
class DateFormatHelperTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestToPHPDateFormat()
|
||||
{
|
||||
return [
|
||||
// Excel date format, expected PHP date format
|
||||
['m/d/yy hh:mm', 'n/j/y H:i'],
|
||||
['mmm-yy', 'M-y'],
|
||||
['d-mmm-yy', 'j-M-y'],
|
||||
['m/dd/yyyy', 'n/d/Y'],
|
||||
['e mmmmm dddd', 'Y M l'],
|
||||
['MMMM DDD', 'F D'],
|
||||
['hh:mm:ss.s', 'H:i:s'],
|
||||
['h:mm:ss AM/PM', 'g:i:s A'],
|
||||
['hh:mm AM/PM', 'h:i A'],
|
||||
['[$-409]hh:mm AM/PM', 'h:i A'],
|
||||
['[$USD-F480]hh:mm AM/PM', 'h:i A'],
|
||||
['"Day " d', '\\D\\a\\y\\ j'],
|
||||
['yy "Year" m "Month"', 'y \\Y\\e\\a\\r n \\M\\o\\n\\t\\h'],
|
||||
['mmm-yy;@', 'M-y'],
|
||||
['[$-409]hh:mm AM/PM;"foo"@', 'h:i A'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestToPHPDateFormat
|
||||
*
|
||||
* @param string $excelDateFormat
|
||||
* @param string $expectedPHPDateFormat
|
||||
* @return void
|
||||
*/
|
||||
public function testToPHPDateFormat($excelDateFormat, $expectedPHPDateFormat)
|
||||
{
|
||||
$phpDateFormat = DateFormatHelper::toPHPDateFormat($excelDateFormat);
|
||||
$this->assertEquals($expectedPHPDateFormat, $phpDateFormat);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
|
||||
public function dataProviderForTestGetBestCachingStrategy()
|
||||
{
|
||||
return [
|
||||
[null, -1, 'FileBasedStrategy'],
|
||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE, -1, 'FileBasedStrategy'],
|
||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE + 10, -1, 'FileBasedStrategy'],
|
||||
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE - 10, -1, 'InMemoryStrategy'],
|
||||
@@ -27,7 +28,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @dataProvider dataProviderForTestGetBestCachingStrategy
|
||||
*
|
||||
* @param int $sharedStringsUniqueCount
|
||||
* @param int|null $sharedStringsUniqueCount
|
||||
* @param int $memoryLimitInKB
|
||||
* @param string $expectedStrategyClassName
|
||||
* @return void
|
||||
|
||||
@@ -82,6 +82,22 @@ class SharedStringsHelperTest extends \PHPUnit_Framework_TestCase
|
||||
$sharedStringsHelper->cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testGetStringAtIndexShouldWorkWithStringsContainingTextAndHyperlinkInSameCell()
|
||||
{
|
||||
$resourcePath = $this->getResourcePath('one_sheet_with_shared_strings_containing_text_and_hyperlink_in_same_cell.xlsx');
|
||||
$sharedStringsHelper = new SharedStringsHelper($resourcePath);
|
||||
|
||||
$sharedStringsHelper->extractSharedStrings();
|
||||
|
||||
$sharedString = $sharedStringsHelper->getStringAtIndex(0);
|
||||
$this->assertEquals('go to https://github.com please', $sharedString);
|
||||
|
||||
$sharedStringsHelper->cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -11,16 +11,16 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
|
||||
/**
|
||||
* @param array $styleAttributes
|
||||
* @param array|void $styleAttributes
|
||||
* @param array|void $customNumberFormats
|
||||
* @return StyleHelper
|
||||
*/
|
||||
private function getStyleHelperMock($styleAttributes, $customNumberFormats = [])
|
||||
private function getStyleHelperMock($styleAttributes = [], $customNumberFormats = [])
|
||||
{
|
||||
/** @var StyleHelper $styleHelper */
|
||||
$styleHelper = $this->getMockBuilder('\Box\Spout\Reader\XLSX\Helper\StyleHelper')
|
||||
->setConstructorArgs(['/path/to/file.xlsx'])
|
||||
->setMethods(['getCustomNumberFormats', 'getStylesAttributes'])
|
||||
->disableOriginalConstructor()
|
||||
->getMock();
|
||||
|
||||
$styleHelper->method('getStylesAttributes')->willReturn($styleAttributes);
|
||||
@@ -34,27 +34,47 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWithDefaultStyle()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([]);
|
||||
$styleHelper = $this->getStyleHelperMock();
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(0);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWhenStyleIdNotListed()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([['applyNumberFormat' => true]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWhenShouldNotApplyNumberFormat()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => false]]);
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => false, 'numFmtId' => 14]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWithGeneralFormat()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 0]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWithNonDateBuiltinFormat()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 9]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWithNoNumFmtId()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => null]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
@@ -74,6 +94,28 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWhenApplyNumberFormatNotSetAndUsingBuiltinDateFormat()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => null, 'numFmtId' => 14]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
|
||||
$this->assertTrue($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldFormatNumericValueAsDateWhenApplyNumberFormatNotSetAndUsingBuiltinNonDateFormat()
|
||||
{
|
||||
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => null, 'numFmtId' => 9]]);
|
||||
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
|
||||
|
||||
$this->assertFalse($shouldFormatAsDate);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -96,6 +138,7 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
['[$-409]d\-mmm\-yy;@', true],
|
||||
['[$-409]d\-mmm\-yyyy;@', true],
|
||||
['mm/dd/yy;@', true],
|
||||
['MM/DD/YY;@', true],
|
||||
['[$-F800]dddd\,\ mmmm\ dd\,\ yyyy', true],
|
||||
['m/d;@', true],
|
||||
['m/d/yy;@', true],
|
||||
@@ -107,9 +150,11 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
['[$-409]m/d/yy\ h:mm\ AM/PM;@', true],
|
||||
['m/d/yy\ h:mm;@', true],
|
||||
['[$-409]mmmmm;@', true],
|
||||
['[$-409]MMmmM;@', true],
|
||||
['[$-409]mmmmm\-yy;@', true],
|
||||
['m/d/yyyy;@', true],
|
||||
['[$-409]m/d/yy\--h:mm;@', true],
|
||||
['General', false],
|
||||
['GENERAL', false],
|
||||
['\ma\yb\e', false],
|
||||
['[Red]foo;', false],
|
||||
|
||||
@@ -23,7 +23,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
return [
|
||||
['/path/to/fake/file.xlsx'],
|
||||
['file_with_no_sheets_in_content_types.xlsx'],
|
||||
['file_with_no_sheets_in_workbook_xml.xlsx'],
|
||||
['file_with_sheet_xml_not_matching_content_types.xlsx'],
|
||||
['file_corrupted.xlsx'],
|
||||
];
|
||||
@@ -95,6 +95,51 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportPrefixedXMLFiles()
|
||||
{
|
||||
// The XML files of this spreadsheet are prefixed.
|
||||
// For instance, they use "<x:sheet>" instead of "<sheet>", etc.
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_prefixed_xml_files.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['s1 - A1', 's1 - B1', 's1 - C1'],
|
||||
['s1 - A2', 's1 - B2', 's1 - C2'],
|
||||
['s1 - A3', 's1 - B3', 's1 - C3'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAttribute()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['s1--A1', 's1--B1'],
|
||||
['s1--A2', 's1--B2'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAndCountAttributes()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count_and_count.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['s1--A1', 's1--B1'],
|
||||
['s1--A2', 's1--B2'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -109,6 +154,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportFilesWithoutCellReference()
|
||||
{
|
||||
// file where the cell definition does not have a "r" attribute
|
||||
// as in <c r="A1">...</c>
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_missing_cell_reference.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['s1--A1'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -181,6 +241,59 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportDifferentTimesAsNumericTimestamp()
|
||||
{
|
||||
// make sure dates are always created with the same timezone
|
||||
date_default_timezone_set('UTC');
|
||||
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_different_numeric_value_times.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
[
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 00:00:00'),
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 11:29:00'),
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 23:29:00'),
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 01:42:25'),
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '1900-01-01 13:42:25'),
|
||||
]
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportFormatDatesAndTimesIfSpecified()
|
||||
{
|
||||
$shouldFormatDates = true;
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.xlsx', $shouldFormatDates);
|
||||
|
||||
$expectedRows = [
|
||||
['1/13/2016', '01/13/2016', '13-Jan-16', 'Wednesday January 13, 16', 'Today is 1/13/2016'],
|
||||
['4:43:25', '04:43', '4:43', '4:43:25 AM', '4:43:25 PM'],
|
||||
['1976-11-22T08:30:00.000', '1976-11-22T08:30', '1582-10-15', '08:30:00', '08:30'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldApplyCustomDateFormatNumberEvenIfApplyNumberFormatNotSpecified()
|
||||
{
|
||||
$shouldFormatDates = true;
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_custom_date_formats_and_no_apply_number_format.xlsx', $shouldFormatDates);
|
||||
|
||||
$expectedRows = [
|
||||
// "General", "GENERAL", "MM/DD/YYYY", "MM/dd/YYYY", "H:MM:SS"
|
||||
['42382', '42382', '01/13/2016', '01/13/2016', '4:43:25'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -239,16 +352,39 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSkipEmptyRows()
|
||||
public function testReadShouldSkipEmptyRowsIfShouldPreserveEmptyRowsNotSet()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_row.xlsx');
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows_and_missing_row_index.xlsx');
|
||||
|
||||
$this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped');
|
||||
$this->assertEquals(3, count($allRows), 'There should be only 3 rows, because the empty rows are skipped');
|
||||
|
||||
$expectedRows = [
|
||||
['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'],
|
||||
// skipped row here
|
||||
['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'],
|
||||
['s1--A2', 's1--B2', 's1--C2'],
|
||||
// skipped row here
|
||||
// skipped row here
|
||||
['s1--A5', 's1--B5', 's1--C5'],
|
||||
['s1--A6', 's1--B6', 's1--C6'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows_and_missing_row_index.xlsx', false, true);
|
||||
|
||||
$this->assertEquals(6, count($allRows), 'There should be 6 rows');
|
||||
|
||||
$expectedRows = [
|
||||
[''],
|
||||
['s1--A2', 's1--B2', 's1--C2'],
|
||||
[''],
|
||||
[''],
|
||||
['s1--A5', 's1--B5', 's1--C5'],
|
||||
['s1--A6', 's1--B6', 's1--C6'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
@@ -404,16 +540,96 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadWithUnsupportedCustomStreamWrapper()
|
||||
{
|
||||
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->open('unsupported://foobar');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\IOException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadWithSupportedCustomStreamWrapper()
|
||||
{
|
||||
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->open('php://memory');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldInludeRowsWithZerosOnly()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['1', '2', '3'],
|
||||
['0', '0', '0']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
|
||||
}
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/184
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldCreateOutputEmptyCellPreserved()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['A', 'B', 'C'],
|
||||
['0', '', ''],
|
||||
['1', '1', '']
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* https://github.com/box/spout/issues/195
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldNotTrimCellValues()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_inline_strings.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['A'],
|
||||
[' A '],
|
||||
["\n\tA\n\t"],
|
||||
];
|
||||
|
||||
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @param bool|void $shouldFormatDates
|
||||
* @param bool|void $shouldPreserveEmptyRows
|
||||
* @return array All the read rows the given file
|
||||
*/
|
||||
private function getAllRowsForFile($fileName)
|
||||
private function getAllRowsForFile($fileName, $shouldFormatDates = false, $shouldPreserveEmptyRows = false)
|
||||
{
|
||||
$allRows = [];
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
|
||||
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->setShouldFormatDates($shouldFormatDates);
|
||||
$reader->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
|
||||
$reader->open($resourcePath);
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
|
||||
|
||||
@@ -7,8 +7,8 @@ namespace Box\Spout;
|
||||
*
|
||||
* @package Box\Spout
|
||||
*/
|
||||
trait TestUsingResource {
|
||||
|
||||
trait TestUsingResource
|
||||
{
|
||||
/** @var string Path to the test resources folder */
|
||||
private $resourcesPath = 'tests/resources';
|
||||
|
||||
@@ -18,6 +18,9 @@ trait TestUsingResource {
|
||||
/** @var string Path to the test resources folder, that does not have writing permissions */
|
||||
private $generatedUnwritableResourcesPath = 'tests/resources/generated/unwritable';
|
||||
|
||||
/** @var string Path to the test temp folder */
|
||||
private $tempFolderPath = 'tests/resources/generated/temp';
|
||||
|
||||
/**
|
||||
* @param string $resourceName
|
||||
* @return string|null Path of the resource who matches the given name or null if resource not found
|
||||
@@ -70,6 +73,11 @@ trait TestUsingResource {
|
||||
*/
|
||||
protected function createUnwritableFolderIfNeeded()
|
||||
{
|
||||
// On Windows, chmod() or the mkdir's mode is ignored
|
||||
if ($this->isWindows()) {
|
||||
$this->markTestSkipped('Skipping because Windows cannot create read-only folders through PHP');
|
||||
}
|
||||
|
||||
if (!file_exists($this->generatedUnwritableResourcesPath)) {
|
||||
// Make sure generated folder exists first
|
||||
if (!file_exists($this->generatedResourcesPath)) {
|
||||
@@ -80,4 +88,54 @@ trait TestUsingResource {
|
||||
mkdir($this->generatedUnwritableResourcesPath, 0444, true);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Path of the temp folder
|
||||
*/
|
||||
protected function getTempFolderPath()
|
||||
{
|
||||
return realpath($this->tempFolderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
protected function recreateTempFolder()
|
||||
{
|
||||
if (file_exists($this->tempFolderPath)) {
|
||||
$this->deleteFolderRecursively($this->tempFolderPath);
|
||||
}
|
||||
|
||||
mkdir($this->tempFolderPath, 0777, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $folderPath
|
||||
* @return void
|
||||
*/
|
||||
private function deleteFolderRecursively($folderPath)
|
||||
{
|
||||
$itemIterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
foreach ($itemIterator as $item) {
|
||||
if ($item->isDir()) {
|
||||
rmdir($item->getPathname());
|
||||
} else {
|
||||
unlink($item->getPathname());
|
||||
}
|
||||
}
|
||||
|
||||
rmdir($folderPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return bool Whether the OS on which PHP is installed is Windows
|
||||
*/
|
||||
protected function isWindows()
|
||||
{
|
||||
return strtoupper(substr(PHP_OS, 0, 3)) === 'WIN';
|
||||
}
|
||||
}
|
||||
@@ -22,7 +22,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
public function testWriteShouldThrowExceptionIfCannotOpenFileForWriting()
|
||||
{
|
||||
$fileName = 'file_that_wont_be_written.csv';
|
||||
$this->createUnwritableFolderIfNeeded($fileName);
|
||||
$this->createUnwritableFolderIfNeeded();
|
||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
@@ -61,6 +61,23 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$writer->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testCloseShouldNoopWhenWriterIsNotOpened()
|
||||
{
|
||||
$fileName = 'test_double_close_calls.csv';
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -74,6 +91,33 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should contain a UTF-8 BOM');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testWriteShouldNotAddUtf8Bom()
|
||||
{
|
||||
$allRows = [
|
||||
['csv--11', 'csv--12'],
|
||||
];
|
||||
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_no_bom.csv', ',', '"', false);
|
||||
|
||||
$this->assertNotContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should not contain a UTF-8 BOM');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testWriteShouldSupportAssociativeArrays()
|
||||
{
|
||||
$allRows = [
|
||||
['foo' => 'csv--11', 'bar' => 'csv--12'],
|
||||
];
|
||||
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_from_associative_arrays.csv');
|
||||
$writtenContent = $this->trimWrittenContent($writtenContent);
|
||||
|
||||
$this->assertEquals('csv--11,csv--12', $writtenContent, 'Values from associative arrays should be written');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -138,9 +182,10 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
* @param string $fileName
|
||||
* @param string $fieldDelimiter
|
||||
* @param string $fieldEnclosure
|
||||
* @param bool $shouldAddBOM
|
||||
* @return null|string
|
||||
*/
|
||||
private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"')
|
||||
private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"', $shouldAddBOM = true)
|
||||
{
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
@@ -148,6 +193,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
$writer->setFieldDelimiter($fieldDelimiter);
|
||||
$writer->setFieldEnclosure($fieldEnclosure);
|
||||
$writer->setShouldAddBOM($shouldAddBOM);
|
||||
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->addRows($allRows);
|
||||
|
||||
@@ -35,6 +35,23 @@ class CellHelperTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedCellIndex, CellHelper::getCellIndexFromColumnIndex($columnIndex));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function testIsEmpty()
|
||||
{
|
||||
$this->assertTrue(CellHelper::isEmpty(null));
|
||||
$this->assertTrue(CellHelper::isEmpty(""));
|
||||
|
||||
$this->assertFalse(CellHelper::isEmpty("string"));
|
||||
$this->assertFalse(CellHelper::isEmpty(0));
|
||||
$this->assertFalse(CellHelper::isEmpty(1));
|
||||
$this->assertFalse(CellHelper::isEmpty(true));
|
||||
$this->assertFalse(CellHelper::isEmpty(false));
|
||||
$this->assertFalse(CellHelper::isEmpty(["string"]));
|
||||
$this->assertFalse(CellHelper::isEmpty(new \stdClass()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
|
||||
@@ -14,7 +14,7 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testGetSheetName()
|
||||
{
|
||||
$sheets = [new Sheet(0), new Sheet(1)];
|
||||
$sheets = [new Sheet(0, 'workbookId1'), new Sheet(1, 'workbookId1')];
|
||||
|
||||
$this->assertEquals('Sheet1', $sheets[0]->getName(), 'Invalid name for the first sheet');
|
||||
$this->assertEquals('Sheet2', $sheets[1]->getName(), 'Invalid name for the second sheet');
|
||||
@@ -26,7 +26,7 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
public function testSetSheetNameShouldCreateSheetWithCustomName()
|
||||
{
|
||||
$customSheetName = 'CustomName';
|
||||
$sheet = new Sheet(0);
|
||||
$sheet = new Sheet(0, 'workbookId1');
|
||||
$sheet->setName($customSheetName);
|
||||
|
||||
$this->assertEquals($customSheetName, $sheet->getName(), "The sheet name should have been changed to '$customSheetName'");
|
||||
@@ -63,7 +63,7 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testSetSheetNameShouldThrowOnInvalidName($customSheetName)
|
||||
{
|
||||
(new Sheet(0))->setName($customSheetName);
|
||||
(new Sheet(0, 'workbookId1'))->setName($customSheetName);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -72,7 +72,7 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
public function testSetSheetNameShouldNotThrowWhenSettingSameNameAsCurrentOne()
|
||||
{
|
||||
$customSheetName = 'Sheet name';
|
||||
$sheet = new Sheet(0);
|
||||
$sheet = new Sheet(0, 'workbookId1');
|
||||
$sheet->setName($customSheetName);
|
||||
$sheet->setName($customSheetName);
|
||||
}
|
||||
@@ -85,10 +85,27 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
$customSheetName = 'Sheet name';
|
||||
|
||||
$sheet = new Sheet(0);
|
||||
$sheet = new Sheet(0, 'workbookId1');
|
||||
$sheet->setName($customSheetName);
|
||||
|
||||
$sheet = new Sheet(1);
|
||||
$sheet = new Sheet(1, 'workbookId1');
|
||||
$sheet->setName($customSheetName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testSetSheetNameShouldNotThrowWhenSameNameUsedInDifferentWorkbooks()
|
||||
{
|
||||
$customSheetName = 'Sheet name';
|
||||
|
||||
$sheet = new Sheet(0, 'workbookId1');
|
||||
$sheet->setName($customSheetName);
|
||||
|
||||
$sheet = new Sheet(0, 'workbookId2');
|
||||
$sheet->setName($customSheetName);
|
||||
|
||||
$sheet = new Sheet(1, 'workbookId3');
|
||||
$sheet->setName($customSheetName);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
namespace Box\Spout\Writer\ODS;
|
||||
|
||||
use Box\Spout\Common\Exception\SpoutException;
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\TestUsingResource;
|
||||
@@ -23,7 +24,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
||||
{
|
||||
$fileName = 'file_that_wont_be_written.ods';
|
||||
$this->createUnwritableFolderIfNeeded($fileName);
|
||||
$this->createUnwritableFolderIfNeeded();
|
||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
@@ -66,7 +67,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @expectedException \Box\Spout\Writer\Exception\WriterAlreadyOpenedException
|
||||
*/
|
||||
public function testsetShouldCreateNewSheetsAutomaticallyShouldThrowExceptionIfCalledAfterOpeningWriter()
|
||||
public function testSetShouldCreateNewSheetsAutomaticallyShouldThrowExceptionIfCalledAfterOpeningWriter()
|
||||
{
|
||||
$fileName = 'file_that_wont_be_written.ods';
|
||||
$filePath = $this->getGeneratedResourcePath($fileName);
|
||||
@@ -91,6 +92,39 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$this->writeToODSFile($dataRows, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowShouldCleanupAllFilesIfExceptionIsThrown()
|
||||
{
|
||||
$fileName = 'test_add_row_should_cleanup_all_files_if_exception_thrown.ods';
|
||||
$dataRows = [
|
||||
['wrong'],
|
||||
[new \stdClass()],
|
||||
];
|
||||
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
$this->recreateTempFolder();
|
||||
$tempFolderPath = $this->getTempFolderPath();
|
||||
|
||||
/** @var \Box\Spout\Writer\ODS\Writer $writer */
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
$writer->setTempFolder($tempFolderPath);
|
||||
$writer->openToFile($resourcePath);
|
||||
|
||||
try {
|
||||
$writer->addRows($dataRows);
|
||||
$this->fail('Exception should have been thrown');
|
||||
} catch (SpoutException $e) {
|
||||
$this->assertFalse(file_exists($fileName), 'Output file should have been deleted');
|
||||
|
||||
$numFiles = iterator_count(new \FilesystemIterator($tempFolderPath, \FilesystemIterator::SKIP_DOTS));
|
||||
$this->assertEquals(0, $numFiles, 'All temp files should have been deleted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -134,6 +168,23 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($firstSheet, $writer->getCurrentSheet(), 'The current sheet should be the first one.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testCloseShouldNoopWhenWriterIsNotOpened()
|
||||
{
|
||||
$fileName = 'test_double_close_calls.ods';
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -177,6 +228,25 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowShouldSupportAssociativeArrays()
|
||||
{
|
||||
$fileName = 'test_add_row_should_support_associative_arrays.ods';
|
||||
$dataRows = [
|
||||
['foo' => 'ods--11', 'bar' => 'ods--12'],
|
||||
];
|
||||
|
||||
$this->writeToODSFile($dataRows, $fileName);
|
||||
|
||||
foreach ($dataRows as $dataRow) {
|
||||
foreach ($dataRow as $cellValue) {
|
||||
$this->assertValueWasWritten($fileName, $cellValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -519,10 +589,9 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
private function moveReaderToCorrectTableNode($fileName, $sheetIndex)
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToSheetFile = $resourcePath . '#content.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToSheetFile);
|
||||
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||
$xmlReader->readUntilNodeFound('table:table');
|
||||
|
||||
for ($i = 1; $i < $sheetIndex; $i++) {
|
||||
|
||||
@@ -5,6 +5,9 @@ namespace Box\Spout\Writer\ODS;
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\TestUsingResource;
|
||||
use Box\Spout\Writer\ODS\Helper\BorderHelper;
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\Style;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
@@ -115,6 +118,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
->setFontSize(15)
|
||||
->setFontColor(Color::RED)
|
||||
->setFontName('Cambria')
|
||||
->setBackgroundColor(Color::GREEN)
|
||||
->build();
|
||||
|
||||
$this->writeToODSFileWithMultipleStyles($dataRows, $fileName, [$style, $style2]);
|
||||
@@ -134,6 +138,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertFirstChildHasAttributeEquals('15pt', $customFont2Element, 'text-properties', 'fo:font-size');
|
||||
$this->assertFirstChildHasAttributeEquals('#' . Color::RED, $customFont2Element, 'text-properties', 'fo:color');
|
||||
$this->assertFirstChildHasAttributeEquals('Cambria', $customFont2Element, 'text-properties', 'style:font-name');
|
||||
$this->assertFirstChildHasAttributeEquals('#' . Color::GREEN, $customFont2Element, 'table-cell-properties', 'fo:background-color');
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -208,7 +213,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
];
|
||||
$style = (new StyleBuilder())->setShouldWrapText()->build();
|
||||
|
||||
$this->writeToODSFile($dataRows, $fileName,$style);
|
||||
$this->writeToODSFile($dataRows, $fileName, $style);
|
||||
|
||||
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
|
||||
$this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)');
|
||||
@@ -236,6 +241,108 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddBackgroundColor()
|
||||
{
|
||||
$fileName = 'test_default_background_style.ods';
|
||||
$dataRows = [
|
||||
['defaultBgColor'],
|
||||
];
|
||||
|
||||
$style = (new StyleBuilder())->setBackgroundColor(Color::WHITE)->build();
|
||||
$this->writeToODSFile($dataRows, $fileName, $style);
|
||||
|
||||
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
|
||||
$this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)');
|
||||
|
||||
$customStyleElement = $styleElements[1];
|
||||
$this->assertFirstChildHasAttributeEquals('#' . Color::WHITE, $customStyleElement, 'table-cell-properties', 'fo:background-color');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testBorders()
|
||||
{
|
||||
$fileName = 'test_borders.ods';
|
||||
|
||||
$dataRows = [
|
||||
['row-with-border-bottom-green-thick-solid'],
|
||||
['row-without-border'],
|
||||
['row-with-border-top-red-thin-dashed'],
|
||||
];
|
||||
|
||||
$borderBottomGreenThickSolid = (new BorderBuilder())
|
||||
->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build();
|
||||
|
||||
|
||||
$borderTopRedThinDashed = (new BorderBuilder())
|
||||
->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||
|
||||
$styles = [
|
||||
(new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(),
|
||||
(new StyleBuilder())->build(),
|
||||
(new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(),
|
||||
];
|
||||
|
||||
$this->writeToODSFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||
|
||||
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
|
||||
|
||||
$this->assertEquals(3, count($styleElements), 'There should be 3 styles)');
|
||||
|
||||
// Use reflection for protected members here
|
||||
$widthMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'widthMap');
|
||||
$styleMap = \ReflectionHelper::getStaticValue('Box\Spout\Writer\ODS\Helper\BorderHelper', 'styleMap');
|
||||
|
||||
$expectedFirst = sprintf(
|
||||
'%s %s #%s',
|
||||
$widthMap[Border::WIDTH_THICK],
|
||||
$styleMap[Border::STYLE_SOLID],
|
||||
Color::GREEN
|
||||
);
|
||||
|
||||
$actualFirst = $styleElements[1]
|
||||
->getElementsByTagName('table-cell-properties')
|
||||
->item(0)
|
||||
->getAttribute('fo:border-bottom');
|
||||
|
||||
$this->assertEquals($expectedFirst, $actualFirst);
|
||||
|
||||
$expectedThird = sprintf(
|
||||
'%s %s #%s',
|
||||
$widthMap[Border::WIDTH_THIN],
|
||||
$styleMap[Border::STYLE_DASHED],
|
||||
Color::RED
|
||||
);
|
||||
|
||||
$actualThird = $styleElements[2]
|
||||
->getElementsByTagName('table-cell-properties')
|
||||
->item(0)
|
||||
->getAttribute('fo:border-top');
|
||||
|
||||
$this->assertEquals($expectedThird, $actualThird);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testSetDefaultRowStyle()
|
||||
{
|
||||
$fileName = 'test_set_default_row_style.ods';
|
||||
$dataRows = [['ods--11']];
|
||||
|
||||
$defaultFontSize = 50;
|
||||
$defaultStyle = (new StyleBuilder())->setFontSize($defaultFontSize)->build();
|
||||
|
||||
$this->writeToODSFileWithDefaultStyle($dataRows, $fileName, $defaultStyle);
|
||||
|
||||
$textPropertiesElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'style:text-properties');
|
||||
$this->assertEquals($defaultFontSize . 'pt', $textPropertiesElement->getAttribute('fo:font-size'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
@@ -257,6 +364,28 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
return $writer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
* @param \Box\Spout\Writer\Style\Style|null $defaultStyle
|
||||
* @return Writer
|
||||
*/
|
||||
private function writeToODSFileWithDefaultStyle($allRows, $fileName, $defaultStyle)
|
||||
{
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
$writer->setDefaultRowStyle($defaultStyle);
|
||||
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->addRows($allRows);
|
||||
$writer->close();
|
||||
|
||||
return $writer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
@@ -296,17 +425,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$cellElements = [];
|
||||
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToStylesXmlFile = $resourcePath . '#content.xml';
|
||||
|
||||
$xmlReader = new \XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'table:table-cell' && $xmlReader->getAttribute('office:value-type') !== null) {
|
||||
if ($xmlReader->isPositionedOnStartingNode('table:table-cell') && $xmlReader->getAttribute('office:value-type') !== null) {
|
||||
$cellElements[] = $xmlReader->expand();
|
||||
}
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $cellElements;
|
||||
}
|
||||
|
||||
@@ -319,17 +449,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$cellStyleElements = [];
|
||||
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToStylesXmlFile = $resourcePath . '#content.xml';
|
||||
|
||||
$xmlReader = new \XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->openFileInZip($resourcePath, 'content.xml');
|
||||
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'style:style' && $xmlReader->getAttribute('style:family') === 'table-cell') {
|
||||
if ($xmlReader->isPositionedOnStartingNode('style:style') && $xmlReader->getAttribute('style:family') === 'table-cell') {
|
||||
$cellStyleElements[] = $xmlReader->expand();
|
||||
}
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $cellStyleElements;
|
||||
}
|
||||
|
||||
@@ -341,10 +472,9 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToStylesXmlFile = $resourcePath . '#styles.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
||||
$xmlReader->openFileInZip($resourcePath, 'styles.xml');
|
||||
$xmlReader->readUntilNodeFound($section);
|
||||
|
||||
return $xmlReader->expand();
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Writer\Style;
|
||||
|
||||
class BorderTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testValidInstance()
|
||||
{
|
||||
$noConstructorParams = new Border();
|
||||
$withConstructorParams = new Border([
|
||||
new BorderPart(Border::LEFT)
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidNameException
|
||||
*/
|
||||
public function testInvalidBorderPart()
|
||||
{
|
||||
$invalidBorderPart = new BorderPart('invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidStyleException
|
||||
*/
|
||||
public function testInvalidBorderPartStyle()
|
||||
{
|
||||
$invalidBorderPartStyle = new BorderPart(Border::LEFT, Color::BLACK, Border::WIDTH_THIN, 'invalid');
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Writer\Exception\Border\InvalidWidthException
|
||||
*/
|
||||
public function testInvalidBorderPartWidth()
|
||||
{
|
||||
$invalidBorderPartStyle = new BorderPart(Border::LEFT, Color::BLACK, 'invalid', Border::STYLE_DASHED);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testNotMoreThanFourPartsPossible()
|
||||
{
|
||||
$border = new Border();
|
||||
$border
|
||||
->addPart(new BorderPart(Border::LEFT))
|
||||
->addPart(new BorderPart(Border::RIGHT))
|
||||
->addPart(new BorderPart(Border::TOP))
|
||||
->addPart(new BorderPart(Border::BOTTOM))
|
||||
->addPart(new BorderPart(Border::LEFT));
|
||||
|
||||
$this->assertEquals(4, count($border->getParts()), 'There should never be more than 4 border parts');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testSetParts()
|
||||
{
|
||||
$border = new Border();
|
||||
$border->setParts([
|
||||
new BorderPart(Border::LEFT)
|
||||
]);
|
||||
|
||||
$this->assertEquals(1, count($border->getParts()), 'It should be possible to set the border parts');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testBorderBuilderFluent()
|
||||
{
|
||||
$border = (new BorderBuilder())
|
||||
->setBorderBottom()
|
||||
->setBorderTop()
|
||||
->setBorderLeft()
|
||||
->setBorderRight()
|
||||
->build();
|
||||
$this->assertEquals(4, count($border->getParts()), 'The border builder exposes a fluent interface');
|
||||
}
|
||||
|
||||
/**
|
||||
* :D :S
|
||||
* @return void
|
||||
*/
|
||||
public function testAnyCombinationOfAllowedBorderPartsParams()
|
||||
{
|
||||
$color = Color::BLACK;
|
||||
foreach (BorderPart::getAllowedNames() as $allowedName) {
|
||||
foreach (BorderPart::getAllowedStyles() as $allowedStyle) {
|
||||
foreach (BorderPart::getAllowedWidths() as $allowedWidth) {
|
||||
$borderPart = new BorderPart($allowedName, $color, $allowedWidth, $allowedStyle);
|
||||
$border = new Border();
|
||||
$border->addPart($borderPart);
|
||||
$this->assertEquals(1, count($border->getParts()));
|
||||
|
||||
/** @var $part BorderPart */
|
||||
$part = $border->getParts()[$allowedName];
|
||||
|
||||
$this->assertEquals($allowedStyle, $part->getStyle());
|
||||
$this->assertEquals($allowedWidth, $part->getWidth());
|
||||
$this->assertEquals($color, $part->getColor());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,4 +129,32 @@ class StyleTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testStyleBuilderShouldApplyBorders()
|
||||
{
|
||||
$border = (new BorderBuilder())
|
||||
->setBorderBottom()
|
||||
->build();
|
||||
$style = (new StyleBuilder())->setBorder($border)->build();
|
||||
$this->assertTrue($style->shouldApplyBorder());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testStyleBuilderShouldMergeBorders()
|
||||
{
|
||||
$border = (new BorderBuilder())->setBorderBottom(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||
|
||||
$baseStyle = (new StyleBuilder())->setBorder($border)->build();
|
||||
$currentStyle = (new StyleBuilder())->build();
|
||||
$mergedStyle = $currentStyle->mergeWith($baseStyle);
|
||||
|
||||
$this->assertEquals(null, $currentStyle->getBorder(), 'Current style has no border');
|
||||
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $baseStyle->getBorder(), 'Base style has a border');
|
||||
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $mergedStyle->getBorder(), 'Merged style has a border');
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,9 @@
|
||||
|
||||
namespace Box\Spout\Writer\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
|
||||
/**
|
||||
@@ -57,6 +60,27 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals(1, $registeredStyle2->getId());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testShouldApplyStyleOnEmptyCell()
|
||||
{
|
||||
$styleWithFont = (new StyleBuilder())->setFontBold()->build();
|
||||
$styleWithBackground = (new StyleBuilder())->setBackgroundColor(Color::BLUE)->build();
|
||||
|
||||
$border = (new BorderBuilder())->setBorderBottom(Color::GREEN)->build();
|
||||
$styleWithBorder = (new StyleBuilder())->setBorder($border)->build();
|
||||
|
||||
$styleHelper = new StyleHelper($this->defaultStyle);
|
||||
$styleHelper->registerStyle($styleWithFont);
|
||||
$styleHelper->registerStyle($styleWithBackground);
|
||||
$styleHelper->registerStyle($styleWithBorder);
|
||||
|
||||
$this->assertFalse($styleHelper->shouldApplyStyleOnEmptyCell($styleWithFont->getId()));
|
||||
$this->assertTrue($styleHelper->shouldApplyStyleOnEmptyCell($styleWithBackground->getId()));
|
||||
$this->assertTrue($styleHelper->shouldApplyStyleOnEmptyCell($styleWithBorder->getId()));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
namespace Box\Spout\Writer\XLSX;
|
||||
|
||||
use Box\Spout\Common\Exception\SpoutException;
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\TestUsingResource;
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Writer\XLSX\Internal\Worksheet;
|
||||
|
||||
/**
|
||||
* Class WriterTest
|
||||
@@ -21,7 +23,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
|
||||
{
|
||||
$fileName = 'file_that_wont_be_written.xlsx';
|
||||
$this->createUnwritableFolderIfNeeded($fileName);
|
||||
$this->createUnwritableFolderIfNeeded();
|
||||
$filePath = $this->getGeneratedUnwritableResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
@@ -97,6 +99,19 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
public function testAddRowShouldThrowExceptionIfUnsupportedDataTypePassedIn()
|
||||
{
|
||||
$fileName = 'test_add_row_should_throw_exception_if_unsupported_data_type_passed_in.xlsx';
|
||||
$dataRows = [
|
||||
[str_repeat('a', Worksheet::MAX_CHARACTERS_PER_CELL + 1)],
|
||||
];
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Common\Exception\InvalidArgumentException
|
||||
*/
|
||||
public function testAddRowShouldThrowExceptionIfWritingStringExceedingMaxNumberOfCharactersAllowedPerCell()
|
||||
{
|
||||
$fileName = 'test_add_row_should_throw_exception_if_string_exceeds_max_num_chars_allowed_per_cell.xlsx';
|
||||
$dataRows = [
|
||||
[new \stdClass()],
|
||||
];
|
||||
@@ -104,6 +119,39 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowShouldCleanupAllFilesIfExceptionIsThrown()
|
||||
{
|
||||
$fileName = 'test_add_row_should_cleanup_all_files_if_exception_thrown.xlsx';
|
||||
$dataRows = [
|
||||
['wrong'],
|
||||
[new \stdClass()],
|
||||
];
|
||||
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
$this->recreateTempFolder();
|
||||
$tempFolderPath = $this->getTempFolderPath();
|
||||
|
||||
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setTempFolder($tempFolderPath);
|
||||
$writer->openToFile($resourcePath);
|
||||
|
||||
try {
|
||||
$writer->addRows($dataRows);
|
||||
$this->fail('Exception should have been thrown');
|
||||
} catch (SpoutException $e) {
|
||||
$this->assertFalse(file_exists($fileName), 'Output file should have been deleted');
|
||||
|
||||
$numFiles = iterator_count(new \FilesystemIterator($tempFolderPath, \FilesystemIterator::SKIP_DOTS));
|
||||
$this->assertEquals(0, $numFiles, 'All temp files should have been deleted');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -146,6 +194,23 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($firstSheet, $writer->getCurrentSheet(), 'The current sheet should be the first one.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testCloseShouldNoopWhenWriterIsNotOpened()
|
||||
{
|
||||
$fileName = 'test_double_close_calls.xlsx';
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -232,6 +297,48 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowShouldSupportAssociativeArrays()
|
||||
{
|
||||
$fileName = 'test_add_row_should_support_associative_arrays.xlsx';
|
||||
$dataRows = [
|
||||
['foo' => 'xlsx--11', 'bar' => 'xlsx--12'],
|
||||
];
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
|
||||
foreach ($dataRows as $dataRow) {
|
||||
foreach ($dataRow as $cellValue) {
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, $cellValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowShouldNotWriteEmptyRows()
|
||||
{
|
||||
$fileName = 'test_add_row_should_not_write_empty_rows.xlsx';
|
||||
$dataRows = [
|
||||
[''],
|
||||
['xlsx--21', 'xlsx--22'],
|
||||
['key' => ''],
|
||||
[''],
|
||||
['xlsx--51', 'xlsx--52'],
|
||||
];
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'row r="2"');
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'row r="5"');
|
||||
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="1"');
|
||||
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="3"');
|
||||
$this->assertInlineDataWasNotWrittenToSheet($fileName, 1, 'row r="4"');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -5,6 +5,8 @@ namespace Box\Spout\Writer\XLSX;
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\TestUsingResource;
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\Style;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
@@ -124,7 +126,6 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$fontElements = $fontsDomElement->getElementsByTagName('font');
|
||||
$this->assertEquals(3, $fontElements->length, 'There should be 3 associated "font" elements, including the default one.');
|
||||
|
||||
// First font should be the default one
|
||||
$defaultFontElement = $fontElements->item(0);
|
||||
$this->assertChildrenNumEquals(3, $defaultFontElement, 'The default font should only have 3 properties.');
|
||||
@@ -175,6 +176,53 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals('0', $cellDomElements[2]->getAttribute('s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddRowWithStyleShouldApplyStyleToEmptyCellsIfNeeded()
|
||||
{
|
||||
$fileName = 'test_add_row_with_style_should_apply_style_to_empty_cells_if_needed.xlsx';
|
||||
$dataRows = [
|
||||
['xlsx--11', '', 'xlsx--13'],
|
||||
['xlsx--21', '', 'xlsx--23'],
|
||||
['xlsx--31', '', 'xlsx--33'],
|
||||
['xlsx--41', '', 'xlsx--43'],
|
||||
];
|
||||
|
||||
$styleWithFont = (new StyleBuilder())->setFontBold()->build();
|
||||
$styleWithBackground = (new StyleBuilder())->setBackgroundColor(Color::BLUE)->build();
|
||||
|
||||
$border = (new BorderBuilder())->setBorderBottom(Color::GREEN)->build();
|
||||
$styleWithBorder = (new StyleBuilder())->setBorder($border)->build();
|
||||
|
||||
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, [null, $styleWithFont, $styleWithBackground, $styleWithBorder]);
|
||||
|
||||
$cellDomElements = $this->getCellElementsFromSheetXmlFile($fileName);
|
||||
|
||||
// The first and second rows should not have a reference to the empty cell
|
||||
// The other rows should have the reference because style should be applied to them
|
||||
// So that's: 2 + 2 + 3 + 3 = 10 cells
|
||||
$this->assertEquals(10, count($cellDomElements));
|
||||
|
||||
// First row has 2 styled cells
|
||||
$this->assertEquals('0', $cellDomElements[0]->getAttribute('s'));
|
||||
$this->assertEquals('0', $cellDomElements[1]->getAttribute('s'));
|
||||
|
||||
// Second row has 2 styled cells
|
||||
$this->assertEquals('1', $cellDomElements[2]->getAttribute('s'));
|
||||
$this->assertEquals('1', $cellDomElements[3]->getAttribute('s'));
|
||||
|
||||
// Third row has 3 styled cells
|
||||
$this->assertEquals('2', $cellDomElements[4]->getAttribute('s'));
|
||||
$this->assertEquals('2', $cellDomElements[5]->getAttribute('s'));
|
||||
$this->assertEquals('2', $cellDomElements[6]->getAttribute('s'));
|
||||
|
||||
// Third row has 3 styled cells
|
||||
$this->assertEquals('3', $cellDomElements[7]->getAttribute('s'));
|
||||
$this->assertEquals('3', $cellDomElements[8]->getAttribute('s'));
|
||||
$this->assertEquals('3', $cellDomElements[9]->getAttribute('s'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -205,7 +253,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
];
|
||||
$style = (new StyleBuilder())->setShouldWrapText()->build();
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName,$style);
|
||||
$this->writeToXLSXFile($dataRows, $fileName, $style);
|
||||
|
||||
$cellXfsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||
$xfElement = $cellXfsDomElement->getElementsByTagName('xf')->item(1);
|
||||
@@ -232,6 +280,239 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testAddBackgroundColor()
|
||||
{
|
||||
$fileName = 'test_add_background_color.xlsx';
|
||||
$dataRows = [
|
||||
["BgColor"],
|
||||
];
|
||||
$style = (new StyleBuilder())->setBackgroundColor(Color::WHITE)->build();
|
||||
$this->writeToXLSXFile($dataRows, $fileName, $style);
|
||||
$fillsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fills');
|
||||
$this->assertEquals(3, $fillsDomElement->getAttribute('count'), 'There should be 3 fills, including the 2 default ones');
|
||||
|
||||
$fillsElements = $fillsDomElement->getElementsByTagName('fill');
|
||||
|
||||
$thirdFillElement = $fillsElements->item(2); // Zero based
|
||||
$fgColor = $thirdFillElement->getElementsByTagName('fgColor')->item(0)->getAttribute('rgb');
|
||||
|
||||
$this->assertEquals(Color::WHITE, $fgColor, 'The foreground color should equal white');
|
||||
|
||||
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||
$this->assertEquals(2, $styleXfsElements->getAttribute('count'), '2 cell xfs present - a default one and a custom one');
|
||||
|
||||
$customFillId = $styleXfsElements->lastChild->getAttribute('fillId');
|
||||
$this->assertEquals(2, (int)$customFillId, 'The custom fill id should have the index 2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReuseBackgroundColorSharedDefinition()
|
||||
{
|
||||
$fileName = 'test_add_background_color_shared_definition.xlsx';
|
||||
$dataRows = [
|
||||
["row-bold-background-red"],
|
||||
["row-background-red"],
|
||||
];
|
||||
|
||||
$styles = [
|
||||
(new StyleBuilder())->setBackgroundColor(Color::RED)->setFontBold()->build(),
|
||||
(new StyleBuilder())->setBackgroundColor(Color::RED)->build()
|
||||
];
|
||||
|
||||
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||
|
||||
$fillsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fills');
|
||||
$this->assertEquals(
|
||||
3,
|
||||
$fillsDomElement->getAttribute('count'),
|
||||
'There should be 3 fills, including the 2 default ones'
|
||||
);
|
||||
|
||||
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||
$this->assertEquals(
|
||||
3,
|
||||
$styleXfsElements->getAttribute('count'),
|
||||
'3 cell xfs present - a default one and two custom ones'
|
||||
);
|
||||
|
||||
$firstCustomId = $styleXfsElements->childNodes->item(1)->getAttribute('fillId');
|
||||
$this->assertEquals(2, (int)$firstCustomId, 'The first custom fill id should have the index 2');
|
||||
|
||||
$secondCustomId = $styleXfsElements->childNodes->item(2)->getAttribute('fillId');
|
||||
$this->assertEquals(2, (int)$secondCustomId, 'The second custom fill id should have the index 2');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testBorders()
|
||||
{
|
||||
$fileName = 'test_borders.xlsx';
|
||||
|
||||
$dataRows = [
|
||||
['row-with-border-bottom-green-thick-solid'],
|
||||
['row-without-border'],
|
||||
['row-with-border-top-red-thin-dashed'],
|
||||
];
|
||||
|
||||
$borderBottomGreenThickSolid = (new BorderBuilder())
|
||||
->setBorderBottom(Color::GREEN, Border::WIDTH_THICK, Border::STYLE_SOLID)->build();
|
||||
|
||||
|
||||
$borderTopRedThinDashed = (new BorderBuilder())
|
||||
->setBorderTop(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
|
||||
|
||||
$styles = [
|
||||
(new StyleBuilder())->setBorder($borderBottomGreenThickSolid)->build(),
|
||||
(new StyleBuilder())->build(),
|
||||
(new StyleBuilder())->setBorder($borderTopRedThinDashed)->build(),
|
||||
];
|
||||
|
||||
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||
$this->assertEquals(3, $borderElements->getAttribute('count'), '3 borders present');
|
||||
|
||||
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||
$this->assertEquals(3, $styleXfsElements->getAttribute('count'), '3 cell xfs present');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testBordersCorrectOrder()
|
||||
{
|
||||
// Border should be Left, Right, Top, Bottom
|
||||
$fileName = 'test_borders_correct_order.xlsx';
|
||||
|
||||
$dataRows = [
|
||||
['I am a teapot'],
|
||||
];
|
||||
|
||||
$borders = (new BorderBuilder())
|
||||
->setBorderRight()
|
||||
->setBorderTop()
|
||||
->setBorderLeft()
|
||||
->setBorderBottom()
|
||||
->build();
|
||||
|
||||
$style = (new StyleBuilder())->setBorder($borders)->build();
|
||||
$this->writeToXLSXFile($dataRows, $fileName, $style);
|
||||
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||
|
||||
$correctOrdering = [
|
||||
'left', 'right', 'top', 'bottom'
|
||||
];
|
||||
|
||||
/** @var $borderNode \DOMElement */
|
||||
foreach ($borderElements->childNodes as $borderNode) {
|
||||
$borderParts = $borderNode->childNodes;
|
||||
$ordering = [];
|
||||
|
||||
/** @var $part \DOMText */
|
||||
foreach ($borderParts as $part) {
|
||||
if ($part instanceof \DOMElement) {
|
||||
$ordering[] = $part->nodeName;
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertEquals($correctOrdering, $ordering, 'The border parts are in correct ordering');
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testSetDefaultRowStyle()
|
||||
{
|
||||
$fileName = 'test_set_default_row_style.xlsx';
|
||||
$dataRows = [['xlsx--11']];
|
||||
|
||||
$defaultFontSize = 50;
|
||||
$defaultStyle = (new StyleBuilder())->setFontSize($defaultFontSize)->build();
|
||||
|
||||
$this->writeToXLSXFileWithDefaultStyle($dataRows, $fileName, $defaultStyle);
|
||||
|
||||
$fontsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'fonts');
|
||||
$fontElements = $fontsDomElement->getElementsByTagName('font');
|
||||
$this->assertEquals(1, $fontElements->length, 'There should only be the default font.');
|
||||
|
||||
$defaultFontElement = $fontElements->item(0);
|
||||
$this->assertFirstChildHasAttributeEquals((string) $defaultFontSize, $defaultFontElement, 'sz', 'val');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReUseBorders()
|
||||
{
|
||||
$fileName = 'test_reuse_borders.xlsx';
|
||||
|
||||
$borderLeft = (new BorderBuilder())->setBorderLeft()->build();
|
||||
$borderLeftStyle = (new StyleBuilder())->setBorder($borderLeft)->build();
|
||||
|
||||
$borderRight = (new BorderBuilder())->setBorderRight(Color::RED, Border::WIDTH_THICK)->build();
|
||||
$borderRightStyle = (new StyleBuilder())->setBorder($borderRight)->build();
|
||||
|
||||
$fontStyle = (new StyleBuilder())->setFontBold()->build();
|
||||
$emptyStyle = (new StyleBuilder())->build();
|
||||
|
||||
$borderRightFontBoldStyle = $borderRightStyle->mergeWith($fontStyle);
|
||||
|
||||
$dataRows = [
|
||||
['Border-Left'],
|
||||
['Empty'],
|
||||
['Font-Bold'],
|
||||
['Border-Right'],
|
||||
['Border-Right-Font-Bold'],
|
||||
];
|
||||
|
||||
$styles = [
|
||||
$borderLeftStyle,
|
||||
$emptyStyle,
|
||||
$fontStyle,
|
||||
$borderRightStyle,
|
||||
$borderRightFontBoldStyle
|
||||
];
|
||||
|
||||
$this->writeToXLSXFileWithMultipleStyles($dataRows, $fileName, $styles);
|
||||
$borderElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'borders');
|
||||
|
||||
$this->assertEquals(3, $borderElements->getAttribute('count'), '3 borders in count attribute');
|
||||
$this->assertEquals(3, $borderElements->childNodes->length, '3 border childnodes present');
|
||||
|
||||
/** @var \DOMElement $firstBorder */
|
||||
$firstBorder = $borderElements->childNodes->item(1); // 0 = default border
|
||||
$leftStyle = $firstBorder->getElementsByTagName('left')->item(0)->getAttribute('style');
|
||||
$this->assertEquals('medium', $leftStyle, 'Style is medium');
|
||||
|
||||
/** @var \DOMElement $secondBorder */
|
||||
$secondBorder = $borderElements->childNodes->item(2);
|
||||
$rightStyle = $secondBorder->getElementsByTagName('right')->item(0)->getAttribute('style');
|
||||
$this->assertEquals('thick', $rightStyle, 'Style is thick');
|
||||
|
||||
$styleXfsElements = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
|
||||
|
||||
// A rather relaxed test
|
||||
// Where a border is applied - the borderId attribute has to be greater than 0
|
||||
$bordersApplied = 0;
|
||||
/** @var \DOMElement $node */
|
||||
foreach ($styleXfsElements->childNodes as $node) {
|
||||
if ($node->getAttribute('applyBorder') == 1) {
|
||||
$bordersApplied++;
|
||||
$this->assertTrue((int)$node->getAttribute('borderId') > 0, 'BorderId is greater than 0');
|
||||
} else {
|
||||
$this->assertTrue((int)$node->getAttribute('borderId') === 0, 'BorderId is 0');
|
||||
}
|
||||
}
|
||||
|
||||
$this->assertEquals(3, $bordersApplied, 'Three borders have been applied');
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
@@ -254,6 +535,29 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
return $writer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
* @param \Box\Spout\Writer\Style\Style|null $defaultStyle
|
||||
* @return Writer
|
||||
*/
|
||||
private function writeToXLSXFileWithDefaultStyle($allRows, $fileName, $defaultStyle)
|
||||
{
|
||||
$this->createGeneratedFolderIfNeeded($fileName);
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
|
||||
/** @var \Box\Spout\Writer\XLSX\Writer $writer */
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setShouldUseInlineStrings(true);
|
||||
$writer->setDefaultRowStyle($defaultStyle);
|
||||
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->addRows($allRows);
|
||||
$writer->close();
|
||||
|
||||
return $writer;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $allRows
|
||||
* @param string $fileName
|
||||
@@ -293,13 +597,16 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
private function getXmlSectionFromStylesXmlFile($fileName, $section)
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToStylesXmlFile = $resourcePath . '#xl/styles.xml';
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
||||
$xmlReader->openFileInZip($resourcePath, 'xl/styles.xml');
|
||||
$xmlReader->readUntilNodeFound($section);
|
||||
|
||||
return $xmlReader->expand();
|
||||
$xmlSection = $xmlReader->expand();
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $xmlSection;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -311,17 +618,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
|
||||
$cellElements = [];
|
||||
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToStylesXmlFile = $resourcePath . '#xl/worksheets/sheet1.xml';
|
||||
|
||||
$xmlReader = new \XMLReader();
|
||||
$xmlReader->open('zip://' . $pathToStylesXmlFile);
|
||||
$xmlReader = new XMLReader();
|
||||
$xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml');
|
||||
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'c') {
|
||||
if ($xmlReader->isPositionedOnStartingNode('c')) {
|
||||
$cellElements[] = $xmlReader->expand();
|
||||
}
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $cellElements;
|
||||
}
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ require_once(dirname(__DIR__) . '/vendor/autoload.php');
|
||||
|
||||
require_once(dirname(__DIR__) . '/tests/Spout/TestUsingResource.php');
|
||||
require_once(dirname(__DIR__) . '/tests/Spout/ReflectionHelper.php');
|
||||
require_once(dirname(__DIR__) . '/tests/Spout/Reader/CSV/SpoutTestStream.php');
|
||||
|
||||
// Make sure a timezone is set to be able to work with dates
|
||||
date_default_timezone_set('UTC');
|
||||
@@ -0,0 +1 @@
|
||||
csv--11,csv--12,csv--13
csv--21,csv--22,csv--23
csv--31,csv--32,csv--33
|
||||
|
@@ -1,2 +0,0 @@
|
||||
csv--11,csv--12,csv--13
|
||||
csv--31,csv--32,csv--33
|
||||
|
@@ -1,3 +0,0 @@
|
||||
csv--11,csv--12,csv--13
|
||||
|
||||
csv--31,csv--32,csv--33
|
||||
|
@@ -0,0 +1,2 @@
|
||||
"This is,
|
||||
a comma",csv--12
|
||||
|
@@ -0,0 +1,5 @@
|
||||
|
||||
csv--21,csv--22,csv--23
|
||||
|
||||
csv--41,csv--42,csv--43
|
||||
|
||||
|
@@ -0,0 +1,3 @@
|
||||
"A","B","C"
|
||||
0,,
|
||||
1,1,
|
||||
|
@@ -0,0 +1,5 @@
|
||||
A
|
||||
" A "
|
||||
"
|
||||
A
|
||||
"
|
||||
|
@@ -0,0 +1,3 @@
|
||||
"A","B","C"
|
||||
1,2,3
|
||||
0,0,0
|
||||
|
Loaded 100 of 125 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user