Compare commits

...
47 Commits
Author SHA1 Message Date
Adrien Loison a8eb7ad39c Shared strings table without uniqueCount and count should work (#269)
Use file based strategy in this case
2016-07-11 19:03:37 +02:00
Adrien Loison ffea8871a6 Add support for missing cell reference (#268)
When describing a cell, the cell reference (r="A1") is optional.
When not present, we should just increment the index of the last processed row.
2016-07-11 18:15:55 +02:00
Marie b02d13cd40 Set BOM as optional on CSV writer (#265) 2016-07-07 15:21:41 +02:00
rlukasz aa25678a83 Update RowIterator.php (#263) 2016-07-04 11:31:03 +02:00
Adrien Loison 192659cb24 Update README.md (#256) 2016-06-19 20:20:49 -07:00
madflow e30bc37448 First stab at #135 - Add borders (#200)
Fixes #135

Added border support for ODS and XLSX files.
Updated README.
2016-06-18 23:36:10 -07:00
Adrien Loison 1891c0b053 Fix XLSX reading when shared strings is missing the uniqueCount attribute (#255)
Use "count" attribute as a fallback
2016-06-16 10:06:11 -07:00
madflow a43c13a36f Fix #252 (#253) 2016-06-14 10:02:01 -07:00
Adrien Loison dc31d6e8c2 Update .scrutinizer.yml (#248) 2016-06-09 10:52:01 -07:00
Adrien Loison 8edd8e2401 Clear previous output when openToBrowser() called (#243)
If any character has been outputted before, the generated file will be corrupted.
2016-06-08 13:53:41 -07:00
madflow cd38ba093e Fix #245 (#246) 2016-06-08 09:50:00 -07:00
Pavel Zyukin 70c81e809f Add the ability to pass an array with various keys to addRows() (#240) 2016-06-03 08:09:37 -07:00
Adrien Loison 1d3a9f939c Convert escapers to singletons (#239) 2016-05-30 13:55:21 -07:00
Ingmar Runge efebfb2bc2 CellValueFormatterTest: fix expectations for 32bit PHP (#234) 2016-05-30 10:25:30 -07:00
Adrien Loison 251c0bebc1 Adding open_file_in_zip() helper function to XMLReader (#238) 2016-05-29 23:22:57 -07:00
Adrien Loison 03866a6604 Support XLSX with prefixed XML files (#237)
While the standard is not to have prefixes, some XLSX files have XML files containing a prefix.
Microsoft has a tool that generates such files: https://msdn.microsoft.com/en-us/library/office/gg278316.aspx
2016-05-29 22:16:59 -07:00
Adrien Loison 2ed30321b4 ODS Writer should accept associative arrays (#232) 2016-05-25 19:59:18 -07:00
Adrien Loison 2c80b1f23a XLSX Reader should add a space between text nodes (#229)
When a cell contains multiple text nodes, the cell value is currently obtained by concatenating the value of each text node.
Instead, values should still be concatenated but a space should be added in between.
2016-05-23 14:15:48 -07:00
Adrien Loison a24e794177 Update README.md (#228)
Fixed the amount of memory used by Spout, as it used to take into account the memory used by PHPUnit.
2016-05-23 13:36:07 -07:00
Adrien Loison 104cd9b811 Option to return formatted dates instead of PHP objects (#226)
When reading spreadsheets, Spout should be able to return formatted dates, as shown when opened with Excel for instance.
It currently only returns DateTime/DateInterval objects, making it impossible to read + write, as the Writer does not accept objects.
2016-05-20 16:08:35 -07:00
Adrien Loison a6a6b158de Pin PHPUnit version to keep support for PHP5.4 (#227) 2016-05-20 16:00:22 -07:00
madflow 2d923c7e46 Fix issue #218 (#222) 2016-05-20 09:32:47 -07:00
Adrien Loison b4724906c4 Add support for cells formatted as time (#224)
Cells formatted as "time" have values between 0 and 1. These values used to be considered as invalid.
Note: this uses what was started in #202
2016-05-19 13:10:47 -07:00
Adrien Loison bb20d2e6bb Update Scrutinizer dependency and code coverage (#223) 2016-05-19 11:25:00 -07:00
Adrien Loison b8fd789ac0 Retrieve XLSX sheets in order of appearance (#220)
Instead of relying on the ID, sheets should be retrieved in the order they appear in the file.
Workbook.xml describes the correct order.
This allows the reader to read data in the correct order when sheets have been manually moved after creation.
2016-05-19 10:37:48 -07:00
Adrien Loison 5a7c2c1262 Handle General number format as non date (#221)
If the number format is set to General (id = 0), do no try to format the value as a date
2016-05-19 09:40:12 -07:00
Adrien Loison e9cd7a397e Merge pull request #199 from box/check_valid_resource_on_close
Check file handle is valid before manipulating it
2016-04-12 11:03:15 -07:00
Adrien Loison 8bb42ebc23 Check file handle is valid before manipulating it
This fixes issues when something went wrong on reader/writer init and the developer wants to close the reader/writer.
The file handle may not be defined so we need to add a check for it, before actually using it.
2016-04-12 10:36:59 -07:00
Adrien Loison 71a6f6a937 Merge pull request #196 from madflow/trim-inline-strings
Fix #195
2016-04-09 13:20:48 -07:00
madflow 616925148e Renamed xlsx file, #195 2016-04-07 08:58:54 +02:00
madflow 6f0f7c9690 Fix #195 2016-04-06 22:00:47 +02:00
Adrien Loison b69e28050b Merge pull request #187 from skeleton/issue-183
Fix line breaks on CSV reader
2016-03-23 17:02:42 -07:00
skeleton d6e8fe4b54 Fix line breaks on CSV reader 2016-03-23 23:26:49 +01:00
Adrien Loison e321f30c3b Merge pull request #191 from box/close_file_pointer_when_done_writing
Writers did not close written file pointer
2016-03-23 10:45:42 -07:00
Adrien Loison c31373fb1a Writers did not close written file pointer 2016-03-23 10:21:27 -07:00
Adrien Loison 6c57125c0c Merge pull request #189 from madflow/ods-missing-values
Fixes for #184
2016-03-21 10:24:38 -07:00
madflow 30837f869d Coding style and typos 2016-03-20 08:46:30 +01:00
madflow e60054f3c4 More explicit rule for ignoring empty placeholder cells in Excel ODS #184 2016-03-19 11:34:32 +01:00
madflow 3ee7099c95 Fix zeros treated as missing values #184 2016-03-19 11:34:32 +01:00
madflow 2b1160bb33 Tests for #184 2016-03-19 11:34:31 +01:00
Adrien Loison 049fd990b9 Merge pull request #188 from box/stream_wrapper_support
Custom stream wrapper support
2016-03-18 18:09:22 -07:00
Adrien Loison d2ac54c578 Custom stream wrapper support
Added support for custom stream wrappers, such as "fly" or "s3".
Support is determined per reader.
2016-03-18 17:09:13 -07:00
Adrien Loison 0c90d102ef Merge pull request #179 from lichunqiang/gitattributes
Add .gitattributes
2016-02-21 18:11:03 -08:00
lichunqiang e39dcb3847 Add .gitattributes 2016-02-22 09:48:28 +08:00
Adrien Loison 10d1140a95 Merge pull request #175 from IlyaBakhlin/master
Fixing the boolean bug.
2016-02-15 16:43:30 -08:00
Ilya Bakhlin 48debbcbca Simplifying the fix. 2016-02-15 22:11:04 +01:00
Ilya Bakhlin f4d6fb87ee Fixing the boolean bug. 2016-02-15 15:55:46 +01:00
89 changed files with 2586 additions and 1130 deletions

No files matched your search

+11
View File
@@ -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
View File
@@ -15,10 +15,13 @@ tools:
php_pdepend: true php_pdepend: true
php_loc: php_loc:
enabled: true enabled: true
excluded_dirs: [vendor, tests] filter:
php_cpd: paths: ['src']
php_cpd: false # Must be disabled to use php_sim instead
php_sim:
enabled: true enabled: true
excluded_dirs: [vendor, tests] filter:
paths: ['src']
build_failure_conditions: build_failure_conditions:
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9 - 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
+3 -2
View File
@@ -16,7 +16,8 @@ install:
script: script:
- mkdir -p build/logs - mkdir -p build/logs
- php vendor/bin/phpunit --coverage-clover build/logs/clover.xml - php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
after_script: after_script:
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml; fi - if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover; fi
+53 -6
View File
@@ -8,7 +8,7 @@
[![License](https://poser.pugx.org/box/spout/license)](https://packagist.org/packages/box/spout) [![License](https://poser.pugx.org/box/spout/license)](https://packagist.org/packages/box/spout)
Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way. Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 10MB). Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 3MB).
Join the community and come discuss about Spout: [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge) Join the community and come discuss about Spout: [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
@@ -118,14 +118,22 @@ Additionally, if you need to read non UTF-8 files, you can specify the encoding
$reader->setEncoding('UTF-16LE'); $reader->setEncoding('UTF-16LE');
``` ```
The writer always generate CSV files encoded in UTF-8, with a BOM. By default, the writer generates CSV files encoded in UTF-8, with a BOM.
It is however possible to not include the BOM:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::CSV);
$writer->setShouldAddBOM(false);
```
### Configuring the XLSX and ODS writers ### Configuring the XLSX and ODS readers and writers
#### Row styling #### Row styling
It is possible to apply some formatting options to a row. Spout supports fonts as well as alignment styles. It is possible to apply some formatting options to a row. Spout supports fonts, borders as well as alignment styles.
```php ```php
use Box\Spout\Common\Type; use Box\Spout\Common\Type;
@@ -150,6 +158,32 @@ $writer->addRowsWithStyle($multipleRows, $style); // style will be applied to al
$writer->close(); $writer->close();
``` ```
Adding borders to a row requires a ```Border``` object.
```php
use Box\Spout\Common\Type;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderBuilder;
use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\StyleBuilder;
use Box\Spout\Writer\WriterFactory;
$border = (new BorderBuilder())
->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED)
->build();
$style = (new StyleBuilder())
->setBorder($border)
->build();
$writer = WriterFactory::create(Type::XLSX);
$writer->openToFile($filePath);
$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style)
$writer->close();
```
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones: Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
Category | Property | API Category | Property | API
@@ -163,7 +197,6 @@ Font | Bold | `StyleBuilder::setFontBold()`
| Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))` | Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))`
Alignment | Wrap text | `StyleBuilder::setShouldWrapText()` Alignment | Wrap text | `StyleBuilder::setShouldWrapText()`
#### New sheet creation #### New sheet creation
It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet: It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet:
@@ -208,6 +241,20 @@ $writer->setShouldUseInlineStrings(false); // will use shared strings
> Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings! > Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings!
#### Date/Time formatting
When reading a spreadsheet containing dates or times, Spout returns the values by default as DateTime objects.
It is possible to change this behavior and have a formatted date returned instead (e.g. "2016-11-29 1:22 AM"). The format of the date corresponds to what is specified in the spreadsheet.
```php
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\Common\Type;
$reader = ReaderFactory::create(Type::XLSX);
$reader->setShouldFormatDates(false); // default value
$reader->setShouldFormatDates(true); // will return formatted dates
```
### Playing with sheets ### Playing with sheets
When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet: When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet:
@@ -288,7 +335,7 @@ For information, the performance tests take about 30 minutes to run (processing
## Frequently Asked Questions ## Frequently Asked Questions
#### How can Spout handle such large data sets and still use less than 10MB of memory? #### How can Spout handle such large data sets and still use less than 3MB of memory?
When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed. When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed.
+1 -2
View File
@@ -18,8 +18,7 @@
"ext-simplexml": "*" "ext-simplexml": "*"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": ">=3.7", "phpunit/phpunit": "^4.8.0"
"scrutinizer/ocular": "~1.1"
}, },
"suggest": { "suggest": {
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)", "ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
Generated
+74 -741
View File
File diff suppressed because it is too large. Load diff
+4
View File
@@ -2,6 +2,8 @@
namespace Box\Spout\Common\Escaper; namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/** /**
* Class ODS * Class ODS
* Provides functions to escape and unescape data for ODS files * Provides functions to escape and unescape data for ODS files
@@ -10,6 +12,8 @@ namespace Box\Spout\Common\Escaper;
*/ */
class ODS implements EscaperInterface class ODS implements EscaperInterface
{ {
use Singleton;
/** /**
* Escapes the given string to make it compatible with XLSX * Escapes the given string to make it compatible with XLSX
* *
+6 -2
View File
@@ -2,6 +2,8 @@
namespace Box\Spout\Common\Escaper; namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/** /**
* Class XLSX * Class XLSX
* Provides functions to escape and unescape data for XLSX files * Provides functions to escape and unescape data for XLSX files
@@ -10,13 +12,15 @@ namespace Box\Spout\Common\Escaper;
*/ */
class XLSX implements EscaperInterface class XLSX implements EscaperInterface
{ {
use Singleton;
/** @var string[] Control characters to be escaped */ /** @var string[] Control characters to be escaped */
protected $controlCharactersEscapingMap; protected $controlCharactersEscapingMap;
/** /**
* * Initializes the singleton instance
*/ */
public function __construct() protected function init()
{ {
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap(); $this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
} }
+1 -1
View File
@@ -59,7 +59,7 @@ class EncodingHelper
{ {
$byteOffsetToSkipBom = 0; $byteOffsetToSkipBom = 0;
if ($this->hasBom($filePointer, $encoding)) { if ($this->hasBOM($filePointer, $encoding)) {
$bomUsed = $this->supportedEncodingsWithBom[$encoding]; $bomUsed = $this->supportedEncodingsWithBom[$encoding];
// we skip the N first bytes // we skip the N first bytes
@@ -252,6 +252,19 @@ class GlobalFunctionsHelper
header($string); header($string);
} }
/**
* Wrapper around global function ob_end_clean()
* @see ob_end_clean()
*
* @return void
*/
public function ob_end_clean()
{
if (ob_get_length() > 0) {
ob_end_clean();
}
}
/** /**
* Wrapper around global function iconv() * Wrapper around global function iconv()
* @see iconv() * @see iconv()
@@ -281,32 +294,14 @@ class GlobalFunctionsHelper
} }
/** /**
* Wrapper around global function stream_get_line() * Wrapper around global function stream_get_wrappers()
* @see stream_get_line() * @see stream_get_wrappers()
* *
* @param resource $handle
* @param int $length
* @param string|void $ending
* @return string|bool
*/
public function stream_get_line($handle, $length, $ending = null)
{
return stream_get_line($handle, $length, $ending);
}
/**
* Wrapper around global function str_getcsv()
* @see str_getcsv()
*
* @param string $input
* @param string|void $delimiter
* @param string|void $enclosure
* @param string|void $escape
* @return array * @return array
*/ */
public function str_getcsv($input, $delimiter = null, $enclosure = null, $escape = null) public function stream_get_wrappers()
{ {
return str_getcsv($input, $delimiter, $enclosure, $escape); return stream_get_wrappers();
} }
/** /**
+41
View File
@@ -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() {}
}
+91 -4
View File
@@ -19,6 +19,16 @@ abstract class AbstractReader implements ReaderInterface
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper; protected $globalFunctionsHelper;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates = false;
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
abstract protected function doesSupportStreamWrapper();
/** /**
* Opens the file at the given file path to make it ready to be read * Opens the file at the given file path to make it ready to be read
* *
@@ -42,7 +52,7 @@ abstract class AbstractReader implements ReaderInterface
abstract protected function closeReader(); abstract protected function closeReader();
/** /**
* @param $globalFunctionsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @return AbstractReader * @return AbstractReader
*/ */
public function setGlobalFunctionsHelper($globalFunctionsHelper) public function setGlobalFunctionsHelper($globalFunctionsHelper)
@@ -51,6 +61,18 @@ abstract class AbstractReader implements ReaderInterface
return $this; return $this;
} }
/**
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
*
* @param bool $shouldFormatDates
* @return AbstractReader
*/
public function setShouldFormatDates($shouldFormatDates)
{
$this->shouldFormatDates = $shouldFormatDates;
return $this;
}
/** /**
* Prepares the reader to read the given file. It also makes sure * Prepares the reader to read the given file. It also makes sure
* that the file exists and is readable. * that the file exists and is readable.
@@ -62,6 +84,10 @@ abstract class AbstractReader implements ReaderInterface
*/ */
public function open($filePath) public function open($filePath)
{ {
if ($this->isStreamWrapper($filePath) && (!$this->doesSupportStreamWrapper() || !$this->isSupportedStreamWrapper($filePath))) {
throw new IOException("Could not open $filePath for reading! Stream wrapper used is not supported for this type of file.");
}
if (!$this->isPhpStream($filePath)) { if (!$this->isPhpStream($filePath)) {
// we skip the checks if the provided file path points to a PHP stream // we skip the checks if the provided file path points to a PHP stream
if (!$this->globalFunctionsHelper->file_exists($filePath)) { if (!$this->globalFunctionsHelper->file_exists($filePath)) {
@@ -72,8 +98,7 @@ abstract class AbstractReader implements ReaderInterface
} }
try { try {
// Need to use realpath to fix "Can't open file" on some Windows setup $fileRealPath = $this->getFileRealPath($filePath);
$fileRealPath = realpath($filePath);
$this->openReader($fileRealPath); $this->openReader($fileRealPath);
$this->isStreamOpened = true; $this->isStreamOpened = true;
} catch (\Exception $exception) { } catch (\Exception $exception) {
@@ -81,6 +106,67 @@ abstract class AbstractReader implements ReaderInterface
} }
} }
/**
* Returns the real path of the given path.
* If the given path is a valid stream wrapper, returns the path unchanged.
*
* @param string $filePath
* @return string
*/
protected function getFileRealPath($filePath)
{
if ($this->isSupportedStreamWrapper($filePath)) {
return $filePath;
}
// Need to use realpath to fix "Can't open file" on some Windows setup
return realpath($filePath);
}
/**
* Returns the scheme of the custom stream wrapper, if the path indicates a stream wrapper is used.
* For example, php://temp => php, s3://path/to/file => s3...
*
* @param string $filePath Path of the file to be read
* @return string|null The stream wrapper scheme or NULL if not a stream wrapper
*/
protected function getStreamWrapperScheme($filePath)
{
$streamScheme = null;
if (preg_match('/^(\w+):\/\//', $filePath, $matches)) {
$streamScheme = $matches[1];
}
return $streamScheme;
}
/**
* Checks if the given path is an unsupported stream wrapper
* (like local path, php://temp, mystream://foo/bar...).
*
* @param string $filePath Path of the file to be read
* @return bool Whether the given path is an unsupported stream wrapper
*/
protected function isStreamWrapper($filePath)
{
return ($this->getStreamWrapperScheme($filePath) !== null);
}
/**
* Checks if the given path is an supported stream wrapper
* (like php://temp, mystream://foo/bar...).
* If the given path is a local path, returns true.
*
* @param string $filePath Path of the file to be read
* @return bool Whether the given path is an supported stream wrapper
*/
protected function isSupportedStreamWrapper($filePath)
{
$streamScheme = $this->getStreamWrapperScheme($filePath);
return ($streamScheme !== null) ?
in_array($streamScheme, $this->globalFunctionsHelper->stream_get_wrappers()) :
true;
}
/** /**
* Checks if a path is a PHP stream (like php://output, php://memory, ...) * Checks if a path is a PHP stream (like php://output, php://memory, ...)
* *
@@ -89,7 +175,8 @@ abstract class AbstractReader implements ReaderInterface
*/ */
protected function isPhpStream($filePath) protected function isPhpStream($filePath)
{ {
return (strpos($filePath, 'php://') === 0); $streamScheme = $this->getStreamWrapperScheme($filePath);
return ($streamScheme === 'php');
} }
/** /**
+18
View File
@@ -32,6 +32,9 @@ class Reader extends AbstractReader
/** @var string Defines the End of line */ /** @var string Defines the End of line */
protected $endOfLineCharacter = "\n"; protected $endOfLineCharacter = "\n";
/** @var string */
protected $autoDetectLineEndings;
/** /**
* Sets the field delimiter for the CSV. * Sets the field delimiter for the CSV.
* Needs to be called before opening the reader. * Needs to be called before opening the reader.
@@ -84,6 +87,16 @@ class Reader extends AbstractReader
return $this; return $this;
} }
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return true;
}
/** /**
* Opens the file at the given path to make it ready to be read. * Opens the file at the given path to make it ready to be read.
* If setEncoding() was not called, it assumes that the file is encoded in UTF-8. * If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
@@ -94,6 +107,9 @@ class Reader extends AbstractReader
*/ */
protected function openReader($filePath) protected function openReader($filePath)
{ {
$this->autoDetectLineEndings = ini_get('auto_detect_line_endings');
ini_set('auto_detect_line_endings', '1');
$this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r'); $this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
if (!$this->filePointer) { if (!$this->filePointer) {
throw new IOException("Could not open file $filePath for reading."); throw new IOException("Could not open file $filePath for reading.");
@@ -130,5 +146,7 @@ class Reader extends AbstractReader
if ($this->filePointer) { if ($this->filePointer) {
$this->globalFunctionsHelper->fclose($this->filePointer); $this->globalFunctionsHelper->fclose($this->filePointer);
} }
ini_set('auto_detect_line_endings', $this->autoDetectLineEndings);
} }
} }
+33 -21
View File
@@ -14,7 +14,7 @@ use Box\Spout\Common\Helper\EncodingHelper;
class RowIterator implements IteratorInterface class RowIterator implements IteratorInterface
{ {
/** /**
* If no value is given to stream_get_line(), it defaults to 8192 (which may be too low). * If no value is given to fgetcsv(), it defaults to 8192 (which may be too low).
* Alignement with other functions like fgets() is discussed here: https://bugs.php.net/bug.php?id=48421 * Alignement with other functions like fgets() is discussed here: https://bugs.php.net/bug.php?id=48421
*/ */
const MAX_READ_BYTES_PER_LINE = 32768; const MAX_READ_BYTES_PER_LINE = 32768;
@@ -57,6 +57,7 @@ class RowIterator implements IteratorInterface
* @param string $fieldDelimiter Character that delimits fields * @param string $fieldDelimiter Character that delimits fields
* @param string $fieldEnclosure Character that enclose fields * @param string $fieldEnclosure Character that enclose fields
* @param string $encoding Encoding of the CSV file to be read * @param string $encoding Encoding of the CSV file to be read
* @param string $endOfLineDelimiter End of line delimiter
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/ */
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $endOfLineDelimiter, $globalFunctionsHelper) public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $endOfLineDelimiter, $globalFunctionsHelper)
@@ -128,16 +129,12 @@ class RowIterator implements IteratorInterface
} }
do { do {
$lineData = false; $rowData = $this->getNextUTF8EncodedRow();
$utf8EncodedLineData = $this->getNextUTF8EncodedLine();
if ($utf8EncodedLineData !== false) {
$lineData = $this->globalFunctionsHelper->str_getcsv($utf8EncodedLineData, $this->fieldDelimiter, $this->fieldEnclosure);
}
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer); $hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
} while (($lineData === false && !$hasNowReachedEndOfFile) || $this->isEmptyLine($lineData)); } while (($rowData === false && !$hasNowReachedEndOfFile) || $this->isEmptyLine($rowData));
if ($lineData !== false) { if ($rowData !== false) {
$this->rowDataBuffer = $lineData; $this->rowDataBuffer = $rowData;
$this->numReadRows++; $this->numReadRows++;
} else { } else {
// If we reach this point, it means end of file was reached. // If we reach this point, it means end of file was reached.
@@ -147,24 +144,39 @@ class RowIterator implements IteratorInterface
} }
/** /**
* Returns the next line, converted if necessary to UTF-8. * Returns the next row, 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. * 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 string|false The next line for the current file pointer, encoded in UTF-8 or FALSE if nothing to read * @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 * @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
*/ */
protected function getNextUTF8EncodedLine() protected function getNextUTF8EncodedRow()
{ {
// Read until the EOL delimiter or EOF is reached. The delimiter's encoding needs to match the CSV's encoding. $encodedRowData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $this->fieldDelimiter, $this->fieldEnclosure);
$encodedEOLDelimiter = $this->getEncodedEOLDelimiter(); if (false === $encodedRowData) {
$encodedLineData = $this->globalFunctionsHelper->stream_get_line($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $encodedEOLDelimiter); return false;
}
// If the line could have been read, it can be converted to UTF-8 foreach ($encodedRowData as $cellIndex => $cellValue) {
$utf8EncodedLineData = ($encodedLineData !== false) ? switch($this->encoding) {
$this->encodingHelper->attemptConversionToUTF8($encodedLineData, $this->encoding) : case EncodingHelper::ENCODING_UTF16_LE:
false; 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;
return $utf8EncodedLineData; 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;
} }
/** /**
@@ -23,6 +23,8 @@ class CellValueFormatter
/** Definition of XML nodes names used to parse data */ /** Definition of XML nodes names used to parse data */
const XML_NODE_P = 'p'; const XML_NODE_P = 'p';
const XML_NODE_S = 'text:s'; const XML_NODE_S = 'text:s';
const XML_NODE_A = 'text:a';
const XML_NODE_SPAN = 'text:span';
/** Definition of XML attribute used to parse data */ /** Definition of XML attribute used to parse data */
const XML_ATTRIBUTE_TYPE = 'office:value-type'; const XML_ATTRIBUTE_TYPE = 'office:value-type';
@@ -33,16 +35,21 @@ class CellValueFormatter
const XML_ATTRIBUTE_CURRENCY = 'office:currency'; const XML_ATTRIBUTE_CURRENCY = 'office:currency';
const XML_ATTRIBUTE_C = 'text:c'; const XML_ATTRIBUTE_C = 'text:c';
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */ /** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
protected $escaper; protected $escaper;
/** /**
* * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/ */
public function __construct() public function __construct($shouldFormatDates)
{ {
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = new \Box\Spout\Common\Escaper\ODS(); $this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
} }
/** /**
@@ -98,6 +105,8 @@ class CellValueFormatter
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C); $spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1; $numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
$currentPValue .= str_repeat(' ', $numSpaces); $currentPValue .= str_repeat(' ', $numSpaces);
} else if ($childNode->nodeName === self::XML_NODE_A || $childNode->nodeName === self::XML_NODE_SPAN) {
$currentPValue .= $childNode->nodeValue;
} }
} }
@@ -119,6 +128,7 @@ class CellValueFormatter
{ {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE); $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
$nodeIntValue = intval($nodeValue); $nodeIntValue = intval($nodeValue);
// The "==" is intentionally not a "===" because only the value matters, not the type
$cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue); $cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
return $cellValue; return $cellValue;
} }
@@ -141,15 +151,27 @@ class CellValueFormatter
* Returns the cell Date value from the given node. * Returns the cell Date value from the given node.
* *
* @param \DOMNode $node * @param \DOMNode $node
* @return \DateTime|null The value associated with the cell or NULL if invalid date value * @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/ */
protected function formatDateCellValue($node) protected function formatDateCellValue($node)
{ {
try { // The XML node looks like this:
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE); // <table:table-cell calcext:value-type="date" office:date-value="2016-05-19T16:39:00" office:value-type="date">
return new \DateTime($nodeValue); // <text:p>05/19/16 04:39 PM</text:p>
} catch (\Exception $e) { // </table:table-cell>
return null;
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "date-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
return new \DateTime($nodeValue);
} catch (\Exception $e) {
return null;
}
} }
} }
@@ -157,15 +179,27 @@ class CellValueFormatter
* Returns the cell Time value from the given node. * Returns the cell Time value from the given node.
* *
* @param \DOMNode $node * @param \DOMNode $node
* @return \DateInterval|null The value associated with the cell or NULL if invalid time value * @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
*/ */
protected function formatTimeCellValue($node) protected function formatTimeCellValue($node)
{ {
try { // The XML node looks like this:
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE); // <table:table-cell calcext:value-type="time" office:time-value="PT13H24M00S" office:value-type="time">
return new \DateInterval($nodeValue); // <text:p>01:24:00 PM</text:p>
} catch (\Exception $e) { // </table:table-cell>
return null;
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "time-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
return new \DateInterval($nodeValue);
} catch (\Exception $e) {
return null;
}
} }
} }
+11 -1
View File
@@ -19,6 +19,16 @@ class Reader extends AbstractReader
/** @var SheetIterator To iterator over the ODS sheets */ /** @var SheetIterator To iterator over the ODS sheets */
protected $sheetIterator; protected $sheetIterator;
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return false;
}
/** /**
* Opens the file at the given file path to make it ready to be read. * Opens the file at the given file path to make it ready to be read.
* *
@@ -32,7 +42,7 @@ class Reader extends AbstractReader
$this->zip = new \ZipArchive(); $this->zip = new \ZipArchive();
if ($this->zip->open($filePath) === true) { if ($this->zip->open($filePath) === true) {
$this->sheetIterator = new SheetIterator($filePath); $this->sheetIterator = new SheetIterator($filePath, $this->shouldFormatDates);
} else { } else {
throw new IOException("Could not open $filePath for reading."); throw new IOException("Could not open $filePath for reading.");
} }
+21 -7
View File
@@ -20,6 +20,7 @@ class RowIterator implements IteratorInterface
const XML_NODE_TABLE = 'table:table'; const XML_NODE_TABLE = 'table:table';
const XML_NODE_ROW = 'table:table-row'; const XML_NODE_ROW = 'table:table-row';
const XML_NODE_CELL = 'table:table-cell'; const XML_NODE_CELL = 'table:table-cell';
const MAX_COLUMNS_EXCEL = 16384;
/** Definition of XML attribute used to parse data */ /** Definition of XML attribute used to parse data */
const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated'; const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated';
@@ -44,11 +45,12 @@ class RowIterator implements IteratorInterface
/** /**
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element * @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/ */
public function __construct($xmlReader) public function __construct($xmlReader, $shouldFormatDates)
{ {
$this->xmlReader = $xmlReader; $this->xmlReader = $xmlReader;
$this->cellValueFormatter = new CellValueFormatter(); $this->cellValueFormatter = new CellValueFormatter($shouldFormatDates);
} }
/** /**
@@ -127,22 +129,23 @@ class RowIterator implements IteratorInterface
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
// End of the row description // End of the row description
$isEmptyRow = ($numCellsRead <= 1 && empty($cellValue)); $isEmptyRow = ($numCellsRead <= 1 && $this->isEmptyCellValue($cellValue));
if ($isEmptyRow) { if ($isEmptyRow) {
// skip empty rows // skip empty rows
$this->next(); $this->next();
return; return;
} }
// Only add value if the last read cell is not empty or does not need to repeat cell values. // 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 $rowData.
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>" // 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). // 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. // In Excel, the number of supported columns is 16384, but we don't want to returns rows with
if (!empty($cellValue) || $numColumnsRepeated === 1) { // always 16384 cells.
if ((count($rowData) + $numColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
for ($i = 0; $i < $numColumnsRepeated; $i++) { for ($i = 0; $i < $numColumnsRepeated; $i++) {
$rowData[] = $cellValue; $rowData[] = $cellValue;
} }
$this->numReadRows++; $this->numReadRows++;
} }
break; break;
@@ -181,6 +184,17 @@ class RowIterator implements IteratorInterface
return $this->cellValueFormatter->extractAndFormatNodeValue($node); return $this->cellValueFormatter->extractAndFormatNodeValue($node);
} }
/**
* empty() replacement that honours 0 as a valid value
*
* @param string|int|float|bool|\DateTime|\DateInterval|null $value The cell value
* @return bool
*/
protected function isEmptyCellValue($value)
{
return (!isset($value) || trim($value) === '');
}
/** /**
* Return the current element, from the buffer. * Return the current element, from the buffer.
* @link http://php.net/manual/en/iterator.current.php * @link http://php.net/manual/en/iterator.current.php
+3 -2
View File
@@ -27,12 +27,13 @@ class Sheet implements SheetInterface
/** /**
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element * @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $sheetName Name of the sheet * @param string $sheetName Name of the sheet
*/ */
public function __construct($xmlReader, $sheetIndex, $sheetName) public function __construct($xmlReader, $shouldFormatDates, $sheetIndex, $sheetName)
{ {
$this->rowIterator = new RowIterator($xmlReader); $this->rowIterator = new RowIterator($xmlReader, $shouldFormatDates);
$this->index = $sheetIndex; $this->index = $sheetIndex;
$this->name = $sheetName; $this->name = $sheetName;
} }
+12 -5
View File
@@ -15,6 +15,8 @@ use Box\Spout\Reader\Wrapper\XMLReader;
*/ */
class SheetIterator implements IteratorInterface class SheetIterator implements IteratorInterface
{ {
const CONTENT_XML_FILE_PATH = 'content.xml';
/** Definition of XML nodes name and attribute used to parse sheet data */ /** Definition of XML nodes name and attribute used to parse sheet data */
const XML_NODE_TABLE = 'table:table'; const XML_NODE_TABLE = 'table:table';
const XML_ATTRIBUTE_TABLE_NAME = 'table:name'; const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
@@ -22,6 +24,9 @@ class SheetIterator implements IteratorInterface
/** @var string $filePath Path of the file to be read */ /** @var string $filePath Path of the file to be read */
protected $filePath; protected $filePath;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var XMLReader The XMLReader object that will help read sheet's XML data */ /** @var XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader; protected $xmlReader;
@@ -36,15 +41,17 @@ class SheetIterator implements IteratorInterface
/** /**
* @param string $filePath Path of the file to be read * @param string $filePath Path of the file to be read
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/ */
public function __construct($filePath) public function __construct($filePath, $shouldFormatDates)
{ {
$this->filePath = $filePath; $this->filePath = $filePath;
$this->shouldFormatDates = $shouldFormatDates;
$this->xmlReader = new XMLReader(); $this->xmlReader = new XMLReader();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = new \Box\Spout\Common\Escaper\ODS(); $this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
} }
/** /**
@@ -58,8 +65,8 @@ class SheetIterator implements IteratorInterface
{ {
$this->xmlReader->close(); $this->xmlReader->close();
$contentXmlFilePath = $this->filePath . '#content.xml'; if ($this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH) === false) {
if ($this->xmlReader->open('zip://' . $contentXmlFilePath) === false) { $contentXmlFilePath = $this->filePath . '#' . self::CONTENT_XML_FILE_PATH;
throw new IOException("Could not open \"{$contentXmlFilePath}\"."); throw new IOException("Could not open \"{$contentXmlFilePath}\".");
} }
@@ -109,7 +116,7 @@ class SheetIterator implements IteratorInterface
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME); $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
$sheetName = $this->escaper->unescape($escapedSheetName); $sheetName = $this->escaper->unescape($escapedSheetName);
return new Sheet($this->xmlReader, $sheetName, $this->currentSheetIndex); return new Sheet($this->xmlReader, $this->shouldFormatDates, $sheetName, $this->currentSheetIndex);
} }
/** /**
+39 -44
View File
@@ -14,66 +14,44 @@ class XMLReader extends \XMLReader
{ {
use XMLInternalErrorsHelper; use XMLInternalErrorsHelper;
const ZIP_WRAPPER = 'zip://';
/** /**
* Set the URI containing the XML to parse * Opens the XML Reader to read a file located inside a ZIP file.
* @see \XMLReader::open
* *
* @param string $URI URI pointing to the document * @param string $zipFilePath Path to the ZIP file
* @param string|null|void $encoding The document encoding * @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
* @param int $options A bitmask of the LIBXML_* constants
* @return bool TRUE on success or FALSE on failure * @return bool TRUE on success or FALSE on failure
*/ */
public function open($URI, $encoding = null, $options = 0) public function openFileInZip($zipFilePath, $fileInsideZipPath)
{ {
$wasOpenSuccessful = false; $wasOpenSuccessful = false;
$realPathURI = $this->convertURIToUseRealPath($URI); $realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
// HHVM does not check if file exists within zip file // HHVM does not check if file exists within zip file
// @link https://github.com/facebook/hhvm/issues/5779 // @link https://github.com/facebook/hhvm/issues/5779
if ($this->isRunningHHVM() && $this->isZipStream($realPathURI)) { if ($this->isRunningHHVM()) {
if ($this->fileExistsWithinZip($realPathURI)) { if ($this->fileExistsWithinZip($realPathURI)) {
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET); $wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
} }
} else { } else {
$wasOpenSuccessful = parent::open($realPathURI, $encoding, $options|LIBXML_NONET); $wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
} }
return $wasOpenSuccessful; return $wasOpenSuccessful;
} }
/** /**
* Updates the given URI to use a real path. * Returns the real path for the given path components.
* This is to avoid issues on some Windows setup. * This is useful to avoid issues on some Windows setup.
* *
* @param string $URI URI * @param string $zipFilePath Path to the ZIP file
* @return string The URI using a real path * @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
* @return string The real path URI
*/ */
protected function convertURIToUseRealPath($URI) public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
{ {
$realPathURI = $URI; return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
if ($this->isZipStream($URI)) {
if (preg_match('/zip:\/\/(.*)#(.*)/', $URI, $matches)) {
$documentPath = $matches[1];
$documentInsideZipPath = $matches[2];
$realPathURI = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
}
} else {
$realPathURI = realpath($URI);
}
return $realPathURI;
}
/**
* Returns whether the given URI is a zip stream.
*
* @param string $URI URI pointing to a document
* @return bool TRUE if URI is a zip stream, FALSE otherwise
*/
protected function isZipStream($URI)
{
return (strpos($URI, 'zip://') === 0);
} }
/** /**
@@ -138,9 +116,10 @@ class XMLReader extends \XMLReader
*/ */
public function readUntilNodeFound($nodeName) public function readUntilNodeFound($nodeName)
{ {
while (($wasReadSuccessful = $this->read()) && ($this->nodeType !== \XMLReader::ELEMENT || $this->name !== $nodeName)) { do {
// do nothing $wasReadSuccessful = $this->read();
} $isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
} while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
return $wasReadSuccessful; return $wasReadSuccessful;
} }
@@ -170,7 +149,7 @@ class XMLReader extends \XMLReader
*/ */
public function isPositionedOnStartingNode($nodeName) public function isPositionedOnStartingNode($nodeName)
{ {
return ($this->nodeType === XMLReader::ELEMENT && $this->name === $nodeName); return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
} }
/** /**
@@ -179,6 +158,22 @@ class XMLReader extends \XMLReader
*/ */
public function isPositionedOnEndingNode($nodeName) public function isPositionedOnEndingNode($nodeName)
{ {
return ($this->nodeType === XMLReader::END_ELEMENT && $this->name === $nodeName); return $this->isPositionedOnNode($nodeName, XMLReader::END_ELEMENT);
}
/**
* @param string $nodeName
* @param int $nodeType
* @return bool Whether the XML Reader is currently positioned on the node with given name and type
*/
private function isPositionedOnNode($nodeName, $nodeType)
{
// In some cases, the node has a prefix (for instance, "<sheet>" can also be "<x:sheet>").
// So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
// @see https://github.com/box/spout/issues/233
$hasPrefix = (strpos($nodeName, ':') !== false);
$currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
} }
} }
@@ -29,6 +29,8 @@ class CellValueFormatter
/** Constants used for date formatting */ /** Constants used for date formatting */
const NUM_SECONDS_IN_ONE_DAY = 86400; 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... * 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 */ /** @var StyleHelper Helper to work with styles */
protected $styleHelper; protected $styleHelper;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */ /** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
protected $escaper; protected $escaper;
/** /**
* @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings * @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
* @param StyleHelper $styleHelper Helper to work with styles * @param 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->sharedStringsHelper = $sharedStringsHelper;
$this->styleHelper = $styleHelper; $this->styleHelper = $styleHelper;
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = new \Box\Spout\Common\Escaper\XLSX(); $this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
} }
/** /**
@@ -118,8 +125,7 @@ class CellValueFormatter
// inline strings are formatted this way: // inline strings are formatted this way:
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c> // <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0); $tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
$escapedCellValue = trim($tNode->nodeValue); $cellValue = $this->escaper->unescape($tNode->nodeValue);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue; return $cellValue;
} }
@@ -167,7 +173,7 @@ class CellValueFormatter
$shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId); $shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId);
if ($shouldFormatAsDate) { if ($shouldFormatAsDate) {
return $this->formatExcelTimestampValue(floatval($nodeValue)); return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId);
} else { } else {
$nodeIntValue = intval($nodeValue); $nodeIntValue = intval($nodeValue);
return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($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. * 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 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 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 * @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 // Fix for the erroneous leap year in Excel
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) { if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
--$nodeValue; --$nodeValue;
} }
// The value 1.0 represents 1900-01-01. Numbers below 1.0 are not valid Excel dates. if ($nodeValue >= 1) {
if ($nodeValue < 1.0) { // 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; 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) {
$styleNumberFormat = $this->styleHelper->getNumberFormat($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormat);
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 // Do not use any unix timestamps for calculation to prevent
// issues with numbers exceeding 2^31. // issues with numbers exceeding 2^31.
$secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY; $secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY;
$secondsRemainder = round($secondsRemainder, 0); $secondsRemainder = round($secondsRemainder, 0);
try { try {
$cellValue = \DateTime::createFromFormat('|Y-m-d', '1899-12-31'); $dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
$cellValue->modify('+' . intval($nodeValue) . 'days'); $dateObj->modify('+' . intval($nodeValue) . 'days');
$cellValue->modify('+' . $secondsRemainder . 'seconds'); $dateObj->modify('+' . $secondsRemainder . 'seconds');
return $cellValue; if ($this->shouldFormatDates) {
$styleNumberFormat = $this->styleHelper->getNumberFormat($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormat);
return $dateObj->format($phpDateFormat);
} else {
return $dateObj;
}
} catch (\Exception $e) { } catch (\Exception $e) {
return null; return null;
} }
@@ -0,0 +1,122 @@
<?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
$dateFormat = preg_replace('/^(\[\$[^\]]+?\])/i', '', $excelDateFormat);
// Double quotes are used to escape characters that must not be interpreted.
// For instance, ["Day " dd] should result in "Day 13" and we should not try to interpret "D", "a", "y"
// By exploding the format string using double quote as a delimiter, we can get all parts
// that must be transformed (even indexes) and all parts that must not be (odd indexes).
$dateFormatParts = explode('"', $dateFormat);
foreach ($dateFormatParts as $partIndex => $dateFormatPart) {
// do not look at odd indexes
if ($partIndex % 2 === 1) {
continue;
}
// Make sure all characters are lowercase, as the mapping table is using lowercase characters
$transformedPart = strtolower($dateFormatPart);
// Remove escapes related to non-format characters
$transformedPart = str_replace('\\', '', $transformedPart);
// Apply general transformation first...
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_GENERAL]);
// ... then apply hour transformation, for 12-hour or 24-hour format
if (self::has12HourFormatMarker($dateFormatPart)) {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_12]);
} else {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_24]);
}
// overwrite the parts array with the new transformed part
$dateFormatParts[$partIndex] = $transformedPart;
}
// Merge all transformed parts back together
$phpDateFormat = implode('"', $dateFormatParts);
// Finally, to have the date format compatible with the DateTime::format() function, we need to escape
// all characters that are inside double quotes (and double quotes must be removed).
// For instance, ["Day " dd] should become [\D\a\y\ dd]
$phpDateFormat = preg_replace_callback('/"(.+?)"/', function($matches) {
$stringToEscape = $matches[1];
$letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY);
return '\\' . implode('\\', $letters);
}, $phpDateFormat);
return $phpDateFormat;
}
/**
* @param string $excelDateFormat Date format as defined by Excel
* @return bool Whether the given date format has the 12-hour format marker
*/
private static function has12HourFormatMarker($excelDateFormat)
{
return (stripos($excelDateFormat, 'am/pm') !== false);
}
}
@@ -78,7 +78,7 @@ class CachingStrategyFactory
* Returns the best caching strategy, given the number of unique shared strings * Returns the best caching strategy, given the number of unique shared strings
* and the amount of memory available. * and the amount of memory available.
* *
* @param int $sharedStringsUniqueCount Number of unique shared strings * @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored * @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
* @return CachingStrategyInterface The best caching strategy * @return CachingStrategyInterface The best caching strategy
*/ */
@@ -95,11 +95,16 @@ class CachingStrategyFactory
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings * Returns whether it is safe to use in-memory caching, given the number of unique shared strings
* and the amount of memory available. * and the amount of memory available.
* *
* @param int $sharedStringsUniqueCount Number of unique shared strings * @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @return bool * @return bool
*/ */
protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount) protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)
{ {
// if the number of shared strings in unknown, do not use "in memory" strategy
if ($sharedStringsUniqueCount === null) {
return false;
}
$memoryAvailable = $this->getMemoryLimitInKB(); $memoryAvailable = $this->getMemoryLimitInKB();
if ($memoryAvailable === -1) { if ($memoryAvailable === -1) {
@@ -80,7 +80,7 @@ class SharedStringsHelper
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
$sharedStringIndex = 0; $sharedStringIndex = 0;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = new \Box\Spout\Common\Escaper\XLSX(); $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$sharedStringsFilePath = $this->getSharedStringsFilePath(); $sharedStringsFilePath = $this->getSharedStringsFilePath();
if ($xmlReader->open($sharedStringsFilePath) === false) { if ($xmlReader->open($sharedStringsFilePath) === false) {
@@ -100,11 +100,16 @@ class SharedStringsHelper
// removes nodes that should not be read, like the pronunciation of the Kanji characters // removes nodes that should not be read, like the pronunciation of the Kanji characters
$cleanNode = $this->removeSuperfluousTextNodes($node); $cleanNode = $this->removeSuperfluousTextNodes($node);
// find all text nodes 't'; there can be multiple if the cell contains formatting // find all text nodes "t"; there can be multiple if the cell contains formatting
$textNodes = $cleanNode->xpath('//ns:t'); $textNodes = $cleanNode->xpath('//ns:t');
$textValue = ''; $textValue = '';
foreach ($textNodes as $textNode) { foreach ($textNodes as $nodeIndex => $textNode) {
if ($nodeIndex !== 0) {
// add a space between each "t" node
$textValue .= ' ';
}
if ($this->shouldPreserveWhitespace($textNode)) { if ($this->shouldPreserveWhitespace($textNode)) {
$textValue .= $textNode->__toString(); $textValue .= $textNode->__toString();
} else { } else {
@@ -142,7 +147,7 @@ class SharedStringsHelper
* Returns the shared strings unique count, as specified in <sst> tag. * Returns the shared strings unique count, as specified in <sst> tag.
* *
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance * @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
* @return int Number of unique shared strings in the sharedStrings.xml file * @return int|null Number of unique shared strings in the sharedStrings.xml file
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read * @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
*/ */
protected function getSharedStringsUniqueCount($xmlReader) protected function getSharedStringsUniqueCount($xmlReader)
@@ -154,13 +159,21 @@ class SharedStringsHelper
$xmlReader->read(); $xmlReader->read();
} }
return intval($xmlReader->getAttribute('uniqueCount')); $uniqueCount = $xmlReader->getAttribute('uniqueCount');
// some software do not add the "uniqueCount" attribute but only use the "count" one
// @see https://github.com/box/spout/issues/254
if ($uniqueCount === null) {
$uniqueCount = $xmlReader->getAttribute('count');
}
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
} }
/** /**
* Returns the best shared strings caching strategy. * Returns the best shared strings caching strategy.
* *
* @param int $sharedStringsUniqueCount * @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @return CachingStrategyInterface * @return CachingStrategyInterface
*/ */
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount) protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
@@ -200,6 +213,8 @@ class SharedStringsHelper
{ {
$tagsToRemove = [ $tagsToRemove = [
'rPh', // Pronunciation of the text 'rPh', // Pronunciation of the text
'pPr', // Paragraph Properties / Previous Paragraph Properties
'rPr', // Run Properties for the Paragraph Mark / Previous Run Properties for the Paragraph Mark
]; ];
foreach ($tagsToRemove as $tagToRemove) { foreach ($tagsToRemove as $tagToRemove) {
+59 -107
View File
@@ -2,7 +2,7 @@
namespace Box\Spout\Reader\XLSX\Helper; namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Reader\Wrapper\SimpleXMLElement; use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Sheet; use Box\Spout\Reader\XLSX\Sheet;
/** /**
@@ -14,18 +14,9 @@ use Box\Spout\Reader\XLSX\Sheet;
class SheetHelper class SheetHelper
{ {
/** Paths of XML files relative to the XLSX file root */ /** Paths of XML files relative to the XLSX file root */
const CONTENT_TYPES_XML_FILE_PATH = '[Content_Types].xml';
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels'; const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml'; const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
/** Namespaces for the XML files */
const MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML = 'http://schemas.openxmlformats.org/package/2006/content-types';
const MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
const MAIN_NAMESPACE_FOR_WORKBOOK_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
/** Value of the Override attribute used in [Content_Types].xml to define sheets */
const OVERRIDE_CONTENT_TYPES_ATTRIBUTE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml';
/** @var string Path of the XLSX file being read */ /** @var string Path of the XLSX file being read */
protected $filePath; protected $filePath;
@@ -35,22 +26,21 @@ class SheetHelper
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper; protected $globalFunctionsHelper;
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml.rels file */ /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $workbookXMLRelsAsXMLElement; protected $shouldFormatDates;
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml file */
protected $workbookXMLAsXMLElement;
/** /**
* @param string $filePath Path of the XLSX file being read * @param string $filePath Path of the XLSX file being read
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/ */
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper) public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates)
{ {
$this->filePath = $filePath; $this->filePath = $filePath;
$this->sharedStringsHelper = $sharedStringsHelper; $this->sharedStringsHelper = $sharedStringsHelper;
$this->globalFunctionsHelper = $globalFunctionsHelper; $this->globalFunctionsHelper = $globalFunctionsHelper;
$this->shouldFormatDates = $shouldFormatDates;
} }
/** /**
@@ -62,119 +52,81 @@ class SheetHelper
public function getSheets() public function getSheets()
{ {
$sheets = []; $sheets = [];
$sheetIndex = 0;
$contentTypesAsXMLElement = $this->getFileAsXMLElementWithNamespace( $xmlReader = new XMLReader();
self::CONTENT_TYPES_XML_FILE_PATH, if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML while ($xmlReader->read()) {
); if ($xmlReader->isPositionedOnStartingNode('sheet')) {
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex);
$sheetIndex++;
} else if ($xmlReader->isPositionedOnEndingNode('sheets')) {
// stop reading once all sheets have been read
break;
}
}
// find all nodes defining a sheet $xmlReader->close();
$sheetNodes = $contentTypesAsXMLElement->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]');
$numSheetNodes = count($sheetNodes);
for ($i = 0; $i < $numSheetNodes; $i++) {
$sheetNode = $sheetNodes[$i];
$sheetDataXMLFilePath = $sheetNode->getAttribute('PartName');
$sheets[] = $this->getSheetFromXML($sheetDataXMLFilePath);
} }
// 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());
});
return $sheets; return $sheets;
} }
/** /**
* Returns an instance of a sheet, given the path of its data XML file. * Returns an instance of a sheet, given the XML node describing the sheet - from "workbook.xml".
* We first look at "xl/_rels/workbook.xml.rels" to find the relationship ID of the sheet. * We can find the XML file path describing the sheet inside "workbook.xml.res", by mapping with the sheet ID
* Then we look at "xl/worbook.xml" to find the sheet entry associated to the found ID. * ("r:id" in "workbook.xml", "Id" in "workbook.xml.res").
* The entry contains the ID and name of the sheet.
* *
* @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 * @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" $sheetId = $xmlReaderOnSheetNode->getAttribute('r:id');
// In workbook.xml.rels, it is only "worksheets/sheet1.xml" $escapedSheetName = $xmlReaderOnSheetNode->getAttribute('name');
$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;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = new \Box\Spout\Common\Escaper\XLSX(); $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$sheetName = $escaper->unescape($escapedSheetName); $sheetName = $escaper->unescape($escapedSheetName);
return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $sheetIndexZeroBased, $sheetName); $sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $this->shouldFormatDates, $sheetIndexZeroBased, $sheetName);
} }
/** /**
* Returns a representation of the workbook.xml.rels file, ready to be parsed. * @param string $sheetId The sheet ID, as defined in "workbook.xml"
* The returned value is cached. * @return string The XML file path describing the sheet inside "workbook.xml.res", for the given sheet ID
*
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
*/ */
protected function getWorkbookXMLRelsAsXMLElement() protected function getSheetDataXMLFilePathForSheetId($sheetId)
{ {
if (!$this->workbookXMLRelsAsXMLElement) { $sheetDataXMLFilePath = '';
$this->workbookXMLRelsAsXMLElement = $this->getFileAsXMLElementWithNamespace(
self::WORKBOOK_XML_RELS_FILE_PATH, // find the file path of the sheet, by looking at the "workbook.xml.res" file
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS $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;
}
}
}
}
$xmlReader->close();
} }
return $this->workbookXMLRelsAsXMLElement; return $sheetDataXMLFilePath;
}
/**
* 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;
} }
} }
+58 -5
View File
@@ -30,6 +30,25 @@ class StyleHelper
/** By convention, default style ID is 0 */ /** By convention, default style ID is 0 */
const DEFAULT_STYLE_ID = 0; const DEFAULT_STYLE_ID = 0;
/**
* @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 */ /** @var string Path of the XLSX file being read */
protected $filePath; protected $filePath;
@@ -57,10 +76,9 @@ class StyleHelper
$this->customNumberFormats = []; $this->customNumberFormats = [];
$this->stylesAttributes = []; $this->stylesAttributes = [];
$stylesXmlFilePath = $this->filePath .'#' . self::STYLES_XML_FILE_PATH;
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
if ($xmlReader->open('zip://' . $stylesXmlFilePath)) { if ($xmlReader->openFileInZip($this->filePath, self::STYLES_XML_FILE_PATH)) {
while ($xmlReader->read()) { while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) { if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
$numFmtsNode = new SimpleXMLElement($xmlReader->readOuterXml()); $numFmtsNode = new SimpleXMLElement($xmlReader->readOuterXml());
@@ -171,18 +189,30 @@ class StyleHelper
protected function doesNumFmtIdIndicateDate($numFmtId) protected function doesNumFmtIdIndicateDate($numFmtId)
{ {
return ( return (
$this->isNumFmtIdBuiltInDateFormat($numFmtId) || !$this->doesNumFmtIdIndicateGeneralFormat($numFmtId) &&
$this->isNumFmtIdCustomDateFormat($numFmtId) (
$this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
$this->isNumFmtIdCustomDateFormat($numFmtId)
)
); );
} }
/**
* @param int $numFmtId
* @return bool Whether the number format ID indicates the "General" format (0 by convention)
*/
protected function doesNumFmtIdIndicateGeneralFormat($numFmtId)
{
return ($numFmtId === 0);
}
/** /**
* @param int $numFmtId * @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 timestamp
*/ */
protected function isNumFmtIdBuiltInDateFormat($numFmtId) protected function isNumFmtIdBuiltInDateFormat($numFmtId)
{ {
$builtInDateFormatIds = [14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]; $builtInDateFormatIds = array_keys(self::$builtinNumFmtIdToNumFormatMapping);
return in_array($numFmtId, $builtInDateFormatIds); return in_array($numFmtId, $builtInDateFormatIds);
} }
@@ -223,4 +253,27 @@ class StyleHelper
return $hasFoundDateFormatCharacter; 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 associated with the given style
*/
public function getNumberFormat($styleId)
{
$stylesAttributes = $this->getStylesAttributes();
$styleAttributes = $stylesAttributes[$styleId];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
$numberFormat = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
} else {
$customNumberFormats = $this->getCustomNumberFormats();
$numberFormat = $customNumberFormats[$numFmtId];
}
return $numberFormat;
}
} }
+11 -1
View File
@@ -37,6 +37,16 @@ class Reader extends AbstractReader
return $this; return $this;
} }
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return false;
}
/** /**
* Opens the file at the given file path to make it ready to be read. * Opens the file at the given file path to make it ready to be read.
* It also parses the sharedStrings.xml file to get all the shared strings available in memory * It also parses the sharedStrings.xml file to get all the shared strings available in memory
@@ -59,7 +69,7 @@ class Reader extends AbstractReader
$this->sharedStringsHelper->extractSharedStrings(); $this->sharedStringsHelper->extractSharedStrings();
} }
$this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper); $this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper, $this->shouldFormatDates);
} else { } else {
throw new IOException("Could not open $filePath for reading."); throw new IOException("Could not open $filePath for reading.");
} }
+27 -4
View File
@@ -55,12 +55,16 @@ class RowIterator implements IteratorInterface
/** @var int The number of columns the sheet has (0 meaning undefined) */ /** @var int The number of columns the sheet has (0 meaning undefined) */
protected $numColumns = 0; protected $numColumns = 0;
/** @var int Last column index processed (zero-based) */
protected $lastColumnIndexProcessed = -1;
/** /**
* @param string $filePath Path of the XLSX file being read * @param string $filePath Path of the XLSX file being read
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings * @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/ */
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper) public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates)
{ {
$this->filePath = $filePath; $this->filePath = $filePath;
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath); $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
@@ -68,7 +72,7 @@ class RowIterator implements IteratorInterface
$this->xmlReader = new XMLReader(); $this->xmlReader = new XMLReader();
$this->styleHelper = new StyleHelper($filePath); $this->styleHelper = new StyleHelper($filePath);
$this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper); $this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $shouldFormatDates);
} }
/** /**
@@ -143,6 +147,9 @@ class RowIterator implements IteratorInterface
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) { } else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_ROW)) {
// Start of the row description // Start of the row description
// Reset index of the last processed column
$this->lastColumnIndexProcessed = -1;
// Read spans info if present // Read spans info if present
$numberOfColumnsForRow = $this->numColumns; $numberOfColumnsForRow = $this->numColumns;
$spans = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance $spans = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
@@ -154,12 +161,13 @@ class RowIterator implements IteratorInterface
} else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) { } else if ($this->xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL)) {
// Start of a cell description // Start of a cell description
$currentCellIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX); $currentColumnIndex = $this->getCellIndex($this->xmlReader);
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
$node = $this->xmlReader->expand(); $node = $this->xmlReader->expand();
$rowData[$currentColumnIndex] = $this->getCellValue($node); $rowData[$currentColumnIndex] = $this->getCellValue($node);
$this->lastColumnIndexProcessed = $currentColumnIndex;
} else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) { } else if ($this->xmlReader->isPositionedOnEndingNode(self::XML_NODE_ROW)) {
// End of the row description // End of the row description
// If needed, we fill the empty cells // If needed, we fill the empty cells
@@ -181,6 +189,21 @@ class RowIterator implements IteratorInterface
$this->rowDataBuffer = $rowData; $this->rowDataBuffer = $rowData;
} }
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<c>" tag
* @return int
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
protected function getCellIndex($xmlReader)
{
// Get "r" attribute if present (from something like <c r="A1"...>
$currentCellIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
return ($currentCellIndex !== null) ?
CellHelper::getColumnIndexFromCellIndex($currentCellIndex) :
$this->lastColumnIndexProcessed + 1;
}
/** /**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node. * Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
* *
+3 -2
View File
@@ -25,12 +25,13 @@ class Sheet implements SheetInterface
* @param string $filePath Path of the XLSX file being read * @param string $filePath Path of the XLSX file being read
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @param Helper\SharedStringsHelper Helper to work with shared strings * @param Helper\SharedStringsHelper Helper to work with shared strings
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $sheetName Name of the sheet * @param string $sheetName Name of the sheet
*/ */
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $sheetIndex, $sheetName) public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates, $sheetIndex, $sheetName)
{ {
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper); $this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates);
$this->index = $sheetIndex; $this->index = $sheetIndex;
$this->name = $sheetName; $this->name = $sheetName;
} }
+3 -2
View File
@@ -24,12 +24,13 @@ class SheetIterator implements IteratorInterface
* @param string $filePath Path of the file to be read * @param string $filePath Path of the file to be read
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/ */
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper) public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates)
{ {
// Fetch all available sheets // Fetch all available sheets
$sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper); $sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates);
$this->sheets = $sheetHelper->getSheets(); $this->sheets = $sheetHelper->getSheets();
if (count($this->sheets) === 0) { if (count($this->sheets) === 0) {
+11 -1
View File
@@ -121,6 +121,10 @@ abstract class AbstractWriter implements WriterInterface
$this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w'); $this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
$this->throwIfFilePointerIsNotAvailable(); $this->throwIfFilePointerIsNotAvailable();
// Clear any previous output (otherwise the generated file will be corrupted)
// @see https://github.com/box/spout/issues/241
$this->globalFunctionsHelper->ob_end_clean();
// Set headers // Set headers
$this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType); $this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType);
$this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"'); $this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"');
@@ -238,7 +242,8 @@ abstract class AbstractWriter implements WriterInterface
public function addRows(array $dataRows) public function addRows(array $dataRows)
{ {
if (!empty($dataRows)) { if (!empty($dataRows)) {
if (!is_array($dataRows[0])) { $firstRow = reset($dataRows);
if (!is_array($firstRow)) {
throw new InvalidArgumentException('The input should be an array of arrays'); throw new InvalidArgumentException('The input should be an array of arrays');
} }
@@ -319,6 +324,11 @@ abstract class AbstractWriter implements WriterInterface
public function close() public function close()
{ {
$this->closeWriter(); $this->closeWriter();
if (is_resource($this->filePointer)) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
$this->isWriterOpened = false; $this->isWriterOpened = false;
} }
} }
+19 -6
View File
@@ -29,6 +29,9 @@ class Writer extends AbstractWriter
/** @var int */ /** @var int */
protected $lastWrittenRowIndex = 0; protected $lastWrittenRowIndex = 0;
/** @var bool */
protected $shouldAddBOM = true;
/** /**
* Sets the field delimiter for the CSV * Sets the field delimiter for the CSV
* *
@@ -55,6 +58,18 @@ class Writer extends AbstractWriter
return $this; return $this;
} }
/**
* Set if a BOM has to be added to the file
*
* @param bool $shouldAddBOM
* @return Writer
*/
public function setShouldAddBOM($shouldAddBOM)
{
$this->shouldAddBOM = (bool) $shouldAddBOM;
return $this;
}
/** /**
* Opens the CSV streamer and makes it ready to accept data. * Opens the CSV streamer and makes it ready to accept data.
* *
@@ -62,8 +77,10 @@ class Writer extends AbstractWriter
*/ */
protected function openWriter() protected function openWriter()
{ {
// Adds UTF-8 BOM for Unicode compatibility if ($this->shouldAddBOM) {
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8); // Adds UTF-8 BOM for Unicode compatibility
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
}
} }
/** /**
@@ -96,10 +113,6 @@ class Writer extends AbstractWriter
*/ */
protected function closeWriter() protected function closeWriter()
{ {
if ($this->filePointer) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
$this->lastWrittenRowIndex = 0; $this->lastWrittenRowIndex = 0;
} }
} }
@@ -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;
}
}
+9 -1
View File
@@ -3,6 +3,7 @@
namespace Box\Spout\Writer\ODS\Helper; namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper; use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
use Box\Spout\Writer\Style\BorderPart;
/** /**
* Class StyleHelper * Class StyleHelper
@@ -256,9 +257,16 @@ EOD;
$content .= '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>'; $content .= '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
} }
if ($style->shouldApplyBorder()) {
$borderProperty = '<style:table-cell-properties %s />';
$borders = array_map(function (BorderPart $borderPart) {
return BorderHelper::serializeBorderPart($borderPart);
}, $style->getBorder()->getParts());
$content .= sprintf($borderProperty, implode(' ', $borders));
}
$content .= '</style:style>'; $content .= '</style:style>';
return $content; return $content;
} }
} }
+13 -3
View File
@@ -47,7 +47,7 @@ class Worksheet implements WorksheetInterface
{ {
$this->externalSheet = $externalSheet; $this->externalSheet = $externalSheet;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = new \Box\Spout\Common\Escaper\ODS(); $this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml'; $this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
$this->stringHelper = new StringHelper(); $this->stringHelper = new StringHelper();
@@ -134,6 +134,10 @@ class Worksheet implements WorksheetInterface
*/ */
public function addRow($dataRow, $style) public function addRow($dataRow, $style)
{ {
// $dataRow can be an associative array. We need to transform
// it into a regular array, as we'll use the numeric indexes.
$dataRowWithNumericIndexes = array_values($dataRow);
$styleIndex = ($style->getId() + 1); // 1-based $styleIndex = ($style->getId() + 1); // 1-based
$cellsCount = count($dataRow); $cellsCount = count($dataRow);
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount); $this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
@@ -144,10 +148,12 @@ class Worksheet implements WorksheetInterface
$nextCellIndex = 1; $nextCellIndex = 1;
for ($i = 0; $i < $cellsCount; $i++) { for ($i = 0; $i < $cellsCount; $i++) {
$currentCellValue = $dataRow[$currentCellIndex]; $currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
// Using isset here because it is way faster than array_key_exists... // 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); $numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
$data .= $this->getCellContent($currentCellValue, $styleIndex, $numTimesValueRepeated); $data .= $this->getCellContent($currentCellValue, $styleIndex, $numTimesValueRepeated);
@@ -218,6 +224,10 @@ class Worksheet implements WorksheetInterface
*/ */
public function close() public function close()
{ {
if (!is_resource($this->sheetFilePointer)) {
return;
}
fclose($this->sheetFilePointer); fclose($this->sheetFilePointer);
} }
} }
+67
View File
@@ -0,0 +1,67 @@
<?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);
}
/**
* @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;
}
}
+75
View File
@@ -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;
}
}
+184
View File
@@ -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;
}
}
+39
View File
@@ -61,6 +61,16 @@ class Style
/** @var bool Whether the wrap text property was set */ /** @var bool Whether the wrap text property was set */
protected $hasSetWrapText = false; protected $hasSetWrapText = false;
/**
* @var Border
*/
protected $border = null;
/**
* @var bool Whether border properties should be applied
*/
protected $shouldApplyBorder = false;
/** /**
* @return int|null * @return int|null
*/ */
@@ -79,6 +89,32 @@ class Style
return $this; return $this;
} }
/**
* @return Border
*/
public function getBorder()
{
return $this->border;
}
/**
* @param Border $border
*/
public function setBorder(Border $border)
{
$this->shouldApplyBorder = true;
$this->border = $border;
return $this;
}
/**
* @return boolean
*/
public function shouldApplyBorder()
{
return $this->shouldApplyBorder;
}
/** /**
* @return boolean * @return boolean
*/ */
@@ -302,6 +338,9 @@ class Style
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) { if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
$mergedStyle->setShouldWrapText(); $mergedStyle->setShouldWrapText();
} }
if (!$this->getBorder() && $baseStyle->shouldApplyBorder()) {
$mergedStyle->setBorder($baseStyle->getBorder());
}
return $mergedStyle; return $mergedStyle;
} }
+12
View File
@@ -121,6 +121,18 @@ class StyleBuilder
return $this; return $this;
} }
/**
* Set a border
*
* @param Border $border
* @return $this
*/
public function setBorder(Border $border)
{
$this->style->setBorder($border);
return $this;
}
/** /**
* Returns the configured style. The style is cached and can be reused. * Returns the configured style. The style is cached and can be reused.
* *
@@ -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()];
}
}
@@ -284,7 +284,7 @@ EOD;
EOD; EOD;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = new \Box\Spout\Common\Escaper\XLSX(); $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
/** @var Worksheet $worksheet */ /** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) { foreach ($worksheets as $worksheet) {
@@ -49,7 +49,7 @@ EOD;
fwrite($this->sharedStringsFilePointer, $header); fwrite($this->sharedStringsFilePointer, $header);
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX(); $this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
} }
/** /**
@@ -88,6 +88,10 @@ EOD;
*/ */
public function close() public function close()
{ {
if (!is_resource($this->sharedStringsFilePointer)) {
return;
}
fwrite($this->sharedStringsFilePointer, '</sst>'); fwrite($this->sharedStringsFilePointer, '</sst>');
// Replace the default strings count with the actual number of shared strings in the file header // Replace the default strings count with the actual number of shared strings in the file header
+28 -12
View File
@@ -100,17 +100,29 @@ EOD;
*/ */
protected function getBordersSectionContent() protected function getBordersSectionContent()
{ {
return <<<EOD $registeredStyles = $this->getRegisteredStyles();
<borders count="1"> $registeredStylesCount = count($registeredStyles);
<border>
<left/> $content = '<borders count="' . $registeredStylesCount . '">';
<right/>
<top/> /** @var \Box\Spout\Writer\Style\Style $style */
<bottom/> foreach ($registeredStyles as $style) {
<diagonal/> $border = $style->getBorder();
</border> if ($border) {
</borders> $content .= '<border>';
EOD; foreach ($border->getParts() as $part) {
/** @var $part \Box\Spout\Writer\Style\BorderPart */
$content .= BorderHelper::serializeBorderPart($part);
}
$content .= '</border>';
} else {
$content .= '<border><left/><right/><top/><bottom/><diagonal/></border>';
}
}
$content .= '</borders>';
return $content;
} }
/** /**
@@ -139,12 +151,16 @@ EOD;
$content = '<cellXfs count="' . count($registeredStyles) . '">'; $content = '<cellXfs count="' . count($registeredStyles) . '">';
foreach ($registeredStyles as $style) { foreach ($registeredStyles as $style) {
$content .= '<xf numFmtId="0" fontId="' . $style->getId() . '" fillId="0" borderId="0" xfId="0"'; $content .= '<xf numFmtId="0" fontId="' . $style->getId() . '" fillId="0" borderId="' . $style->getId() . '" xfId="0"';
if ($style->shouldApplyFont()) { if ($style->shouldApplyFont()) {
$content .= ' applyFont="1"'; $content .= ' applyFont="1"';
} }
if ($style->shouldApplyBorder()) {
$content .= ' applyBorder="1"';
}
if ($style->shouldWrapText()) { if ($style->shouldWrapText()) {
$content .= ' applyAlignment="1">'; $content .= ' applyAlignment="1">';
$content .= '<alignment wrapText="1"/>'; $content .= '<alignment wrapText="1"/>';
+6 -2
View File
@@ -56,7 +56,7 @@ EOD;
$this->shouldUseInlineStrings = $shouldUseInlineStrings; $this->shouldUseInlineStrings = $shouldUseInlineStrings;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX(); $this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml'; $this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
$this->startSheet(); $this->startSheet();
@@ -146,7 +146,7 @@ EOD;
$cellXML .= ' t="s"><v>' . $sharedStringId . '</v></c>'; $cellXML .= ' t="s"><v>' . $sharedStringId . '</v></c>';
} }
} else if (CellHelper::isBoolean($cellValue)) { } else if (CellHelper::isBoolean($cellValue)) {
$cellXML .= ' t="b"><v>' . $cellValue . '</v></c>'; $cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
} else if (CellHelper::isNumeric($cellValue)) { } else if (CellHelper::isNumeric($cellValue)) {
$cellXML .= '><v>' . $cellValue . '</v></c>'; $cellXML .= '><v>' . $cellValue . '</v></c>';
} else if (empty($cellValue)) { } else if (empty($cellValue)) {
@@ -178,6 +178,10 @@ EOD;
*/ */
public function close() public function close()
{ {
if (!is_resource($this->sheetFilePointer)) {
return;
}
fwrite($this->sheetFilePointer, '</sheetData>'); fwrite($this->sheetFilePointer, '</sheetData>');
fwrite($this->sheetFilePointer, '</worksheet>'); fwrite($this->sheetFilePointer, '</worksheet>');
fclose($this->sheetFilePointer); fclose($this->sheetFilePointer);
+2 -2
View File
@@ -35,7 +35,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
public function testEscape($stringToEscape, $expectedEscapedString) public function testEscape($stringToEscape, $expectedEscapedString)
{ {
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = new \Box\Spout\Common\Escaper\XLSX(); $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$escapedString = $escaper->escape($stringToEscape); $escapedString = $escaper->escape($stringToEscape);
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string'); $this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
@@ -67,7 +67,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
public function testUnescape($stringToUnescape, $expectedUnescapedString) public function testUnescape($stringToUnescape, $expectedUnescapedString)
{ {
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = new \Box\Spout\Common\Escaper\XLSX(); $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$unescapedString = $escaper->unescape($stringToUnescape); $unescapedString = $escaper->unescape($stringToUnescape);
$this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string'); $this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string');
+104
View File
@@ -204,6 +204,15 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals('This is, a comma', $allRows[0][0]); $this->assertEquals('This is, a comma', $allRows[0][0]);
} }
/**
* @return void
*/
public function testReadShouldNotTruncateLineBreak()
{
$allRows = $this->getAllRowsForFile('csv_with_line_breaks.csv', ',');
$this->assertEquals("This is,\na comma", $allRows[0][0]);
}
/** /**
* @return array * @return array
*/ */
@@ -343,6 +352,55 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldInludeRowsWithZerosOnly()
{
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.csv');
$expectedRows = [
['A', 'B', 'C'],
['1', '2', '3'],
['0', '0', '0']
];
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldCreateOutputEmptyCellPreserved()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.csv');
$expectedRows = [
['A', 'B', 'C'],
['0', '', ''],
['1', '1', '']
];
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
}
/**
* https://github.com/box/spout/issues/195
* @return void
*/
public function testReaderShouldNotTrimCellValues()
{
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.csv');
$expectedRows = [
['A'],
[' A '],
["\n\tA\n\t"],
];
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
}
/** /**
* @param string $fileName * @param string $fileName
* @param string|void $fieldDelimiter * @param string|void $fieldDelimiter
@@ -423,4 +481,50 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $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');
}
} }
+120
View File
@@ -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);
}
}
+122 -1
View File
@@ -164,6 +164,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* @return void
*/
public function testReadShouldSupportFormatDatesAndTimesIfSpecified()
{
$shouldFormatDates = true;
$allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $shouldFormatDates);
$expectedRows = [
['05/19/2016', '5/19/16', '05/19/2016 16:39:00', '05/19/16 04:39 PM', '5/19/2016'],
['11:29', '13:23:45', '01:23:45', '01:23:45 AM', '01:23:45 PM'],
];
$this->assertEquals($expectedRows, $allRows);
}
/** /**
* @return void * @return void
*/ */
@@ -363,16 +378,122 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithUnsupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\ODS\Reader $reader */
$reader = ReaderFactory::create(Type::ODS);
$reader->open('unsupported://foobar');
}
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithSupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\ODS\Reader $reader */
$reader = ReaderFactory::create(Type::ODS);
$reader->open('php://memory');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldInludeRowsWithZerosOnly()
{
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.ods');
$expectedRows = [
['A', 'B', 'C'],
['1', '2', '3'],
['0', '0', '0']
];
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldCreateOutputEmptyCellPreserved()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.ods');
$expectedRows = [
['A', 'B', 'C'],
['0', '', ''],
['1', '1', '']
];
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
}
/**
* https://github.com/box/spout/issues/195
* @return void
*/
public function testReaderShouldNotTrimCellValues()
{
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.ods');
$expectedRows = [
['A'],
[' A '],
["\n\tA\n\t"],
];
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
}
/**
* https://github.com/box/spout/issues/218
* @return void
*/
public function testReaderShouldReadTextInHyperlinks()
{
$allRows = $this->getAllRowsForFile('sheet_with_hyperlinks.ods');
$expectedRows = [
['email', 'text'],
['1@example.com', 'text'],
['2@example.com', 'text and https://github.com/box/spout/issues/218 and text'],
];
$this->assertEquals($expectedRows, $allRows, 'Text in hyperlinks should be read');
}
/**
* @return void
*/
public function testReaderShouldReadInlineFontFormattingAsText()
{
$allRows = $this->getAllRowsForFile('sheet_with_inline_font_formatting.ods');
$expectedRows = [
['I am a yellow bird']
];
$this->assertEquals($expectedRows, $allRows, 'Text formatted inline should be read');
}
/** /**
* @param string $fileName * @param string $fileName
* @param bool|void $shouldFormatDates
* @return array All the read rows the given file * @return array All the read rows the given file
*/ */
private function getAllRowsForFile($fileName) private function getAllRowsForFile($fileName, $shouldFormatDates = false)
{ {
$allRows = []; $allRows = [];
$resourcePath = $this->getResourcePath($fileName); $resourcePath = $this->getResourcePath($fileName);
$reader = ReaderFactory::create(Type::ODS); $reader = ReaderFactory::create(Type::ODS);
$reader->setShouldFormatDates($shouldFormatDates);
$reader->open($resourcePath); $reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
+50 -47
View File
@@ -20,12 +20,11 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
public function testOpenShouldFailIfFileInsideZipDoesNotExist() public function testOpenShouldFailIfFileInsideZipDoesNotExist()
{ {
$resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx'); $resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx');
$nonExistingXMLFilePath = 'zip://' . $resourcePath . '#path/to/fake/file.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
// using "@" to prevent errors/warning to be displayed // using "@" to prevent errors/warning to be displayed
$wasOpenSuccessful = @$xmlReader->open($nonExistingXMLFilePath); $wasOpenSuccessful = @$xmlReader->openFileInZip($resourcePath, 'path/to/fake/file.xml');
$this->assertTrue($wasOpenSuccessful === false); $this->assertTrue($wasOpenSuccessful === false);
} }
@@ -72,10 +71,9 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
public function testReadShouldThrowExceptionOnError() public function testReadShouldThrowExceptionOnError()
{ {
$resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx'); $resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx');
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/worksheets/sheet1.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
if ($xmlReader->open($sheetDataXMLFilePath) === false) { if ($xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml') === false) {
$this->fail(); $this->fail();
} }
@@ -95,43 +93,13 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
// The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains // The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains
// a doctype element that causes read errors // a doctype element that causes read errors
$resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx'); $resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx');
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/sharedStrings.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
if ($xmlReader->open($sheetDataXMLFilePath) !== false) { if ($xmlReader->openFileInZip($resourcePath, 'xl/sharedStrings.xml') !== false) {
@$xmlReader->next('sst'); @$xmlReader->next('sst');
} }
} }
/**
* @return array
*/
public function dataProviderForTestIsZipStream()
{
return [
['/absolute/path/to/file.xlsx', false],
['relative/path/to/file.xlsx', false],
['php://temp', false],
['zip:///absolute/path/to/file.xlsx', true],
['zip://relative/path/to/file.xlsx', true],
];
}
/**
* @dataProvider dataProviderForTestIsZipStream
*
* @param string $URI
* @param bool $expectedResult
* @return void
*/
public function testIsZipStream($URI, $expectedResult)
{
$xmlReader = new XMLReader();
$isZipStream = \ReflectionHelper::callMethodOnObject($xmlReader, 'isZipStream', $URI);
$this->assertEquals($expectedResult, $isZipStream);
}
/** /**
* @return array * @return array
*/ */
@@ -167,35 +135,70 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
/** /**
* @return array * @return array
*/ */
public function dataProviderForTestConvertURIToUseRealPath() public function dataProviderForTestGetRealPathURIForFileInZip()
{ {
$tempFolder = realpath(sys_get_temp_dir()); $tempFolder = realpath(sys_get_temp_dir());
$expectedRealPathURI = 'zip://' . $tempFolder . '/test.xlsx#test.xml';
return [ return [
['/../../../' . $tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'], [$tempFolder, "$tempFolder/test.xlsx", 'test.xml', $expectedRealPathURI],
[$tempFolder . '/test.xlsx', $tempFolder . '/test.xlsx'], [$tempFolder, "/../../../$tempFolder/test.xlsx", 'test.xml', $expectedRealPathURI],
['zip://' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
['zip:///../../../' . $tempFolder . '/test.xlsx#test.xml', 'zip://' . $tempFolder . '/test.xlsx#test.xml'],
]; ];
} }
/** /**
* @dataProvider dataProviderForTestConvertURIToUseRealPath * @dataProvider dataProviderForTestGetRealPathURIForFileInZip
* *
* @param string $URI * @param string $tempFolder
* @param string $expectedConvertedURI * @param string $zipFilePath
* @param string $fileInsideZipPath
* @param string $expectedRealPathURI
* @return void * @return void
*/ */
public function testConvertURIToUseRealPath($URI, $expectedConvertedURI) public function testGetRealPathURIForFileInZip($tempFolder, $zipFilePath, $fileInsideZipPath, $expectedRealPathURI)
{ {
$tempFolder = sys_get_temp_dir();
touch($tempFolder . '/test.xlsx'); touch($tempFolder . '/test.xlsx');
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
$convertedURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'convertURIToUseRealPath', $URI); $realPathURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'getRealPathURIForFileInZip', $zipFilePath, $fileInsideZipPath);
$this->assertEquals($expectedConvertedURI, $convertedURI); $this->assertEquals($expectedRealPathURI, $realPathURI);
unlink($tempFolder . '/test.xlsx'); unlink($tempFolder . '/test.xlsx');
} }
/**
* @return array
*/
public function dataProviderForTestIsPositionedOnStartingAndEndingNode()
{
return [
['<test></test>'], // not prefixed
['<x:test xmlns:x="foo"></x:test>'], // prefixed
];
}
/**
* @dataProvider dataProviderForTestIsPositionedOnStartingAndEndingNode
*
* @param string $testXML
* @return void
*/
public function testIsPositionedOnStartingAndEndingNode($testXML)
{
$xmlReader = new XMLReader();
$xmlReader->XML($testXML);
// the first read moves the pointer to "<test>"
$xmlReader->read();
$this->assertTrue($xmlReader->isPositionedOnStartingNode('test'));
$this->assertFalse($xmlReader->isPositionedOnEndingNode('test'));
// the seconds read moves the pointer to "</test>"
$xmlReader->read();
$this->assertFalse($xmlReader->isPositionedOnStartingNode('test'));
$this->assertTrue($xmlReader->isPositionedOnEndingNode('test'));
$xmlReader->close();
}
} }
@@ -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, 42429, '2016-02-29 00:00:00'],
[CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00'], [CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00'],
[CellValueFormatter::CELL_TYPE_NUMERIC, -700, null], [CellValueFormatter::CELL_TYPE_NUMERIC, -700, null],
[CellValueFormatter::CELL_TYPE_NUMERIC, 0, null], [CellValueFormatter::CELL_TYPE_NUMERIC, 0, '1900-01-01 00:00:00'],
[CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, null], [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, 1, '1900-01-01 00:00:00'],
[CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59'], [CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59'],
[CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00'], [CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00'],
@@ -68,7 +71,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
->with(123) ->with(123)
->will($this->returnValue(true)); ->will($this->returnValue(true));
$formatter = new CellValueFormatter(null, $styleHelperMock); $formatter = new CellValueFormatter(null, $styleHelperMock, false);
$result = $formatter->extractAndFormatNodeValue($nodeMock); $result = $formatter->extractAndFormatNodeValue($nodeMock);
if ($expectedDateAsString === null) { if ($expectedDateAsString === null) {
@@ -84,6 +87,10 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
*/ */
public function dataProviderForTestFormatNumericCellValueWithNumbers() 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 [ return [
[42, 42, 'integer'], [42, 42, 'integer'],
[42.5, 42.5, 'double'], [42.5, 42.5, 'double'],
@@ -91,8 +98,8 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
[-42.5, -42.5, 'double'], [-42.5, -42.5, 'double'],
['42', 42, 'integer'], ['42', 42, 'integer'],
['42.5', 42.5, 'double'], ['42.5', 42.5, 'double'],
[865640023012945, 865640023012945, 'integer'], [865640023012945, 865640023012945, $expectedBigNumberType],
['865640023012945', 865640023012945, 'integer'], ['865640023012945', 865640023012945, $expectedBigNumberType],
[865640023012945.5, 865640023012945.5, 'double'], [865640023012945.5, 865640023012945.5, 'double'],
['865640023012945.5', 865640023012945.5, 'double'], ['865640023012945.5', 865640023012945.5, 'double'],
[PHP_INT_MAX, PHP_INT_MAX, 'integer'], [PHP_INT_MAX, PHP_INT_MAX, 'integer'],
@@ -117,10 +124,52 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
->method('shouldFormatNumericValueAsDate') ->method('shouldFormatNumericValueAsDate')
->will($this->returnValue(false)); ->will($this->returnValue(false));
$formatter = new CellValueFormatter(null, $styleHelperMock); $formatter = new CellValueFormatter(null, $styleHelperMock, false);
$formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0); $formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0);
$this->assertEquals($expectedFormattedValue, $formattedValue); $this->assertEquals($expectedFormattedValue, $formattedValue);
$this->assertEquals($expectedType, gettype($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,47 @@
<?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'],
];
}
/**
* @dataProvider dataProviderForTestToPHPDateFormat
*
* @param string $excelDateFormat
* @param string $expectedPHPDateFormat
* @return void
*/
public function testToPHPDateFormat($excelDateFormat, $expectedPHPDateFormat)
{
$phpDateFormat = DateFormatHelper::toPHPDateFormat($excelDateFormat);
$this->assertEquals($expectedPHPDateFormat, $phpDateFormat);
}
}
@@ -15,6 +15,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
public function dataProviderForTestGetBestCachingStrategy() public function dataProviderForTestGetBestCachingStrategy()
{ {
return [ return [
[null, -1, 'FileBasedStrategy'],
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE, -1, 'FileBasedStrategy'], [CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE, -1, 'FileBasedStrategy'],
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE + 10, -1, 'FileBasedStrategy'], [CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE + 10, -1, 'FileBasedStrategy'],
[CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE - 10, -1, 'InMemoryStrategy'], [CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE - 10, -1, 'InMemoryStrategy'],
@@ -27,7 +28,7 @@ class CachingStrategyFactoryTest extends \PHPUnit_Framework_TestCase
/** /**
* @dataProvider dataProviderForTestGetBestCachingStrategy * @dataProvider dataProviderForTestGetBestCachingStrategy
* *
* @param int $sharedStringsUniqueCount * @param int|null $sharedStringsUniqueCount
* @param int $memoryLimitInKB * @param int $memoryLimitInKB
* @param string $expectedStrategyClassName * @param string $expectedStrategyClassName
* @return void * @return void
@@ -82,6 +82,22 @@ class SharedStringsHelperTest extends \PHPUnit_Framework_TestCase
$sharedStringsHelper->cleanup(); $sharedStringsHelper->cleanup();
} }
/**
* @return void
*/
public function testGetStringAtIndexShouldWorkWithStringsContainingTextAndHyperlinkInSameCell()
{
$resourcePath = $this->getResourcePath('one_sheet_with_shared_strings_containing_text_and_hyperlink_in_same_cell.xlsx');
$sharedStringsHelper = new SharedStringsHelper($resourcePath);
$sharedStringsHelper->extractSharedStrings();
$sharedString = $sharedStringsHelper->getStringAtIndex(0);
$this->assertEquals('go to https://github.com please', $sharedString);
$sharedStringsHelper->cleanup();
}
/** /**
* @return void * @return void
*/ */
@@ -59,6 +59,16 @@ class StyleHelperTest extends \PHPUnit_Framework_TestCase
$this->assertFalse($shouldFormatAsDate); $this->assertFalse($shouldFormatAsDate);
} }
/**
* @return void
*/
public function testShouldFormatNumericValueAsDateWithGeneralFormat()
{
$styleHelper = $this->getStyleHelperMock([[], ['applyNumberFormat' => true, 'numFmtId' => 0]]);
$shouldFormatAsDate = $styleHelper->shouldFormatNumericValueAsDate(1);
$this->assertFalse($shouldFormatAsDate);
}
/** /**
* @return void * @return void
*/ */
+176 -2
View File
@@ -23,7 +23,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
{ {
return [ return [
['/path/to/fake/file.xlsx'], ['/path/to/fake/file.xlsx'],
['file_with_no_sheets_in_content_types.xlsx'], ['file_with_no_sheets_in_workbook_xml.xlsx'],
['file_with_sheet_xml_not_matching_content_types.xlsx'], ['file_with_sheet_xml_not_matching_content_types.xlsx'],
['file_corrupted.xlsx'], ['file_corrupted.xlsx'],
]; ];
@@ -95,6 +95,51 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* @return void
*/
public function testReadShouldSupportPrefixedXMLFiles()
{
// The XML files of this spreadsheet are prefixed.
// For instance, they use "<x:sheet>" instead of "<sheet>", etc.
$allRows = $this->getAllRowsForFile('sheet_with_prefixed_xml_files.xlsx');
$expectedRows = [
['s1 - A1', 's1 - B1', 's1 - C1'],
['s1 - A2', 's1 - B2', 's1 - C2'],
['s1 - A3', 's1 - B3', 's1 - C3'],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAttribute()
{
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count.xlsx');
$expectedRows = [
['s1--A1', 's1--B1'],
['s1--A2', 's1--B2'],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportSheetWithSharedStringsMissingUniqueCountAndCountAttributes()
{
$allRows = $this->getAllRowsForFile('one_sheet_with_shared_strings_missing_unique_count_and_count.xlsx');
$expectedRows = [
['s1--A1', 's1--B1'],
['s1--A2', 's1--B2'],
];
$this->assertEquals($expectedRows, $allRows);
}
/** /**
* @return void * @return void
*/ */
@@ -109,6 +154,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* @return void
*/
public function testReadShouldSupportFilesWithoutCellReference()
{
// file where the cell definition does not have a "r" attribute
// as in <c r="A1">...</c>
$allRows = $this->getAllRowsForFile('sheet_with_missing_cell_reference.xlsx');
$expectedRows = [
['s1--A1'],
];
$this->assertEquals($expectedRows, $allRows);
}
/** /**
* @return void * @return void
*/ */
@@ -181,6 +241,43 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $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'],
];
$this->assertEquals($expectedRows, $allRows);
}
/** /**
* @return void * @return void
*/ */
@@ -404,16 +501,93 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows); $this->assertEquals($expectedRows, $allRows);
} }
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithUnsupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
$reader = ReaderFactory::create(Type::XLSX);
$reader->open('unsupported://foobar');
}
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithSupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\XLSX\Reader $reader */
$reader = ReaderFactory::create(Type::XLSX);
$reader->open('php://memory');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldInludeRowsWithZerosOnly()
{
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.xlsx');
$expectedRows = [
['A', 'B', 'C'],
['1', '2', '3'],
['0', '0', '0']
];
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldCreateOutputEmptyCellPreserved()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.xlsx');
$expectedRows = [
['A', 'B', 'C'],
['0', '', ''],
['1', '1', '']
];
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
}
/**
* https://github.com/box/spout/issues/195
* @return void
*/
public function testReaderShouldNotTrimCellValues()
{
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_inline_strings.xlsx');
$expectedRows = [
['A'],
[' A '],
["\n\tA\n\t"],
];
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
}
/** /**
* @param string $fileName * @param string $fileName
* @param bool|void $shouldFormatDates
* @return array All the read rows the given file * @return array All the read rows the given file
*/ */
private function getAllRowsForFile($fileName) private function getAllRowsForFile($fileName, $shouldFormatDates = false)
{ {
$allRows = []; $allRows = [];
$resourcePath = $this->getResourcePath($fileName); $resourcePath = $this->getResourcePath($fileName);
$reader = ReaderFactory::create(Type::XLSX); $reader = ReaderFactory::create(Type::XLSX);
$reader->setShouldFormatDates($shouldFormatDates);
$reader->open($resourcePath); $reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
+31 -2
View File
@@ -74,6 +74,33 @@ class WriterTest extends \PHPUnit_Framework_TestCase
$this->assertContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should contain a UTF-8 BOM'); $this->assertContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should contain a UTF-8 BOM');
} }
/**
* @return void
*/
public function testWriteShouldNotAddUtf8Bom()
{
$allRows = [
['csv--11', 'csv--12'],
];
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_no_bom.csv', ',', '"', false);
$this->assertNotContains(EncodingHelper::BOM_UTF8, $writtenContent, 'The CSV file should not contain a UTF-8 BOM');
}
/**
* @return void
*/
public function testWriteShouldSupportAssociativeArrays()
{
$allRows = [
['foo' => 'csv--11', 'bar' => 'csv--12'],
];
$writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_from_associative_arrays.csv');
$writtenContent = $this->trimWrittenContent($writtenContent);
$this->assertEquals('csv--11,csv--12', $writtenContent, 'Values from associative arrays should be written');
}
/** /**
* @return void * @return void
*/ */
@@ -134,13 +161,14 @@ class WriterTest extends \PHPUnit_Framework_TestCase
} }
/** /**
* @param array $allRows * @param array $allRows
* @param string $fileName * @param string $fileName
* @param string $fieldDelimiter * @param string $fieldDelimiter
* @param string $fieldEnclosure * @param string $fieldEnclosure
* @param bool $shouldAddBOM
* @return null|string * @return null|string
*/ */
private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"') private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"', $shouldAddBOM = true)
{ {
$this->createGeneratedFolderIfNeeded($fileName); $this->createGeneratedFolderIfNeeded($fileName);
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
@@ -148,6 +176,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
$writer = WriterFactory::create(Type::CSV); $writer = WriterFactory::create(Type::CSV);
$writer->setFieldDelimiter($fieldDelimiter); $writer->setFieldDelimiter($fieldDelimiter);
$writer->setFieldEnclosure($fieldEnclosure); $writer->setFieldEnclosure($fieldEnclosure);
$writer->setShouldAddBOM($shouldAddBOM);
$writer->openToFile($resourcePath); $writer->openToFile($resourcePath);
$writer->addRows($allRows); $writer->addRows($allRows);
+21 -3
View File
@@ -23,7 +23,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting() public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
{ {
$fileName = 'file_that_wont_be_written.ods'; $fileName = 'file_that_wont_be_written.ods';
$this->createUnwritableFolderIfNeeded($fileName); $this->createUnwritableFolderIfNeeded();
$filePath = $this->getGeneratedUnwritableResourcePath($fileName); $filePath = $this->getGeneratedUnwritableResourcePath($fileName);
$writer = WriterFactory::create(Type::ODS); $writer = WriterFactory::create(Type::ODS);
@@ -177,6 +177,25 @@ class WriterTest extends \PHPUnit_Framework_TestCase
} }
} }
/**
* @return void
*/
public function testAddRowShouldSupportAssociativeArrays()
{
$fileName = 'test_add_row_should_support_associative_arrays.ods';
$dataRows = [
['foo' => 'ods--11', 'bar' => 'ods--12'],
];
$this->writeToODSFile($dataRows, $fileName);
foreach ($dataRows as $dataRow) {
foreach ($dataRow as $cellValue) {
$this->assertValueWasWritten($fileName, $cellValue);
}
}
}
/** /**
* @return void * @return void
*/ */
@@ -519,10 +538,9 @@ class WriterTest extends \PHPUnit_Framework_TestCase
private function moveReaderToCorrectTableNode($fileName, $sheetIndex) private function moveReaderToCorrectTableNode($fileName, $sheetIndex)
{ {
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToSheetFile = $resourcePath . '#content.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToSheetFile); $xmlReader->openFileInZip($resourcePath, 'content.xml');
$xmlReader->readUntilNodeFound('table:table'); $xmlReader->readUntilNodeFound('table:table');
for ($i = 1; $i < $sheetIndex; $i++) { for ($i = 1; $i < $sheetIndex; $i++) {
+81 -11
View File
@@ -5,6 +5,9 @@ namespace Box\Spout\Writer\ODS;
use Box\Spout\Common\Type; use Box\Spout\Common\Type;
use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\TestUsingResource; use Box\Spout\TestUsingResource;
use Box\Spout\Writer\ODS\Helper\BorderHelper;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderBuilder;
use Box\Spout\Writer\Style\Color; use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\Style; use Box\Spout\Writer\Style\Style;
use Box\Spout\Writer\Style\StyleBuilder; use Box\Spout\Writer\Style\StyleBuilder;
@@ -208,7 +211,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
]; ];
$style = (new StyleBuilder())->setShouldWrapText()->build(); $style = (new StyleBuilder())->setShouldWrapText()->build();
$this->writeToODSFile($dataRows, $fileName,$style); $this->writeToODSFile($dataRows, $fileName, $style);
$styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName); $styleElements = $this->getCellStyleElementsFromContentXmlFile($fileName);
$this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)'); $this->assertEquals(2, count($styleElements), 'There should be 2 styles (default and custom)');
@@ -236,6 +239,72 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
$this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option'); $this->assertFirstChildHasAttributeEquals('wrap', $customStyleElement, 'table-cell-properties', 'fo:wrap-option');
} }
/**
* @return void
*/
public function 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);
}
/** /**
* @param array $allRows * @param array $allRows
* @param string $fileName * @param string $fileName
@@ -296,17 +365,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
$cellElements = []; $cellElements = [];
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToStylesXmlFile = $resourcePath . '#content.xml';
$xmlReader = new \XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToStylesXmlFile); $xmlReader->openFileInZip($resourcePath, 'content.xml');
while ($xmlReader->read()) { while ($xmlReader->read()) {
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'table:table-cell' && $xmlReader->getAttribute('office:value-type') !== null) { if ($xmlReader->isPositionedOnStartingNode('table:table-cell') && $xmlReader->getAttribute('office:value-type') !== null) {
$cellElements[] = $xmlReader->expand(); $cellElements[] = $xmlReader->expand();
} }
} }
$xmlReader->close();
return $cellElements; return $cellElements;
} }
@@ -319,17 +389,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
$cellStyleElements = []; $cellStyleElements = [];
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToStylesXmlFile = $resourcePath . '#content.xml';
$xmlReader = new \XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToStylesXmlFile); $xmlReader->openFileInZip($resourcePath, 'content.xml');
while ($xmlReader->read()) { while ($xmlReader->read()) {
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'style:style' && $xmlReader->getAttribute('style:family') === 'table-cell') { if ($xmlReader->isPositionedOnStartingNode('style:style') && $xmlReader->getAttribute('style:family') === 'table-cell') {
$cellStyleElements[] = $xmlReader->expand(); $cellStyleElements[] = $xmlReader->expand();
} }
} }
$xmlReader->close();
return $cellStyleElements; return $cellStyleElements;
} }
@@ -341,10 +412,9 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
private function getXmlSectionFromStylesXmlFile($fileName, $section) private function getXmlSectionFromStylesXmlFile($fileName, $section)
{ {
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToStylesXmlFile = $resourcePath . '#styles.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToStylesXmlFile); $xmlReader->openFileInZip($resourcePath, 'styles.xml');
$xmlReader->readUntilNodeFound($section); $xmlReader->readUntilNodeFound($section);
return $xmlReader->expand(); return $xmlReader->expand();
+110
View File
@@ -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());
}
}
}
}
}
+28
View File
@@ -129,4 +129,32 @@ class StyleTest extends \PHPUnit_Framework_TestCase
$this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize()); $this->assertTrue($currentStyle->serialize() === $mergedStyle->serialize());
} }
/**
* @return void
*/
public function testStyleBuilderShouldApplyBorders()
{
$border = (new BorderBuilder())
->setBorderBottom()
->build();
$style = (new StyleBuilder())->setBorder($border)->build();
$this->assertTrue($style->shouldApplyBorder());
}
/**
* @return void
*/
public function testStyleBuilderShouldMergeBorders()
{
$border = (new BorderBuilder())->setBorderBottom(Color::RED, Border::WIDTH_THIN, Border::STYLE_DASHED)->build();
$baseStyle = (new StyleBuilder())->setBorder($border)->build();
$currentStyle = (new StyleBuilder())->build();
$mergedStyle = $currentStyle->mergeWith($baseStyle);
$this->assertEquals(null, $currentStyle->getBorder(), 'Current style has no border');
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $baseStyle->getBorder(), 'Base style has a border');
$this->assertInstanceOf('Box\Spout\Writer\Style\Border', $mergedStyle->getBorder(), 'Merged style has a border');
}
} }
+20 -1
View File
@@ -21,7 +21,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting() public function testAddRowShouldThrowExceptionIfCannotOpenAFileForWriting()
{ {
$fileName = 'file_that_wont_be_written.xlsx'; $fileName = 'file_that_wont_be_written.xlsx';
$this->createUnwritableFolderIfNeeded($fileName); $this->createUnwritableFolderIfNeeded();
$filePath = $this->getGeneratedUnwritableResourcePath($fileName); $filePath = $this->getGeneratedUnwritableResourcePath($fileName);
$writer = WriterFactory::create(Type::XLSX); $writer = WriterFactory::create(Type::XLSX);
@@ -232,6 +232,25 @@ 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, $shouldUseInlineStrings = true);
foreach ($dataRows as $dataRow) {
foreach ($dataRow as $cellValue) {
$this->assertInlineDataWasWrittenToSheet($fileName, 1, $cellValue);
}
}
}
/** /**
* @return void * @return void
*/ */
@@ -5,6 +5,8 @@ namespace Box\Spout\Writer\XLSX;
use Box\Spout\Common\Type; use Box\Spout\Common\Type;
use Box\Spout\Reader\Wrapper\XMLReader; use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\TestUsingResource; use Box\Spout\TestUsingResource;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderBuilder;
use Box\Spout\Writer\Style\Color; use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\Style; use Box\Spout\Writer\Style\Style;
use Box\Spout\Writer\Style\StyleBuilder; use Box\Spout\Writer\Style\StyleBuilder;
@@ -205,7 +207,7 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
]; ];
$style = (new StyleBuilder())->setShouldWrapText()->build(); $style = (new StyleBuilder())->setShouldWrapText()->build();
$this->writeToXLSXFile($dataRows, $fileName,$style); $this->writeToXLSXFile($dataRows, $fileName, $style);
$cellXfsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs'); $cellXfsDomElement = $this->getXmlSectionFromStylesXmlFile($fileName, 'cellXfs');
$xfElement = $cellXfsDomElement->getElementsByTagName('xf')->item(1); $xfElement = $cellXfsDomElement->getElementsByTagName('xf')->item(1);
@@ -232,6 +234,40 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
$this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText'); $this->assertFirstChildHasAttributeEquals('1', $xfElement, 'alignment', 'wrapText');
} }
/**
* @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');
}
/** /**
* @param array $allRows * @param array $allRows
* @param string $fileName * @param string $fileName
@@ -293,13 +329,16 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
private function getXmlSectionFromStylesXmlFile($fileName, $section) private function getXmlSectionFromStylesXmlFile($fileName, $section)
{ {
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToStylesXmlFile = $resourcePath . '#xl/styles.xml';
$xmlReader = new XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToStylesXmlFile); $xmlReader->openFileInZip($resourcePath, 'xl/styles.xml');
$xmlReader->readUntilNodeFound($section); $xmlReader->readUntilNodeFound($section);
return $xmlReader->expand(); $xmlSection = $xmlReader->expand();
$xmlReader->close();
return $xmlSection;
} }
/** /**
@@ -311,17 +350,18 @@ class WriterWithStyleTest extends \PHPUnit_Framework_TestCase
$cellElements = []; $cellElements = [];
$resourcePath = $this->getGeneratedResourcePath($fileName); $resourcePath = $this->getGeneratedResourcePath($fileName);
$pathToStylesXmlFile = $resourcePath . '#xl/worksheets/sheet1.xml';
$xmlReader = new \XMLReader(); $xmlReader = new XMLReader();
$xmlReader->open('zip://' . $pathToStylesXmlFile); $xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml');
while ($xmlReader->read()) { while ($xmlReader->read()) {
if ($xmlReader->nodeType === \XMLReader::ELEMENT && $xmlReader->name === 'c') { if ($xmlReader->isPositionedOnStartingNode('c')) {
$cellElements[] = $xmlReader->expand(); $cellElements[] = $xmlReader->expand();
} }
} }
$xmlReader->close();
return $cellElements; return $cellElements;
} }
+1
View File
@@ -3,6 +3,7 @@ require_once(dirname(__DIR__) . '/vendor/autoload.php');
require_once(dirname(__DIR__) . '/tests/Spout/TestUsingResource.php'); require_once(dirname(__DIR__) . '/tests/Spout/TestUsingResource.php');
require_once(dirname(__DIR__) . '/tests/Spout/ReflectionHelper.php'); require_once(dirname(__DIR__) . '/tests/Spout/ReflectionHelper.php');
require_once(dirname(__DIR__) . '/tests/Spout/Reader/CSV/SpoutTestStream.php');
// Make sure a timezone is set to be able to work with dates // Make sure a timezone is set to be able to work with dates
date_default_timezone_set('UTC'); date_default_timezone_set('UTC');
@@ -0,0 +1,2 @@
"This is,
a comma",csv--12
1 This is, a comma csv--12
@@ -0,0 +1,3 @@
"A","B","C"
0,,
1,1,
1 A B C
2 0
3 1 1
@@ -0,0 +1,5 @@
A
" A "
"
A
"
1 A
2 A
3 A
@@ -0,0 +1,3 @@
"A","B","C"
1,2,3
0,0,0
1 A B C
2 1 2 3
3 0 0 0
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.