Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3681a3421a | ||
|
|
3bbff7ea7d | ||
|
|
fd6dd46b25 | ||
|
|
6d44cd26cc | ||
|
|
ee5dee61c7 | ||
|
|
40b4a57e6b | ||
|
|
5d4166196a | ||
|
|
99816b0b8e | ||
|
|
606103f7fc | ||
|
|
4e6db6a8a1 | ||
|
|
048105461c | ||
|
|
1eb01a3d2a | ||
|
|
9f80ece73f | ||
|
|
7f8b95b2f3 |
No files matched your search
+3
-2
@@ -1,5 +1,6 @@
|
||||
/.idea
|
||||
*.iml
|
||||
|
||||
/tests/resources/generated
|
||||
/tests/coverage
|
||||
/vendor
|
||||
/.idea
|
||||
*.iml
|
||||
+1
-1
@@ -4,7 +4,7 @@ filter:
|
||||
tools:
|
||||
external_code_coverage:
|
||||
timeout: 600 # Wait 10 minutes for results
|
||||
runs: 3 # Merge results for 5.4, 5.5 and 5.6 jobs
|
||||
runs: 1 # Results are only coming from the PHP 7.1 job
|
||||
php_mess_detector: true
|
||||
php_code_sniffer:
|
||||
config:
|
||||
|
||||
+9
-3
@@ -1,11 +1,14 @@
|
||||
language: php
|
||||
|
||||
dist: trusty
|
||||
|
||||
php:
|
||||
- 5.4
|
||||
- 5.5
|
||||
- 5.6
|
||||
- 7.0
|
||||
- hhvm
|
||||
- 7.1
|
||||
- hhvm-3.6
|
||||
|
||||
cache:
|
||||
directories:
|
||||
@@ -19,5 +22,8 @@ script:
|
||||
- php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
|
||||
|
||||
after_script:
|
||||
- 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
|
||||
- |
|
||||
if [[ "$TRAVIS_PHP_VERSION" == '7.1' ]]; then
|
||||
wget https://scrutinizer-ci.com/ocular.phar
|
||||
php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover
|
||||
fi
|
||||
@@ -12,29 +12,10 @@ Contrary to other file readers or writers, it is capable of processing very larg
|
||||
|
||||
Join the community and come discuss about Spout: [](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
|
||||
## Installation
|
||||
|
||||
### Composer (recommended)
|
||||
## Documentation
|
||||
|
||||
Spout can be installed directly from [Composer](https://getcomposer.org/).
|
||||
|
||||
Run the following command:
|
||||
```
|
||||
$ composer require box/spout
|
||||
```
|
||||
|
||||
### Manual installation
|
||||
|
||||
If you can't use Composer, no worries! You can still install Spout manually.
|
||||
|
||||
> Before starting, make sure your system meets the [requirements](#requirements).
|
||||
|
||||
1. Download the source code from the [Releases page](https://github.com/box/spout/releases)
|
||||
2. Extract the downloaded content into your project.
|
||||
3. Add this code to the top controller (index.php) or wherever it may be more appropriate:
|
||||
```php
|
||||
require_once '[PATH/TO]/src/Spout/Autoloader/autoload.php'; // don't forget to change the path!
|
||||
```
|
||||
Full documentation can be found at [http://opensource.box.com/spout/](http://opensource.box.com/spout/).
|
||||
|
||||
|
||||
## Requirements
|
||||
@@ -42,293 +23,6 @@ require_once '[PATH/TO]/src/Spout/Autoloader/autoload.php'; // don't forget to c
|
||||
* PHP version 5.4.0 or higher
|
||||
* PHP extension `php_zip` enabled
|
||||
* PHP extension `php_xmlreader` enabled
|
||||
* PHP extension `php_simplexml` enabled
|
||||
|
||||
|
||||
## Basic usage
|
||||
|
||||
### Reader
|
||||
|
||||
Regardless of the file type, the interface to read a file is always the same:
|
||||
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::XLSX); // for XLSX files
|
||||
//$reader = ReaderFactory::create(Type::CSV); // for CSV files
|
||||
//$reader = ReaderFactory::create(Type::ODS); // for ODS files
|
||||
|
||||
$reader->open($filePath);
|
||||
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
foreach ($sheet->getRowIterator() as $row) {
|
||||
// do stuff with the row
|
||||
}
|
||||
}
|
||||
|
||||
$reader->close();
|
||||
```
|
||||
|
||||
If there are multiple sheets in the file, the reader will read all of them sequentially.
|
||||
|
||||
### Writer
|
||||
|
||||
As with the reader, there is one common interface to write data to a file:
|
||||
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX); // for XLSX files
|
||||
//$writer = WriterFactory::create(Type::CSV); // for CSV files
|
||||
//$writer = WriterFactory::create(Type::ODS); // for ODS files
|
||||
|
||||
$writer->openToFile($filePath); // write data to a file or to a PHP stream
|
||||
//$writer->openToBrowser($fileName); // stream data directly to the browser
|
||||
|
||||
$writer->addRow($singleRow); // add a row at a time
|
||||
$writer->addRows($multipleRows); // add multiple rows at a time
|
||||
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
For XLSX and ODS files, the number of rows per sheet is limited to 1,048,576. By default, once this limit is reached, the writer will automatically create a new sheet and continue writing data into it.
|
||||
|
||||
|
||||
## Advanced usage
|
||||
|
||||
If you are looking for how to perform some common, more advanced tasks with Spout, please take a look at the [Wiki](https://github.com/box/spout/wiki). It contains code snippets, ready to be used.
|
||||
|
||||
### Configuring the CSV reader and writer
|
||||
|
||||
It is possible to configure both the CSV reader and writer to specify the field separator as well as the field enclosure:
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->setFieldDelimiter('|');
|
||||
$reader->setFieldEnclosure('@');
|
||||
$reader->setEndOfLineCharacter("\r");
|
||||
```
|
||||
|
||||
Additionally, if you need to read non UTF-8 files, you can specify the encoding of your file this way:
|
||||
```php
|
||||
$reader->setEncoding('UTF-16LE');
|
||||
```
|
||||
|
||||
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 readers and writers
|
||||
|
||||
#### Row styling
|
||||
|
||||
It is possible to apply some formatting options to a row. Spout supports fonts, background, borders as well as alignment styles.
|
||||
|
||||
```php
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
|
||||
$style = (new StyleBuilder())
|
||||
->setFontBold()
|
||||
->setFontSize(15)
|
||||
->setFontColor(Color::BLUE)
|
||||
->setShouldWrapText()
|
||||
->setBackgroundColor(Color::YELLOW)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->openToFile($filePath);
|
||||
|
||||
$writer->addRowWithStyle($singleRow, $style); // style will only be applied to this row
|
||||
$writer->addRow($otherSingleRow); // no style will be applied
|
||||
$writer->addRowsWithStyle($multipleRows, $style); // style will be applied to all given rows
|
||||
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
Adding borders to a row requires a ```Border``` object.
|
||||
|
||||
```php
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Writer\Style\Border;
|
||||
use Box\Spout\Writer\Style\BorderBuilder;
|
||||
use Box\Spout\Writer\Style\Color;
|
||||
use Box\Spout\Writer\Style\StyleBuilder;
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
|
||||
$border = (new BorderBuilder())
|
||||
->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED)
|
||||
->build();
|
||||
|
||||
$style = (new StyleBuilder())
|
||||
->setBorder($border)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->openToFile($filePath);
|
||||
|
||||
$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style);
|
||||
|
||||
$writer->close();
|
||||
```
|
||||
|
||||
Spout will use a default style for all created rows. This style can be overridden this way:
|
||||
|
||||
```php
|
||||
$defaultStyle = (new StyleBuilder())
|
||||
->setFontName('Arial')
|
||||
->setFontSize(11)
|
||||
->build();
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setDefaultRowStyle($defaultStyle)
|
||||
->openToFile($filePath);
|
||||
```
|
||||
|
||||
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
|
||||
|
||||
| Category | Property | API
|
||||
|-----------|---------------|---------------------------------------
|
||||
| Font | Bold | `StyleBuilder::setFontBold()`
|
||||
| | Italic | `StyleBuilder::setFontItalic()`
|
||||
| | Underline | `StyleBuilder::setFontUnderline()`
|
||||
| | Strikethrough | `StyleBuilder::setFontStrikethrough()`
|
||||
| | Font name | `StyleBuilder::setFontName('Arial')`
|
||||
| | Font size | `StyleBuilder::setFontSize(14)`
|
||||
| | Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))`
|
||||
| Alignment | Wrap text | `StyleBuilder::setShouldWrapText(true|false)`
|
||||
|
||||
#### New sheet creation
|
||||
|
||||
It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
$writer->setShouldCreateNewSheetsAutomatically(true); // default value
|
||||
$writer->setShouldCreateNewSheetsAutomatically(false); // will stop writing new data when limit is reached
|
||||
```
|
||||
|
||||
#### Using custom temporary folder
|
||||
|
||||
Processing XLSX and ODS files require temporary files to be created. By default, Spout will use the system default temporary folder (as returned by `sys_get_temp_dir()`). It is possible to override this by explicitly setting it on the reader or writer:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setTempFolder($customTempFolderPath);
|
||||
```
|
||||
|
||||
#### Strings storage (XLSX writer)
|
||||
|
||||
XLSX files support different ways to store the string values:
|
||||
* Shared strings are meant to optimize file size by separating strings from the sheet representation and ignoring strings duplicates (if a string is used three times, only one string will be stored)
|
||||
* Inline strings are less optimized (as duplicate strings are all stored) but is faster to process
|
||||
|
||||
In order to keep the memory usage really low, Spout does not optimize strings when using shared strings. It is nevertheless possible to use this mode.
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setShouldUseInlineStrings(true); // default (and recommended) value
|
||||
$writer->setShouldUseInlineStrings(false); // will use shared strings
|
||||
```
|
||||
|
||||
> ##### Note on Apple Numbers and iOS support
|
||||
>
|
||||
> Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings!
|
||||
|
||||
|
||||
#### Date/Time formatting
|
||||
|
||||
When reading a spreadsheet containing dates or times, Spout returns the values by default as DateTime objects.
|
||||
It is possible to change this behavior and have a formatted date returned instead (e.g. "2016-11-29 1:22 AM"). The format of the date corresponds to what is specified in the spreadsheet.
|
||||
|
||||
```php
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$reader = ReaderFactory::create(Type::XLSX);
|
||||
$reader->setShouldFormatDates(false); // default value
|
||||
$reader->setShouldFormatDates(true); // will return formatted dates
|
||||
```
|
||||
|
||||
### Playing with sheets
|
||||
|
||||
When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet:
|
||||
```php
|
||||
$firstSheet = $writer->getCurrentSheet();
|
||||
$writer->addRow($rowForSheet1); // writes the row to the first sheet
|
||||
|
||||
$newSheet = $writer->addNewSheetAndMakeItCurrent();
|
||||
$writer->addRow($rowForSheet2); // writes the row to the new sheet
|
||||
|
||||
$writer->setCurrentSheet($firstSheet);
|
||||
$writer->addRow($anotherRowForSheet1); // append the row to the first sheet
|
||||
```
|
||||
|
||||
It is also possible to retrieve all the sheets currently created:
|
||||
```php
|
||||
$sheets = $writer->getSheets();
|
||||
```
|
||||
|
||||
If you rely on the sheet's name in your application, you can access it and customize it this way:
|
||||
```php
|
||||
// Accessing the sheet name when reading
|
||||
foreach ($reader->getSheetIterator() as $sheet) {
|
||||
$sheetName = $sheet->getName();
|
||||
}
|
||||
|
||||
// Accessing the sheet name when writing
|
||||
$sheet = $writer->getCurrentSheet();
|
||||
$sheetName = $sheet->getName();
|
||||
|
||||
// Customizing the sheet name when writing
|
||||
$sheet = $writer->getCurrentSheet();
|
||||
$sheet->setName('My custom name');
|
||||
```
|
||||
|
||||
> Please note that Excel has some restrictions on the sheet's name:
|
||||
> * it must not be blank
|
||||
> * it must not exceed 31 characters
|
||||
> * it must not contain these characters: \ / ? * : [ or ]
|
||||
> * it must not start or end with a single quote
|
||||
> * it must be unique
|
||||
>
|
||||
> Handling these restrictions is the developer's responsibility. Spout does not try to automatically change the sheet's name, as one may rely on this name to be exactly what was passed in.
|
||||
|
||||
|
||||
### Fluent interface
|
||||
|
||||
Because fluent interfaces are great, you can use them with Spout:
|
||||
```php
|
||||
use Box\Spout\Writer\WriterFactory;
|
||||
use Box\Spout\Common\Type;
|
||||
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->setTempFolder($customTempFolderPath)
|
||||
->setShouldUseInlineStrings(true)
|
||||
->openToFile($filePath)
|
||||
->addRow($headerRow)
|
||||
->addRows($dataRows)
|
||||
->close();
|
||||
```
|
||||
|
||||
|
||||
## Running tests
|
||||
@@ -347,44 +41,14 @@ For information, the performance tests take about 30 minutes to run (processing
|
||||
> Performance tests status: [](https://travis-ci.org/box/spout)
|
||||
|
||||
|
||||
## Frequently Asked Questions
|
||||
|
||||
#### 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.
|
||||
|
||||
Same goes with reading. Only one row at a time is stored in memory. A special technique is used to handle shared strings in XLSX, storing them - if needed - into several small temporary files that allows fast access.
|
||||
|
||||
#### How long does it take to generate a file with X rows?
|
||||
|
||||
Here are a few numbers regarding the performance of Spout:
|
||||
|
||||
| Type | Action | 2,000 rows (6,000 cells) | 200,000 rows (600,000 cells) | 2,000,000 rows (6,000,000 cells) |
|
||||
|------|-------------------------------|--------------------------|------------------------------|----------------------------------|
|
||||
| CSV | Read | < 1 second | 4 seconds | 2-3 minutes |
|
||||
| | Write | < 1 second | 2 seconds | 2-3 minutes |
|
||||
| XLSX | Read<br>*inline strings* | < 1 second | 35-40 seconds | 18-20 minutes |
|
||||
| | Read<br>*shared strings* | 1 second | 1-2 minutes | 35-40 minutes |
|
||||
| | Write | 1 second | 20-25 seconds | 8-10 minutes |
|
||||
| ODS | Read | 1 second | 1-2 minutes | 5-6 minutes |
|
||||
| | Write | < 1 second | 35-40 seconds | 5-6 minutes |
|
||||
|
||||
#### Does Spout support charts or formulas?
|
||||
|
||||
No. This is a compromise to keep memory usage low. Charts and formulas requires data to be kept in memory in order to be used.
|
||||
So the larger the file would be, the more memory would be consumed, preventing your code to scale well.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
Need to contact us directly? Email oss@box.com and be sure to include the name of this project in the subject.
|
||||
|
||||
You can also ask questions, submit new features ideas or discuss about Spout in the chat room:<br>
|
||||
You can ask questions, submit new features ideas or discuss about Spout in the chat room:<br>
|
||||
[](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
|
||||
|
||||
## Copyright and License
|
||||
|
||||
Copyright 2015 Box, Inc. All rights reserved.
|
||||
Copyright 2017 Box, Inc. All rights reserved.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
|
||||
+1
-2
@@ -14,8 +14,7 @@
|
||||
"require": {
|
||||
"php": ">=5.4.0",
|
||||
"ext-zip": "*",
|
||||
"ext-xmlreader" : "*",
|
||||
"ext-simplexml": "*"
|
||||
"ext-xmlreader" : "*"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^4.8.0"
|
||||
|
||||
@@ -26,10 +26,13 @@ class ODS implements EscaperInterface
|
||||
// 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
|
||||
// Otherwise control characters like a vertical tab "\v" will make the XML document unreadable by the XML processor
|
||||
// @link https://github.com/box/spout/issues/329
|
||||
$replacedString = htmlspecialchars($string, ENT_QUOTES | ENT_DISALLOWED);
|
||||
$replacedString = htmlspecialchars($string, ENT_NOQUOTES | ENT_DISALLOWED);
|
||||
} else {
|
||||
// We are on hhvm or any other engine that does not support ENT_DISALLOWED
|
||||
$escapedString = htmlspecialchars($string, ENT_QUOTES);
|
||||
// We are on hhvm or any other engine that does not support ENT_DISALLOWED.
|
||||
//
|
||||
// @NOTE: Using ENT_NOQUOTES as only XML entities ('<', '>', '&') need to be encoded.
|
||||
// Single and double quotes can be left as is.
|
||||
$escapedString = htmlspecialchars($string, ENT_NOQUOTES);
|
||||
|
||||
// control characters values are from 0 to 1F (hex values) in the ASCII table
|
||||
// some characters should not be escaped though: "\t", "\r" and "\n".
|
||||
@@ -52,6 +55,12 @@ class ODS implements EscaperInterface
|
||||
*/
|
||||
public function unescape($string)
|
||||
{
|
||||
return htmlspecialchars_decode($string, ENT_QUOTES);
|
||||
// ==============
|
||||
// = WARNING =
|
||||
// ==============
|
||||
// It is assumed that the given string has already had its XML entities decoded.
|
||||
// This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
|
||||
// Therefore there is no need to call "htmlspecialchars_decode()".
|
||||
return $string;
|
||||
}
|
||||
}
|
||||
@@ -42,7 +42,9 @@ class XLSX implements EscaperInterface
|
||||
public function escape($string)
|
||||
{
|
||||
$escapedString = $this->escapeControlCharacters($string);
|
||||
$escapedString = htmlspecialchars($escapedString, ENT_QUOTES);
|
||||
// @NOTE: Using ENT_NOQUOTES as only XML entities ('<', '>', '&') need to be encoded.
|
||||
// Single and double quotes can be left as is.
|
||||
$escapedString = htmlspecialchars($escapedString, ENT_NOQUOTES);
|
||||
|
||||
return $escapedString;
|
||||
}
|
||||
@@ -55,8 +57,13 @@ class XLSX implements EscaperInterface
|
||||
*/
|
||||
public function unescape($string)
|
||||
{
|
||||
$unescapedString = htmlspecialchars_decode($string, ENT_QUOTES);
|
||||
$unescapedString = $this->unescapeControlCharacters($unescapedString);
|
||||
// ==============
|
||||
// = WARNING =
|
||||
// ==============
|
||||
// It is assumed that the given string has already had its XML entities decoded.
|
||||
// This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
|
||||
// Therefore there is no need to call "htmlspecialchars_decode()".
|
||||
$unescapedString = $this->unescapeControlCharacters($string);
|
||||
|
||||
return $unescapedString;
|
||||
}
|
||||
|
||||
@@ -32,4 +32,31 @@ class Sheet implements SheetInterface
|
||||
{
|
||||
return $this->rowIterator;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return int Index of the sheet
|
||||
*/
|
||||
public function getIndex()
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return string Name of the sheet - empty string since CSV does not support that
|
||||
*/
|
||||
public function getName()
|
||||
{
|
||||
return '';
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Always TRUE as there is only one sheet
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -26,7 +26,7 @@ class CellValueFormatter
|
||||
const XML_NODE_A = 'text:a';
|
||||
const XML_NODE_SPAN = 'text:span';
|
||||
|
||||
/** Definition of XML attribute used to parse data */
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_TYPE = 'office:value-type';
|
||||
const XML_ATTRIBUTE_VALUE = 'office:value';
|
||||
const XML_ATTRIBUTE_BOOLEAN_VALUE = 'office:boolean-value';
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\ODS\Helper;
|
||||
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
* Class SettingsHelper
|
||||
* This class provides helper functions to extract data from the "settings.xml" file.
|
||||
*
|
||||
* @package Box\Spout\Reader\ODS\Helper
|
||||
*/
|
||||
class SettingsHelper
|
||||
{
|
||||
const SETTINGS_XML_FILE_PATH = 'settings.xml';
|
||||
|
||||
/** Definition of XML nodes name and attribute used to parse settings data */
|
||||
const XML_NODE_CONFIG_ITEM = 'config:config-item';
|
||||
const XML_ATTRIBUTE_CONFIG_NAME = 'config:name';
|
||||
const XML_ATTRIBUTE_VALUE_ACTIVE_TABLE = 'ActiveTable';
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @return string|null Name of the sheet that was defined as active or NULL if none found
|
||||
*/
|
||||
public function getActiveSheetName($filePath)
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($filePath, self::SETTINGS_XML_FILE_PATH) === false) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$activeSheetName = null;
|
||||
|
||||
try {
|
||||
while ($xmlReader->readUntilNodeFound(self::XML_NODE_CONFIG_ITEM)) {
|
||||
if ($xmlReader->getAttribute(self::XML_ATTRIBUTE_CONFIG_NAME) === self::XML_ATTRIBUTE_VALUE_ACTIVE_TABLE) {
|
||||
$activeSheetName = $xmlReader->readString();
|
||||
break;
|
||||
}
|
||||
}
|
||||
} catch (XMLProcessingException $exception) {
|
||||
// do nothing
|
||||
}
|
||||
|
||||
$xmlReader->close();
|
||||
|
||||
return $activeSheetName;
|
||||
}
|
||||
}
|
||||
@@ -202,6 +202,7 @@ class RowIterator implements IteratorInterface
|
||||
{
|
||||
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
|
||||
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$node = $xmlReader->expand();
|
||||
$currentCellValue = $this->getCellValue($node);
|
||||
|
||||
|
||||
@@ -25,17 +25,22 @@ class Sheet implements SheetInterface
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/** @var bool Whether the sheet was the active one */
|
||||
protected $isActive;
|
||||
|
||||
/**
|
||||
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
|
||||
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
* @param string $sheetName Name of the sheet
|
||||
* @param bool $isSheetActive Whether the sheet was defined as active
|
||||
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
*/
|
||||
public function __construct($xmlReader, $sheetIndex, $sheetName, $options)
|
||||
public function __construct($xmlReader, $sheetIndex, $sheetName, $isSheetActive, $options)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($xmlReader, $options);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
$this->isActive = $isSheetActive;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -64,4 +69,13 @@ class Sheet implements SheetInterface
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Whether the sheet was defined as active
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\ODS;
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\IteratorInterface;
|
||||
use Box\Spout\Reader\ODS\Helper\SettingsHelper;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
|
||||
/**
|
||||
@@ -39,6 +40,9 @@ class SheetIterator implements IteratorInterface
|
||||
/** @var int The index of the sheet being read (zero-based) */
|
||||
protected $currentSheetIndex;
|
||||
|
||||
/** @var string The name of the sheet that was defined as active */
|
||||
protected $activeSheetName;
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the file to be read
|
||||
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
|
||||
@@ -52,6 +56,9 @@ class SheetIterator implements IteratorInterface
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
|
||||
|
||||
$settingsHelper = new SettingsHelper();
|
||||
$this->activeSheetName = $settingsHelper->getActiveSheetName($filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -115,8 +122,27 @@ class SheetIterator implements IteratorInterface
|
||||
{
|
||||
$escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
|
||||
$sheetName = $this->escaper->unescape($escapedSheetName);
|
||||
$isActiveSheet = $this->isActiveSheet($sheetName, $this->currentSheetIndex, $this->activeSheetName);
|
||||
|
||||
return new Sheet($this->xmlReader, $this->currentSheetIndex, $sheetName, $this->options);
|
||||
return new Sheet($this->xmlReader, $this->currentSheetIndex, $sheetName, $isActiveSheet, $this->options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current sheet was defined as the active one
|
||||
*
|
||||
* @param string $sheetName Name of the current sheet
|
||||
* @param int $sheetIndex Index of the current sheet
|
||||
* @param string|null Name of the sheet that was defined as active or NULL if none defined
|
||||
* @return bool Whether the current sheet was defined as the active one
|
||||
*/
|
||||
private function isActiveSheet($sheetName, $sheetIndex, $activeSheetName)
|
||||
{
|
||||
// The given sheet is active if its name matches the defined active sheet's name
|
||||
// or if no information about the active sheet was found, it defaults to the first sheet.
|
||||
return (
|
||||
($activeSheetName === null && $sheetIndex === 0) ||
|
||||
($activeSheetName === $sheetName)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Wrapper;
|
||||
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
|
||||
|
||||
/**
|
||||
* Class SimpleXMLElement
|
||||
* Wrapper around the built-in SimpleXMLElement. This class does not extend \SimpleXMLElement
|
||||
* because it its constructor is final... Instead, it is used as a passthrough.
|
||||
* @see \SimpleXMLElement
|
||||
*
|
||||
* @package Box\Spout\Reader\Wrapper
|
||||
*/
|
||||
class SimpleXMLElement
|
||||
{
|
||||
use XMLInternalErrorsHelper;
|
||||
|
||||
/** @var \SimpleXMLElement Instance of the wrapped SimpleXMLElement object */
|
||||
protected $simpleXMLElement;
|
||||
|
||||
/**
|
||||
* Creates a new SimpleXMLElement object
|
||||
* @see \SimpleXMLElement::__construct
|
||||
*
|
||||
* @param string $xmlData A well-formed XML string
|
||||
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If the XML string is not well-formed
|
||||
*/
|
||||
public function __construct($xmlData)
|
||||
{
|
||||
$this->useXMLInternalErrors();
|
||||
|
||||
try {
|
||||
$this->simpleXMLElement = new \SimpleXMLElement($xmlData);
|
||||
} catch (\Exception $exception) {
|
||||
// if the data is invalid, the constructor will throw an Exception
|
||||
$this->resetXMLInternalErrorsSetting();
|
||||
throw new XMLProcessingException($this->getLastXMLErrorMessage());
|
||||
}
|
||||
|
||||
$this->resetXMLInternalErrorsSetting();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the attribute for the given name.
|
||||
*
|
||||
* @param string $name Attribute name
|
||||
* @param string|null|void $namespace An optional namespace for the retrieved attributes
|
||||
* @return string|null The attribute value or NULL if attribute not found
|
||||
*/
|
||||
public function getAttribute($name, $namespace = null)
|
||||
{
|
||||
$isPrefix = ($namespace !== null);
|
||||
$attributes = $this->simpleXMLElement->attributes($namespace, $isPrefix);
|
||||
$attributeValue = $attributes->{$name};
|
||||
|
||||
return ($attributeValue !== null) ? (string) $attributeValue : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a prefix/ns context for the next XPath query
|
||||
* @see \SimpleXMLElement::registerXPathNamespace
|
||||
*
|
||||
* @param string $prefix The namespace prefix to use in the XPath query for the namespace given in "namespace".
|
||||
* @param string $namespace The namespace to use for the XPath query. This must match a namespace in
|
||||
* use by the XML document or the XPath query using "prefix" will not return any results.
|
||||
* @return bool TRUE on success or FALSE on failure.
|
||||
*/
|
||||
public function registerXPathNamespace($prefix, $namespace)
|
||||
{
|
||||
return $this->simpleXMLElement->registerXPathNamespace($prefix, $namespace);
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs XPath query on XML data
|
||||
* @see \SimpleXMLElement::xpath
|
||||
*
|
||||
* @param string $path An XPath path
|
||||
* @return SimpleXMLElement[]|bool an array of SimpleXMLElement objects or FALSE in case of an error.
|
||||
*/
|
||||
public function xpath($path)
|
||||
{
|
||||
$elements = $this->simpleXMLElement->xpath($path);
|
||||
|
||||
if ($elements !== false) {
|
||||
$wrappedElements = [];
|
||||
foreach ($elements as $element) {
|
||||
$wrappedElement = $this->wrapSimpleXMLElement($element);
|
||||
|
||||
if ($wrappedElement !== null) {
|
||||
$wrappedElements[] = $this->wrapSimpleXMLElement($element);
|
||||
}
|
||||
}
|
||||
|
||||
$elements = $wrappedElements;
|
||||
}
|
||||
|
||||
return $elements;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps the given element into an instance of the wrapper
|
||||
*
|
||||
* @param \SimpleXMLElement $element Element to be wrapped
|
||||
* @return SimpleXMLElement|null The wrapped element or NULL if the given element is invalid
|
||||
*/
|
||||
protected function wrapSimpleXMLElement(\SimpleXMLElement $element)
|
||||
{
|
||||
$wrappedElement = null;
|
||||
$elementAsXML = $element->asXML();
|
||||
|
||||
if ($elementAsXML !== false) {
|
||||
$wrappedElement = new SimpleXMLElement($elementAsXML);
|
||||
}
|
||||
|
||||
return $wrappedElement;
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all nodes matching the given XPath query.
|
||||
* It does not map to any \SimpleXMLElement function.
|
||||
*
|
||||
* @param string $path An XPath path
|
||||
* @return void
|
||||
*/
|
||||
public function removeNodesMatchingXPath($path)
|
||||
{
|
||||
$nodesToRemove = $this->simpleXMLElement->xpath($path);
|
||||
|
||||
foreach ($nodesToRemove as $nodeToRemove) {
|
||||
unset($nodeToRemove[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the first child matching the given tag name
|
||||
*
|
||||
* @param string $tagName
|
||||
* @return SimpleXMLElement|null The first child matching the tag name or NULL if none found
|
||||
*/
|
||||
public function getFirstChildByTagName($tagName)
|
||||
{
|
||||
$doesElementExist = isset($this->simpleXMLElement->{$tagName});
|
||||
|
||||
/** @var \SimpleXMLElement $realElement */
|
||||
$realElement = $this->simpleXMLElement->{$tagName};
|
||||
|
||||
return $doesElementExist ? $this->wrapSimpleXMLElement($realElement) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string
|
||||
*/
|
||||
public function __toString()
|
||||
{
|
||||
return $this->simpleXMLElement->__toString();
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Wrapper;
|
||||
use DOMNode;
|
||||
|
||||
|
||||
/**
|
||||
@@ -28,13 +29,10 @@ class XMLReader extends \XMLReader
|
||||
$wasOpenSuccessful = false;
|
||||
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
|
||||
|
||||
// HHVM does not check if file exists within zip file
|
||||
// @link https://github.com/facebook/hhvm/issues/5779
|
||||
if ($this->isRunningHHVM()) {
|
||||
if ($this->fileExistsWithinZip($realPathURI)) {
|
||||
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||
}
|
||||
} else {
|
||||
// We need to check first that the file we are trying to read really exist because:
|
||||
// - PHP emits a warning when trying to open a file that does not exist.
|
||||
// - HHVM does not check if file exists within zip file (@link https://github.com/facebook/hhvm/issues/5779)
|
||||
if ($this->fileExistsWithinZip($realPathURI)) {
|
||||
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
|
||||
}
|
||||
|
||||
@@ -54,16 +52,6 @@ class XMLReader extends \XMLReader
|
||||
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the current environment is HHVM
|
||||
*
|
||||
* @return bool TRUE if running on HHVM, FALSE otherwise
|
||||
*/
|
||||
protected function isRunningHHVM()
|
||||
{
|
||||
return defined('HHVM_VERSION');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the file at the given location exists
|
||||
*
|
||||
@@ -176,4 +164,12 @@ class XMLReader extends \XMLReader
|
||||
|
||||
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The name of the current node, un-prefixed
|
||||
*/
|
||||
public function getCurrentNodeName()
|
||||
{
|
||||
return $this->localName;
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,6 @@ namespace Box\Spout\Reader\XLSX\Helper;
|
||||
|
||||
use Box\Spout\Common\Exception\IOException;
|
||||
use Box\Spout\Reader\Exception\XMLProcessingException;
|
||||
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
|
||||
use Box\Spout\Reader\Wrapper\XMLReader;
|
||||
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyFactory;
|
||||
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyInterface;
|
||||
@@ -23,6 +22,18 @@ class SharedStringsHelper
|
||||
/** Main namespace for the sharedStrings.xml file */
|
||||
const MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
|
||||
|
||||
/** Definition of XML nodes names used to parse data */
|
||||
const XML_NODE_SST = 'sst';
|
||||
const XML_NODE_SI = 'si';
|
||||
const XML_NODE_R = 'r';
|
||||
const XML_NODE_T = 't';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_COUNT = 'count';
|
||||
const XML_ATTRIBUTE_UNIQUE_COUNT = 'uniqueCount';
|
||||
const XML_ATTRIBUTE_XML_SPACE = 'xml:space';
|
||||
const XML_ATTRIBUTE_VALUE_PRESERVE = 'preserve';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
@@ -69,8 +80,6 @@ class SharedStringsHelper
|
||||
*
|
||||
* The XML file can be really big with sheets containing a lot of data. That is why
|
||||
* we need to use a XML reader that provides streaming like the XMLReader library.
|
||||
* Please note that SimpleXML does not provide such a functionality but since it is faster
|
||||
* and more handy to parse few XML nodes, it is used in combination with XMLReader for that purpose.
|
||||
*
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml can't be read
|
||||
@@ -79,11 +88,8 @@ class SharedStringsHelper
|
||||
{
|
||||
$xmlReader = new XMLReader();
|
||||
$sharedStringIndex = 0;
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
|
||||
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
||||
if ($xmlReader->open($sharedStringsFilePath) === false) {
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::SHARED_STRINGS_XML_FILE_PATH) === false) {
|
||||
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
|
||||
}
|
||||
|
||||
@@ -91,14 +97,14 @@ class SharedStringsHelper
|
||||
$sharedStringsUniqueCount = $this->getSharedStringsUniqueCount($xmlReader);
|
||||
$this->cachingStrategy = $this->getBestSharedStringsCachingStrategy($sharedStringsUniqueCount);
|
||||
|
||||
$xmlReader->readUntilNodeFound('si');
|
||||
$xmlReader->readUntilNodeFound(self::XML_NODE_SI);
|
||||
|
||||
while ($xmlReader->name === 'si') {
|
||||
$this->processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper);
|
||||
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SI) {
|
||||
$this->processSharedStringsItem($xmlReader, $sharedStringIndex);
|
||||
$sharedStringIndex++;
|
||||
|
||||
// jump to the next 'si' tag
|
||||
$xmlReader->next('si');
|
||||
// jump to the next '<si>' tag
|
||||
$xmlReader->next(self::XML_NODE_SI);
|
||||
}
|
||||
|
||||
$this->cachingStrategy->closeCache();
|
||||
@@ -110,14 +116,6 @@ class SharedStringsHelper
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The path to the shared strings XML file
|
||||
*/
|
||||
protected function getSharedStringsFilePath()
|
||||
{
|
||||
return 'zip://' . $this->filePath . '#' . self::SHARED_STRINGS_XML_FILE_PATH;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the shared strings unique count, as specified in <sst> tag.
|
||||
*
|
||||
@@ -127,19 +125,19 @@ class SharedStringsHelper
|
||||
*/
|
||||
protected function getSharedStringsUniqueCount($xmlReader)
|
||||
{
|
||||
$xmlReader->next('sst');
|
||||
$xmlReader->next(self::XML_NODE_SST);
|
||||
|
||||
// Iterate over the "sst" elements to get the actual "sst ELEMENT" (skips any DOCTYPE)
|
||||
while ($xmlReader->name === 'sst' && $xmlReader->nodeType !== XMLReader::ELEMENT) {
|
||||
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SST && $xmlReader->nodeType !== XMLReader::ELEMENT) {
|
||||
$xmlReader->read();
|
||||
}
|
||||
|
||||
$uniqueCount = $xmlReader->getAttribute('uniqueCount');
|
||||
$uniqueCount = $xmlReader->getAttribute(self::XML_ATTRIBUTE_UNIQUE_COUNT);
|
||||
|
||||
// some software do not add the "uniqueCount" attribute but only use the "count" one
|
||||
// @see https://github.com/box/spout/issues/254
|
||||
if ($uniqueCount === null) {
|
||||
$uniqueCount = $xmlReader->getAttribute('count');
|
||||
$uniqueCount = $xmlReader->getAttribute(self::XML_ATTRIBUTE_COUNT);
|
||||
}
|
||||
|
||||
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
|
||||
@@ -160,99 +158,54 @@ class SharedStringsHelper
|
||||
/**
|
||||
* Processes the shared strings item XML node which the given XML reader is positioned on.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on a "<si>" node
|
||||
* @param int $sharedStringIndex Index of the processed shared strings item
|
||||
* @param \Box\Spout\Common\Escaper\XLSX $escaper Helper to escape values
|
||||
* @return void
|
||||
*/
|
||||
protected function processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper)
|
||||
protected function processSharedStringsItem($xmlReader, $sharedStringIndex)
|
||||
{
|
||||
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
|
||||
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
|
||||
$sharedStringValue = '';
|
||||
|
||||
// removes nodes that should not be read, like the pronunciation of the Kanji characters
|
||||
$cleanNode = $this->removeSuperfluousTextNodes($node);
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$siNode = $xmlReader->expand();
|
||||
$textNodes = $siNode->getElementsByTagName(self::XML_NODE_T);
|
||||
|
||||
// find all text nodes "t"; there can be multiple if the cell contains formatting
|
||||
$textNodes = $cleanNode->xpath('//ns:t');
|
||||
foreach ($textNodes as $textNode) {
|
||||
if ($this->shouldExtractTextNodeValue($textNode)) {
|
||||
$textNodeValue = $textNode->nodeValue;
|
||||
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
|
||||
|
||||
$textValue = $this->extractTextValueForNodes($textNodes);
|
||||
$unescapedTextValue = $escaper->unescape($textValue);
|
||||
$sharedStringValue .= ($shouldPreserveWhitespace) ? $textNodeValue : trim($textNodeValue);
|
||||
}
|
||||
}
|
||||
|
||||
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
|
||||
$this->cachingStrategy->addStringForIndex($sharedStringValue, $sharedStringIndex);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a SimpleXMLElement node from the current node in the given XMLReader instance.
|
||||
* This is to simplify the parsing of the subtree.
|
||||
* Not all text nodes' values must be extracted.
|
||||
* Some text nodes are part of a node describing the pronunciation for instance.
|
||||
* We'll only consider the nodes whose parents are "<si>" or "<r>".
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader
|
||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the current node cannot be read
|
||||
* @param \DOMElement $textNode Text node to check
|
||||
* @return bool Whether the given text node's value must be extracted
|
||||
*/
|
||||
protected function getSimpleXmlElementNodeFromXMLReader($xmlReader)
|
||||
protected function shouldExtractTextNodeValue($textNode)
|
||||
{
|
||||
$node = null;
|
||||
try {
|
||||
$node = new SimpleXMLElement($xmlReader->readOuterXml());
|
||||
} catch (XMLProcessingException $exception) {
|
||||
throw new IOException("The sharedStrings.xml file contains unreadable data [{$exception->getMessage()}].");
|
||||
}
|
||||
|
||||
return $node;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes nodes that should not be read, like the pronunciation of the Kanji characters.
|
||||
* By keeping them, their text content would be added to the read string.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\SimpleXMLElement $parentNode Parent node that may contain nodes to remove
|
||||
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement Cleaned parent node
|
||||
*/
|
||||
protected function removeSuperfluousTextNodes($parentNode)
|
||||
{
|
||||
$tagsToRemove = [
|
||||
'rPh', // Pronunciation of the text
|
||||
'pPr', // Paragraph Properties / Previous Paragraph Properties
|
||||
'rPr', // Run Properties for the Paragraph Mark / Previous Run Properties for the Paragraph Mark
|
||||
];
|
||||
|
||||
foreach ($tagsToRemove as $tagToRemove) {
|
||||
$xpath = '//ns:' . $tagToRemove;
|
||||
$parentNode->removeNodesMatchingXPath($xpath);
|
||||
}
|
||||
|
||||
return $parentNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param array $textNodes Text XML nodes ("<t>")
|
||||
* @return string The value associated with the given text node(s)
|
||||
*/
|
||||
protected function extractTextValueForNodes($textNodes)
|
||||
{
|
||||
$textValue = '';
|
||||
|
||||
foreach ($textNodes as $nodeIndex => $textNode) {
|
||||
$textNodeAsString = $textNode->__toString();
|
||||
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
|
||||
|
||||
$textValue .= ($shouldPreserveWhitespace) ? $textNodeAsString : trim($textNodeAsString);
|
||||
}
|
||||
|
||||
return $textValue;
|
||||
$parentTagName = $textNode->parentNode->localName;
|
||||
return ($parentTagName === self::XML_NODE_SI || $parentTagName === self::XML_NODE_R);
|
||||
}
|
||||
|
||||
/**
|
||||
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\SimpleXMLElement $textNode The text node element (<t>) whitespace may be preserved
|
||||
* @param \DOMElement $textNode The text node element (<t>) whose whitespace may be preserved
|
||||
* @return bool Whether whitespace should be preserved
|
||||
*/
|
||||
protected function shouldPreserveWhitespace($textNode)
|
||||
{
|
||||
$spaceValue = $textNode->getAttribute('space', 'xml');
|
||||
return ($spaceValue === 'preserve');
|
||||
$spaceValue = $textNode->getAttribute(self::XML_ATTRIBUTE_XML_SPACE);
|
||||
return ($spaceValue === self::XML_ATTRIBUTE_VALUE_PRESERVE);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -17,6 +17,19 @@ class SheetHelper
|
||||
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
|
||||
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
|
||||
|
||||
/** Definition of XML node names used to parse data */
|
||||
const XML_NODE_WORKBOOK_VIEW = 'workbookView';
|
||||
const XML_NODE_SHEET = 'sheet';
|
||||
const XML_NODE_SHEETS = 'sheets';
|
||||
const XML_NODE_RELATIONSHIP = 'Relationship';
|
||||
|
||||
/** Definition of XML attributes used to parse data */
|
||||
const XML_ATTRIBUTE_ACTIVE_TAB = 'activeTab';
|
||||
const XML_ATTRIBUTE_R_ID = 'r:id';
|
||||
const XML_ATTRIBUTE_NAME = 'name';
|
||||
const XML_ATTRIBUTE_ID = 'Id';
|
||||
const XML_ATTRIBUTE_TARGET = 'Target';
|
||||
|
||||
/** @var string Path of the XLSX file being read */
|
||||
protected $filePath;
|
||||
|
||||
@@ -53,14 +66,20 @@ class SheetHelper
|
||||
{
|
||||
$sheets = [];
|
||||
$sheetIndex = 0;
|
||||
$activeSheetIndex = 0; // By default, the first sheet is active
|
||||
|
||||
$xmlReader = new XMLReader();
|
||||
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
|
||||
while ($xmlReader->read()) {
|
||||
if ($xmlReader->isPositionedOnStartingNode('sheet')) {
|
||||
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex);
|
||||
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_WORKBOOK_VIEW)) {
|
||||
// The "workbookView" node is located before "sheet" nodes, ensuring that
|
||||
// the active sheet is known before parsing sheets data.
|
||||
$activeSheetIndex = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_ACTIVE_TAB);
|
||||
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_SHEET)) {
|
||||
$isSheetActive = ($sheetIndex === $activeSheetIndex);
|
||||
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex, $isSheetActive);
|
||||
$sheetIndex++;
|
||||
} else if ($xmlReader->isPositionedOnEndingNode('sheets')) {
|
||||
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_SHEETS)) {
|
||||
// stop reading once all sheets have been read
|
||||
break;
|
||||
}
|
||||
@@ -79,12 +98,13 @@ class SheetHelper
|
||||
*
|
||||
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReaderOnSheetNode XML Reader instance, pointing on the node describing the sheet, as defined in "workbook.xml"
|
||||
* @param int $sheetIndexZeroBased Index of the sheet, based on order of appearance in the workbook (zero-based)
|
||||
* @param bool $isSheetActive Whether this sheet was defined as active
|
||||
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
|
||||
*/
|
||||
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased)
|
||||
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased, $isSheetActive)
|
||||
{
|
||||
$sheetId = $xmlReaderOnSheetNode->getAttribute('r:id');
|
||||
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute('name');
|
||||
$sheetId = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_R_ID);
|
||||
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME);
|
||||
|
||||
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
|
||||
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
|
||||
@@ -92,7 +112,11 @@ class SheetHelper
|
||||
|
||||
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
|
||||
|
||||
return new Sheet($this->filePath, $sheetDataXMLFilePath, $sheetIndexZeroBased, $sheetName, $this->options, $this->sharedStringsHelper);
|
||||
return new Sheet(
|
||||
$this->filePath, $sheetDataXMLFilePath,
|
||||
$sheetIndexZeroBased, $sheetName, $isSheetActive,
|
||||
$this->options, $this->sharedStringsHelper
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -107,13 +131,13 @@ class SheetHelper
|
||||
$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 ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_RELATIONSHIP)) {
|
||||
$relationshipSheetId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ID);
|
||||
|
||||
if ($relationshipSheetId === $sheetId) {
|
||||
// In workbook.xml.rels, it is only "worksheets/sheet1.xml"
|
||||
// In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml"
|
||||
$sheetDataXMLFilePath = $xmlReader->getAttribute('Target');
|
||||
$sheetDataXMLFilePath = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TARGET);
|
||||
|
||||
// sometimes, the sheet data file path already contains "/xl/"...
|
||||
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
|
||||
|
||||
@@ -128,8 +128,7 @@ class RowIterator implements IteratorInterface
|
||||
{
|
||||
$this->xmlReader->close();
|
||||
|
||||
$sheetDataFilePath = 'zip://' . $this->filePath . '#' . $this->sheetDataXMLFilePath;
|
||||
if ($this->xmlReader->open($sheetDataFilePath) === false) {
|
||||
if ($this->xmlReader->openFileInZip($this->filePath, $this->sheetDataXMLFilePath) === false) {
|
||||
throw new IOException("Could not open \"{$this->sheetDataXMLFilePath}\".");
|
||||
}
|
||||
|
||||
@@ -260,6 +259,7 @@ class RowIterator implements IteratorInterface
|
||||
{
|
||||
$currentColumnIndex = $this->getColumnIndex($xmlReader);
|
||||
|
||||
// NOTE: expand() will automatically decode all XML entities of the child nodes
|
||||
$node = $xmlReader->expand();
|
||||
$this->currentlyProcessedRowData[$currentColumnIndex] = $this->getCellValue($node);
|
||||
$this->lastColumnIndexProcessed = $currentColumnIndex;
|
||||
@@ -348,7 +348,7 @@ class RowIterator implements IteratorInterface
|
||||
*/
|
||||
protected function isEmptyRow($rowData)
|
||||
{
|
||||
return (count($rowData) === 1 && $rowData[0] === '');
|
||||
return (count($rowData) === 1 && key($rowData) === '');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -21,19 +21,24 @@ class Sheet implements SheetInterface
|
||||
/** @var string Name of the sheet */
|
||||
protected $name;
|
||||
|
||||
/** @var bool Whether the sheet was the active one */
|
||||
protected $isActive;
|
||||
|
||||
/**
|
||||
* @param string $filePath Path of the XLSX file being read
|
||||
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
|
||||
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
|
||||
* @param string $sheetName Name of the sheet
|
||||
* @param bool $isSheetActive Whether the sheet was defined as active
|
||||
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
|
||||
* @param Helper\SharedStringsHelper Helper to work with shared strings
|
||||
*/
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $sheetIndex, $sheetName, $options, $sharedStringsHelper)
|
||||
public function __construct($filePath, $sheetDataXMLFilePath, $sheetIndex, $sheetName, $isSheetActive, $options, $sharedStringsHelper)
|
||||
{
|
||||
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
|
||||
$this->index = $sheetIndex;
|
||||
$this->name = $sheetName;
|
||||
$this->isActive = $isSheetActive;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -62,4 +67,13 @@ class Sheet implements SheetInterface
|
||||
{
|
||||
return $this->name;
|
||||
}
|
||||
|
||||
/**
|
||||
* @api
|
||||
* @return bool Whether the sheet was defined as active
|
||||
*/
|
||||
public function isActive()
|
||||
{
|
||||
return $this->isActive;
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ interface WriterInterface
|
||||
* @param array $dataRow Array containing data to be streamed.
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @return WriterInterface
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yetthe writer
|
||||
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
|
||||
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
|
||||
*/
|
||||
public function addRow(array $dataRow);
|
||||
|
||||
@@ -159,7 +159,7 @@ EOD;
|
||||
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
|
||||
* @return bool Whether the given row is empty
|
||||
*/
|
||||
private function isEmptyRow($dataRow)
|
||||
protected function isEmptyRow($dataRow)
|
||||
{
|
||||
$numCells = count($dataRow);
|
||||
// using "reset()" instead of "$dataRow[0]" because $dataRow can be an associative array
|
||||
@@ -176,7 +176,7 @@ EOD;
|
||||
* @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
|
||||
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
|
||||
*/
|
||||
private function addNonEmptyRow($dataRow, $style)
|
||||
protected function addNonEmptyRow($dataRow, $style)
|
||||
{
|
||||
$cellNumber = 0;
|
||||
$rowIndex = $this->lastWrittenRowIndex + 1;
|
||||
@@ -207,7 +207,7 @@ EOD;
|
||||
* @return string
|
||||
* @throws InvalidArgumentException If the given value cannot be processed
|
||||
*/
|
||||
private function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
|
||||
protected function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
|
||||
{
|
||||
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
|
||||
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
|
||||
@@ -241,7 +241,7 @@ EOD;
|
||||
* @return string The XML fragment representing the cell
|
||||
* @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
|
||||
*/
|
||||
private function getCellXMLFragmentForNonEmptyString($cellValue)
|
||||
protected function getCellXMLFragmentForNonEmptyString($cellValue)
|
||||
{
|
||||
if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
|
||||
throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
|
||||
|
||||
@@ -16,7 +16,7 @@ class ODSTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
return [
|
||||
['test', 'test'],
|
||||
['carl\'s "pokemon"', 'carl's "pokemon"'],
|
||||
['carl\'s "pokemon"', 'carl\'s "pokemon"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
|
||||
@@ -16,7 +16,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
return [
|
||||
['test', 'test'],
|
||||
['adam\'s "car"', 'adam's "car"'],
|
||||
['adam\'s "car"', 'adam\'s "car"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
@@ -25,7 +25,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
['_x0000_', '_x005F_x0000_'],
|
||||
[chr(21), '_x0015_'],
|
||||
['control '.chr(21).' character', 'control _x0015_ character'],
|
||||
['control\'s '.chr(21).' "character"', 'control's _x0015_ "character"'],
|
||||
['control\'s '.chr(21).' "character"', 'control\'s _x0015_ "character"'],
|
||||
];
|
||||
}
|
||||
|
||||
@@ -52,7 +52,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
return [
|
||||
['test', 'test'],
|
||||
['adam's "car"', 'adam\'s "car"'],
|
||||
['adam's "car"', 'adam's "car"'],
|
||||
["\n", "\n"],
|
||||
["\r", "\r"],
|
||||
["\t", "\t"],
|
||||
@@ -61,7 +61,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
['_x005F_x0000_', '_x0000_'],
|
||||
['_x0015_', chr(21)],
|
||||
['control _x0015_ character', 'control '.chr(21).' character'],
|
||||
['control's _x0015_ "character"', 'control\'s '.chr(21).' "character"'],
|
||||
['control's _x0015_ "character"', 'control's '.chr(21).' "character"'],
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\CSV;
|
||||
|
||||
use Box\Spout\Common\Type;
|
||||
use Box\Spout\Reader\ReaderFactory;
|
||||
use Box\Spout\TestUsingResource;
|
||||
|
||||
/**
|
||||
* Class SheetTest
|
||||
*
|
||||
* @package Box\Spout\Reader\CSV
|
||||
*/
|
||||
class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
use TestUsingResource;
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldReturnCorrectSheetInfos()
|
||||
{
|
||||
$sheet = $this->openFileAndReturnSheet('csv_standard.csv');
|
||||
|
||||
$this->assertEquals('', $sheet->getName());
|
||||
$this->assertEquals(0, $sheet->getIndex());
|
||||
$this->assertTrue($sheet->isActive());
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @return Sheet
|
||||
*/
|
||||
private function openFileAndReturnSheet($fileName)
|
||||
{
|
||||
$resourcePath = $this->getResourcePath($fileName);
|
||||
$reader = ReaderFactory::create(Type::CSV);
|
||||
$reader->open($resourcePath);
|
||||
|
||||
$sheet = $reader->getSheetIterator()->current();
|
||||
|
||||
$reader->close();
|
||||
|
||||
return $sheet;
|
||||
}
|
||||
}
|
||||
@@ -284,7 +284,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldBeProtectedAgainstBillionLaughAttack()
|
||||
public function testReadShouldBeProtectedAgainstBillionLaughsAttack()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
$fileName = 'attack_billion_laughs.ods';
|
||||
|
||||
@@ -18,15 +18,30 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testNextSheetShouldReturnCorrectSheetInfos()
|
||||
public function testReaderShouldReturnCorrectSheetInfos()
|
||||
{
|
||||
// NOTE: This spreadsheet has its second tab defined as active
|
||||
$sheets = $this->openFileAndReturnSheets('two_sheets_with_custom_names.ods');
|
||||
|
||||
$this->assertEquals('Sheet First', $sheets[0]->getName());
|
||||
$this->assertEquals(0, $sheets[0]->getIndex());
|
||||
$this->assertFalse($sheets[0]->isActive());
|
||||
|
||||
$this->assertEquals('Sheet Last', $sheets[1]->getName());
|
||||
$this->assertEquals(1, $sheets[1]->getIndex());
|
||||
$this->assertTrue($sheets[1]->isActive());
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReaderShouldDefineFirstSheetAsActiveByDefault()
|
||||
{
|
||||
// NOTE: This spreadsheet has no information about the active sheet
|
||||
$sheets = $this->openFileAndReturnSheets('two_sheets_with_no_settings_xml_file.ods');
|
||||
|
||||
$this->assertTrue($sheets[0]->isActive());
|
||||
$this->assertFalse($sheets[1]->isActive());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -1,127 +0,0 @@
|
||||
<?php
|
||||
|
||||
namespace Box\Spout\Reader\Wrapper;
|
||||
|
||||
use Box\Spout\TestUsingResource;
|
||||
|
||||
/**
|
||||
* Class SimpleXMLElementTest
|
||||
*
|
||||
* @package Box\Spout\Reader\Wrapper
|
||||
*/
|
||||
class SimpleXMLElementTest extends \PHPUnit_Framework_TestCase
|
||||
{
|
||||
use TestUsingResource;
|
||||
|
||||
/**
|
||||
* @expectedException \Box\Spout\Reader\Exception\XMLProcessingException
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testConstructShouldThrowExceptionIfInvalidData()
|
||||
{
|
||||
$invalidXML = '<invalid><xml></invalid>';
|
||||
new SimpleXMLElement($invalidXML);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestGetAttribute()
|
||||
{
|
||||
$xmlWithoutNamespace = <<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet foo="bar" type="test" />
|
||||
XML;
|
||||
|
||||
$xmlWithHalfNamespace = <<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet
|
||||
xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
foo="bar" r:type="test" />
|
||||
XML;
|
||||
|
||||
$xmlWithFullNamespace = <<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet
|
||||
xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"
|
||||
r:foo="bar" r:type="test" />
|
||||
XML;
|
||||
|
||||
return [
|
||||
[$xmlWithoutNamespace, null, ['foo' => 'bar', 'type' => 'test']],
|
||||
[$xmlWithHalfNamespace, null, ['foo' => 'bar', 'type' => null]],
|
||||
[$xmlWithFullNamespace, null, ['foo' => null, 'type' => null]],
|
||||
[$xmlWithoutNamespace, 'r', ['foo' => null, 'type' => null]],
|
||||
[$xmlWithHalfNamespace, 'r', ['foo' => null, 'type' => 'test']],
|
||||
[$xmlWithFullNamespace, 'r', ['foo' => 'bar', 'type' => 'test']],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestGetAttribute
|
||||
*
|
||||
* @param string $xml
|
||||
* @param string|null $namespace
|
||||
* @param array $expectedAttributes
|
||||
* @return void
|
||||
*/
|
||||
public function testGetAttribute($xml, $namespace, $expectedAttributes)
|
||||
{
|
||||
$element = new SimpleXMLElement($xml);
|
||||
|
||||
foreach ($expectedAttributes as $name => $expectedValue) {
|
||||
$value = $element->getAttribute($name, $namespace);
|
||||
$this->assertEquals($expectedValue, $value);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testXPath()
|
||||
{
|
||||
$xml = <<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet>
|
||||
<sheetData>
|
||||
<row r="1">
|
||||
<c r="A1"><v>0</v></c>
|
||||
<c r="A2"><v>1</v></c>
|
||||
</row>
|
||||
</sheetData>
|
||||
</worksheet>
|
||||
XML;
|
||||
|
||||
$element = new SimpleXMLElement($xml);
|
||||
$matchedElements = $element->xpath('//c');
|
||||
|
||||
$this->assertEquals(2, count($matchedElements));
|
||||
$this->assertTrue($matchedElements[0] instanceof SimpleXMLElement, 'The SimpleXMLElement should be wrapped');
|
||||
$this->assertEquals('A2', $matchedElements[1]->getAttribute('r'));
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testRemoveNodeMatchingXPath()
|
||||
{
|
||||
$xml = <<<XML
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<worksheet>
|
||||
<sheetData>
|
||||
<row r="1">
|
||||
<c r="A1"><v>0</v></c>
|
||||
<c r="A2"><v>1</v></c>
|
||||
</row>
|
||||
</sheetData>
|
||||
</worksheet>
|
||||
XML;
|
||||
|
||||
$element = new SimpleXMLElement($xml);
|
||||
$this->assertNotNull($element->getFirstChildByTagName('sheetData'));
|
||||
|
||||
$element->removeNodesMatchingXPath('//sheetData');
|
||||
$this->assertNull($element->getFirstChildByTagName('sheetData'));
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,22 @@ class SharedStringsHelperTest extends \PHPUnit_Framework_TestCase
|
||||
$sharedStringsHelper->cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testGetStringAtIndexShouldNotDoubleDecodeHTMLEntities()
|
||||
{
|
||||
$resourcePath = $this->getResourcePath('one_sheet_with_pre_encoded_html_entities.xlsx');
|
||||
$sharedStringsHelper = new SharedStringsHelper($resourcePath);
|
||||
|
||||
$sharedStringsHelper->extractSharedStrings();
|
||||
|
||||
$sharedString = $sharedStringsHelper->getStringAtIndex(0);
|
||||
$this->assertEquals('quote: " - ampersand: &', $sharedString);
|
||||
|
||||
$sharedStringsHelper->cleanup();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -112,6 +112,22 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportPrefixedSharedStringsXML()
|
||||
{
|
||||
// The sharedStrings.xml file of this spreadsheet is prefixed.
|
||||
// For instance, they use "<x:sst>" instead of "<sst>", etc.
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_prefixed_shared_strings_xml.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'],
|
||||
['s1--A2', 's1--B2', 's1--C2', 's1--D2', 's1--E2'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -169,6 +185,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportFilesWithRowsNotStartingAtColumnA()
|
||||
{
|
||||
// file where the row starts at column C:
|
||||
// <row r="1"><c r="C1" s="0" t="s"><v>0</v></c>...
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_row_not_starting_at_column_a.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['', '', 's1--C1', 's1--D1', 's1--E1'],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -426,42 +457,46 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals($expectedRow, $allRows[0], 'Pronunciation data should be removed.');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* @return array
|
||||
*/
|
||||
public function dataProviderForTestReadShouldBeProtectedAgainstAttacks()
|
||||
{
|
||||
return [
|
||||
['attack_billion_laughs.xlsx'],
|
||||
['attack_quadratic_blowup.xlsx'],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* @dataProvider dataProviderForTestReadShouldBeProtectedAgainstAttacks
|
||||
* @NOTE: The LIBXML_NOENT is used to ACTUALLY substitute entities (and should therefore not be used)
|
||||
*
|
||||
* @param string $fileName
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldBeProtectedAgainstAttacks($fileName)
|
||||
public function testReadShouldBeProtectedAgainstBillionLaughsAttack()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
try {
|
||||
// using @ to prevent warnings/errors from being displayed
|
||||
@$this->getAllRowsForFile($fileName);
|
||||
@$this->getAllRowsForFile('attack_billion_laughs.xlsx');
|
||||
$this->fail('An exception should have been thrown');
|
||||
} catch (IOException $exception) {
|
||||
$duration = microtime(true) - $startTime;
|
||||
$this->assertLessThan(10, $duration, 'Entities should not be expanded and therefore take more than 10 seconds to be parsed.');
|
||||
|
||||
$expectedMaxMemoryUsage = 30 * 1024 * 1024; // 30MB
|
||||
$expectedMaxMemoryUsage = 40 * 1024 * 1024; // 40MB
|
||||
$this->assertLessThan($expectedMaxMemoryUsage, memory_get_peak_usage(true), 'Entities should not be expanded and therefore consume all the memory.');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @NOTE: The LIBXML_NOENT is used to ACTUALLY substitute entities (and should therefore not be used)
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldBeProtectedAgainstQuadraticBlowupAttack()
|
||||
{
|
||||
$startTime = microtime(true);
|
||||
|
||||
$this->getAllRowsForFile('attack_quadratic_blowup.xlsx');
|
||||
|
||||
$duration = microtime(true) - $startTime;
|
||||
$this->assertLessThan(10, $duration, 'Entities should not be expanded and therefore take more than 10 seconds to be parsed.');
|
||||
|
||||
$expectedMaxMemoryUsage = 40 * 1024 * 1024; // 40MB
|
||||
$this->assertLessThan($expectedMaxMemoryUsage, memory_get_peak_usage(true), 'Entities should not be expanded and therefore consume all the memory.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
|
||||
@@ -18,15 +18,18 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testNextSheetShouldReturnCorrectSheetInfos()
|
||||
public function testReaderShouldReturnCorrectSheetInfos()
|
||||
{
|
||||
$sheets = $this->openFileAndReturnSheets('two_sheets_with_custom_names.xlsx');
|
||||
// NOTE: This spreadsheet has its second tab defined as active
|
||||
$sheets = $this->openFileAndReturnSheets('two_sheets_with_custom_names_and_custom_active_tab.xlsx');
|
||||
|
||||
$this->assertEquals('CustomName1', $sheets[0]->getName());
|
||||
$this->assertEquals(0, $sheets[0]->getIndex());
|
||||
$this->assertFalse($sheets[0]->isActive());
|
||||
|
||||
$this->assertEquals('CustomName2', $sheets[1]->getName());
|
||||
$this->assertEquals(1, $sheets[1]->getIndex());
|
||||
$this->assertTrue($sheets[1]->isActive());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -73,7 +73,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$writer = WriterFactory::create(Type::CSV);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
|
||||
@@ -180,7 +180,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$writer = WriterFactory::create(Type::ODS);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
@@ -421,7 +421,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$this->writeToODSFile($dataRows, $fileName);
|
||||
|
||||
$this->assertValueWasWritten($fileName, 'I'm in "great" mood', 'Quotes should be escaped');
|
||||
$this->assertValueWasWritten($fileName, 'I\'m in "great" mood', 'Quotes should not be escaped');
|
||||
$this->assertValueWasWritten($fileName, 'This <must> be escaped & tested', '<, > and & should be escaped');
|
||||
}
|
||||
|
||||
|
||||
@@ -206,7 +206,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
$writer = WriterFactory::create(Type::XLSX);
|
||||
$writer->close(); // This call should not cause any error
|
||||
|
||||
$writer->openToFile($fileName);
|
||||
$writer->openToFile($resourcePath);
|
||||
$writer->close();
|
||||
$writer->close(); // This call should not cause any error
|
||||
}
|
||||
@@ -473,7 +473,7 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'I'm in "great" mood', 'Quotes should be escaped');
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'I\'m in "great" mood', 'Quotes should not be escaped');
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'This <must> be escaped & tested', '<, > and & should be escaped');
|
||||
}
|
||||
|
||||
@@ -482,14 +482,14 @@ class WriterTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
public function testAddRowShouldEscapeControlCharacters()
|
||||
{
|
||||
$fileName = 'test_add_row_should_escape_html_special_characters.xlsx';
|
||||
$fileName = 'test_add_row_should_escape_control_characters.xlsx';
|
||||
$dataRows = [
|
||||
['control\'s '.chr(21).' "character"'],
|
||||
['control '.chr(21).' character'],
|
||||
];
|
||||
|
||||
$this->writeToXLSXFile($dataRows, $fileName);
|
||||
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control's _x0015_ "character"');
|
||||
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control _x0015_ character');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user