Compare commits

..
14 Commits
Author SHA1 Message Date
Lumír Toman 3681a3421a Change Worksheet function visibility from private to protected in order to class extending. We have created small package on top of the Spout for support cell number format and per cell styles. 2017-09-25 21:44:35 +02:00
Chris Muthig 3bbff7ea7d Fix WriterInterface docblock typo 2017-09-06 10:54:21 +02:00
Adrien Loison fd6dd46b25 Update gitignore (#453) 2017-07-27 23:40:26 +02:00
Adrien Loison 6d44cd26cc Fix prefixed shared strings XML file (#450)
A prefixed sharedStrings.xml file was not properly read, as we were comparing the un-prefixed name with the possible prefixed name.
Also, this commit contains a fix for sheets with rows not starting at column A.
2017-07-25 14:16:22 +02:00
Adrien Loison ee5dee61c7 Fix HHVM jobs on Travis (#451) 2017-07-25 14:02:17 +02:00
Adrien Loison 40b4a57e6b Update README.md (#442) 2017-06-19 20:37:30 +02:00
madflow 5d4166196a HHVM is no longer supported on Ubuntu Precise (#439) 2017-06-02 08:46:48 +02:00
Adrien Loison 99816b0b8e Use openFileInZip() only (#421) 2017-05-22 14:39:26 +02:00
Adrien Loison 606103f7fc Move all documentation to Spout's website (#413)
The README became really long. All the documentation (README + Wiki) has been moved to a custom website: http://opensource.box.com/spout/.
It is now possible to remove all the duplicate content from the README.
2017-05-02 14:47:00 +02:00
Adrien Loison 4e6db6a8a1 Update copyright year (#412) 2017-04-28 11:17:41 +02:00
Adrien Loison 048105461c Fix shared strings XML Entities auto decode (#411)
When converting an XMLReader node to a SimpleXMLElement, the conversion would automatically decode the XML entities. This resulted in a double decode.
For example: """ was converted to """ when imported into a SimpleXMLElement and was again converted into " (quote).

This commit changes the way the XLSX Shared Strings file is processed. It also changes the unescaping logic for both XLSX and ODS.

Finally, it removes any usage of the SimpleXML library (yay!).
2017-04-28 02:27:33 +02:00
Adrien Loison 1eb01a3d2a Use constants instead of arbitrary strings in SheetHelper (#407) 2017-04-15 23:52:48 +02:00
Adrien Loison 9f80ece73f Minor fixes for tests (#406) 2017-04-15 23:33:50 +02:00
Adrien Loison 7f8b95b2f3 Expose Sheet::isActive() to provide info about the last active sheet (#405) 2017-04-15 21:40:19 +02:00
38 changed files with 430 additions and 809 deletions

No files matched your search

+3 -2
View File
@@ -1,5 +1,6 @@
/.idea
*.iml
/tests/resources/generated
/tests/coverage
/vendor
/.idea
*.iml
+1 -1
View File
@@ -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
View File
@@ -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
+4 -340
View File
@@ -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: [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](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: [![Build Status](https://travis-ci.org/box/spout.svg?branch=perf-tests)](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&nbsp;strings* | < 1 second | 35-40 seconds | 18-20 minutes |
| | Read<br>*shared&nbsp;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>
[![Gitter](https://badges.gitter.im/Join%20Chat.svg)](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
View File
@@ -14,8 +14,7 @@
"require": {
"php": ">=5.4.0",
"ext-zip": "*",
"ext-xmlreader" : "*",
"ext-simplexml": "*"
"ext-xmlreader" : "*"
},
"require-dev": {
"phpunit/phpunit": "^4.8.0"
+13 -4
View File
@@ -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;
}
}
+10 -3
View File
@@ -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;
}
+27
View File
@@ -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;
}
}
+1
View File
@@ -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);
+16 -2
View File
@@ -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;
}
}
+27 -1
View File
@@ -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();
}
}
+13 -17
View File
@@ -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);
}
/**
+34 -10
View File
@@ -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) {
+3 -3
View File
@@ -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) === '');
}
/**
+15 -1
View File
@@ -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;
}
}
+1 -1
View File
@@ -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);
+4 -4
View File
@@ -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)');
+1 -1
View File
@@ -16,7 +16,7 @@ class ODSTest extends \PHPUnit_Framework_TestCase
{
return [
['test', 'test'],
['carl\'s "pokemon"', 'carl&#039;s &quot;pokemon&quot;'],
['carl\'s "pokemon"', 'carl\'s "pokemon"'],
["\n", "\n"],
["\r", "\r"],
["\t", "\t"],
+4 -4
View File
@@ -16,7 +16,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
{
return [
['test', 'test'],
['adam\'s "car"', 'adam&#039;s &quot;car&quot;'],
['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&#039;s _x0015_ &quot;character&quot;'],
['control\'s '.chr(21).' "character"', 'control\'s _x0015_ "character"'],
];
}
@@ -52,7 +52,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
{
return [
['test', 'test'],
['adam&#039;s &quot;car&quot;', 'adam\'s "car"'],
['adam&#039;s &quot;car&quot;', 'adam&#039;s &quot;car&quot;'],
["\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&#039;s _x0015_ &quot;character&quot;', 'control\'s '.chr(21).' "character"'],
['control&#039;s _x0015_ &quot;character&quot;', 'control&#039;s '.chr(21).' &quot;character&quot;'],
];
}
+46
View File
@@ -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;
}
}
+1 -1
View File
@@ -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';
+16 -1
View File
@@ -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: &#34; - ampersand: &amp;', $sharedString);
$sharedStringsHelper->cleanup();
}
/**
* @return void
*/
+52 -17
View File
@@ -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
*/
+5 -2
View File
@@ -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());
}
/**
+1 -1
View File
@@ -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
}
+2 -2
View File
@@ -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&#039;m in &quot;great&quot; mood', 'Quotes should be escaped');
$this->assertValueWasWritten($fileName, 'I\'m in "great" mood', 'Quotes should not be escaped');
$this->assertValueWasWritten($fileName, 'This &lt;must&gt; be escaped &amp; tested', '<, > and & should be escaped');
}
+5 -5
View File
@@ -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&#039;m in &quot;great&quot; mood', 'Quotes should be escaped');
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'I\'m in "great" mood', 'Quotes should not be escaped');
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'This &lt;must&gt; be escaped &amp; 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&#039;s _x0015_ &quot;character&quot;');
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control _x0015_ character');
}
/**