Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d4cf853270 | ||
|
|
8bac924d48 | ||
|
|
591d86cf07 | ||
|
|
ba4a55292d | ||
|
|
d617a50b85 | ||
|
|
92981fa80b | ||
|
|
766c733466 | ||
|
|
46cae76546 | ||
|
|
03be58b84d | ||
|
|
b21bb86682 | ||
|
|
8a45cec220 | ||
|
|
04d41d7c9f | ||
|
|
f8f194bc8f | ||
|
|
044ad11596 | ||
|
|
935ba1fb5e | ||
|
|
fb0175d633 |
No files matched your search
@@ -1,11 +1,13 @@
|
||||
# Spout
|
||||
|
||||
[](https://packagist.org/packages/box/spout)
|
||||
[](http://opensource.box.com/badges)
|
||||
[](http://travis-ci.org/box/spout)
|
||||
[](https://packagist.org/packages/box/spout) [](https://packagist.org/packages/box/spout)
|
||||
|
||||
Spout is a PHP library to read and write CSV and XLSX files, in a fast and scalable way.
|
||||
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 10MB).
|
||||
|
||||
[](http://travis-ci.org/box/spout)
|
||||
[](http://opensource.box.com/badges)
|
||||
|
||||
|
||||
## Installation
|
||||
|
||||
@@ -248,6 +250,11 @@ Here are a few numbers regarding the performance of Spout:
|
||||
| Read XLSX (using shared strings) | 1 second | 1-2 minutes | 35-40 minutes |
|
||||
| Write XLSX | 1 second | 20-25 seconds | 8-10 minutes |
|
||||
|
||||
#### Does Spout support charts or formulas?
|
||||
|
||||
No. This is a compromise to keep memory usage low. Charts and formulas requires data to be kept in memory in order to be used.
|
||||
So the larger the file would be, the more memory would be consumed, preventing your code to scale well.
|
||||
|
||||
|
||||
## Support
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
"name": "box/spout",
|
||||
"description": "PHP Library to read and write CSV and XLSX files, in a fast and scalable way",
|
||||
"type": "library",
|
||||
"version": "1.0.7",
|
||||
"keywords": ["php","read","write","csv","xlsx","excel","spreadsheet","scale","memory","stream","ooxml"],
|
||||
"license": "Apache-2.0",
|
||||
"homepage": "https://www.github.com/box/spout",
|
||||
|
||||
@@ -71,6 +71,24 @@ class SharedStringsHelper
|
||||
$this->tempFolder = $this->fileSystemHelper->createFolder($rootTempFolder, uniqid('sharedstrings'));
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether the XLSX file contains a shared strings XML file
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function hasSharedStrings()
|
||||
{
|
||||
$hasSharedStrings = false;
|
||||
$zip = new \ZipArchive();
|
||||
|
||||
if ($zip->open($this->filePath) === true) {
|
||||
$hasSharedStrings = ($zip->locateName(self::SHARED_STRINGS_XML_FILE_PATH) !== false);
|
||||
$zip->close();
|
||||
}
|
||||
|
||||
return $hasSharedStrings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an in-memory array containing all the shared strings of the worksheet.
|
||||
* All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'.
|
||||
@@ -93,7 +111,7 @@ class SharedStringsHelper
|
||||
$this->tempFilePointer = null;
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
|
||||
$sharedStringsFilePath = 'zip://' . $this->filePath . '#' . self::SHARED_STRINGS_XML_FILE_PATH;
|
||||
$sharedStringsFilePath = $this->getSharedStringsFilePath();
|
||||
if ($xmlReader->open($sharedStringsFilePath, null, LIBXML_NONET) === false) {
|
||||
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
|
||||
}
|
||||
@@ -143,6 +161,15 @@ class SharedStringsHelper
|
||||
$xmlReader->close();
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string The path to the shared strings XML file, working cross-platforms
|
||||
*/
|
||||
protected function getSharedStringsFilePath()
|
||||
{
|
||||
$sharedStringsXmlFilePath = str_replace('/', DIRECTORY_SEPARATOR, self::SHARED_STRINGS_XML_FILE_PATH);
|
||||
return 'zip://' . $this->filePath . '#' . $sharedStringsXmlFilePath;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes nodes that should not be read, like the pronunciation of the Kanji characters.
|
||||
* By keeping them, their text content would be added to the read string.
|
||||
|
||||
@@ -186,7 +186,8 @@ class WorksheetHelper
|
||||
*/
|
||||
protected function getFileAsXMLElementWithNamespace($xmlFilePath, $mainNamespace)
|
||||
{
|
||||
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $xmlFilePath);
|
||||
$normalizedXmlFilePath = str_replace('/', DIRECTORY_SEPARATOR, $xmlFilePath);
|
||||
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $normalizedXmlFilePath);
|
||||
|
||||
$xmlElement = new \SimpleXMLElement($xmlContents);
|
||||
$xmlElement->registerXPathNamespace('ns', $mainNamespace);
|
||||
|
||||
@@ -32,11 +32,13 @@ class Worksheet
|
||||
}
|
||||
|
||||
/**
|
||||
* @return string Path of the XML file containing the worksheet data, without the leading slash
|
||||
* @return string Path of the XML file containing the worksheet data,
|
||||
* without the leading slash and working cross-platforms.
|
||||
*/
|
||||
public function getDataXmlFilePath()
|
||||
{
|
||||
return ltrim($this->dataXmlFilePath, '/');
|
||||
$dataXmlFilePath = ltrim($this->dataXmlFilePath, '/');
|
||||
return str_replace('/', DIRECTORY_SEPARATOR, $dataXmlFilePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+63
-23
@@ -72,8 +72,12 @@ class XLSX extends AbstractReader
|
||||
$this->zip = new \ZipArchive();
|
||||
|
||||
if ($this->zip->open($filePath) === true) {
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
|
||||
|
||||
if ($this->sharedStringsHelper->hasSharedStrings()) {
|
||||
// Extracts all the strings from the worksheets for easy access in the future
|
||||
$this->extractSharedStrings($filePath);
|
||||
$this->sharedStringsHelper->extractSharedStrings();
|
||||
}
|
||||
|
||||
// Fetch all available worksheets
|
||||
$this->worksheetHelper = new WorksheetHelper($filePath, $this->globalFunctionsHelper);
|
||||
@@ -87,19 +91,6 @@ class XLSX extends AbstractReader
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds an in-memory array containing all the shared strings of the worksheets.
|
||||
*
|
||||
* @param string $filePath Path of the XLSX file to be read
|
||||
* @return void
|
||||
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings XML file can't be read
|
||||
*/
|
||||
protected function extractSharedStrings($filePath)
|
||||
{
|
||||
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
|
||||
$this->sharedStringsHelper->extractSharedStrings();
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether another worksheet exists after the current worksheet.
|
||||
*
|
||||
@@ -186,6 +177,7 @@ class XLSX extends AbstractReader
|
||||
throw new BadUsageException('You must call nextSheet() before calling hasNextRow() or nextRow()');
|
||||
}
|
||||
|
||||
$escaper = new \Box\Spout\Common\Escaper\XLSX();
|
||||
$isInsideRowTag = false;
|
||||
$rowData = [];
|
||||
|
||||
@@ -197,6 +189,7 @@ class XLSX extends AbstractReader
|
||||
$lastCellIndex = $matches[1];
|
||||
$this->numberOfColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
|
||||
}
|
||||
|
||||
} else if ($this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'row') {
|
||||
// Start of the row description
|
||||
$isInsideRowTag = true;
|
||||
@@ -209,20 +202,15 @@ class XLSX extends AbstractReader
|
||||
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
|
||||
}
|
||||
$rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
|
||||
|
||||
} else if ($isInsideRowTag && $this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'c') {
|
||||
// Start of a cell description
|
||||
$currentCellIndex = $this->xmlReader->getAttribute('r');
|
||||
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
|
||||
$node = $this->xmlReader->expand();
|
||||
|
||||
$hasSharedString = ($this->xmlReader->getAttribute('t') === 's');
|
||||
if ($hasSharedString) {
|
||||
$sharedStringIndex = intval($node->nodeValue);
|
||||
$rowData[$currentColumnIndex] = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
|
||||
} else {
|
||||
// for inline strings or numbers, just get the value
|
||||
$rowData[$currentColumnIndex] = trim($node->nodeValue);
|
||||
}
|
||||
$node = $this->xmlReader->expand();
|
||||
$rowData[$currentColumnIndex] = $this->getCellValue($node, $escaper);
|
||||
|
||||
} else if ($this->xmlReader->nodeType == \XMLReader::END_ELEMENT && $this->xmlReader->name === 'row') {
|
||||
// End of the row description
|
||||
// If needed, we fill the empty cells
|
||||
@@ -235,6 +223,58 @@ class XLSX extends AbstractReader
|
||||
return ($rowData !== []) ? $rowData : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the (unescaped) cell value associated to the given XML node.
|
||||
*
|
||||
* @param \DOMNode $node
|
||||
* @param \Box\Spout\Common\Escaper\XLSX $escaper
|
||||
* @return string|int|float|bool|null The value associated with the cell (null when the cell has an error)
|
||||
*/
|
||||
protected function getCellValue($node, $escaper)
|
||||
{
|
||||
$cellValue = '';
|
||||
|
||||
// Default cell type is "n"
|
||||
$cellType = $node->getAttribute('t') ?: 'n';
|
||||
|
||||
if ($cellType === 'inlineStr') {
|
||||
// inline strings are formatted this way:
|
||||
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
|
||||
$tNode = $node->getElementsByTagName('t')->item(0);
|
||||
$escapedCellValue = trim($tNode->nodeValue);
|
||||
$cellValue = $escaper->unescape($escapedCellValue);
|
||||
} else {
|
||||
// all other cell types should have a "v" tag containing the value.
|
||||
// if not, the returned value should be empty string.
|
||||
$vNode = $node->getElementsByTagName('v')->item(0);
|
||||
|
||||
if ($vNode !== null) {
|
||||
if ($cellType === 's') {
|
||||
// shared strings are formatted this way:
|
||||
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
|
||||
$sharedStringIndex = intval($vNode->nodeValue);
|
||||
$escapedCellValue = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
|
||||
$cellValue = $escaper->unescape($escapedCellValue);
|
||||
} else if ($cellType === 'b') {
|
||||
// !! is similar to boolval()
|
||||
$cellValue = !!$vNode->nodeValue;
|
||||
} else if ($cellType === 'n') {
|
||||
$nodeValue = $vNode->nodeValue;
|
||||
$cellValue = is_int($nodeValue) ? intval($nodeValue) : floatval($nodeValue);
|
||||
} else if ($cellType === 'd') {
|
||||
$cellValue = new \DateTime($vNode->nodeValue);
|
||||
} else if ($cellType === 'e') {
|
||||
$cellValue = null;
|
||||
} else if ($cellType === 'str') {
|
||||
$escapedCellValue = trim($vNode->nodeValue);
|
||||
$cellValue = $escaper->unescape($escapedCellValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return $cellValue;
|
||||
}
|
||||
|
||||
/**
|
||||
* Closes the reader. To be used after reading the file.
|
||||
*
|
||||
|
||||
@@ -100,6 +100,41 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportFilesWithoutSharedStringsFile()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_no_shared_strings_file.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
[10, 11],
|
||||
[20, 21],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSupportAllCellTypes()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_all_cell_types.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
[
|
||||
's1--A1', 's1--A2',
|
||||
false, true,
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '2015-06-03 13:21:58'),
|
||||
\DateTime::createFromFormat('Y-m-d H:i:s', '2015-06-01 00:00:00'),
|
||||
10, 10.43,
|
||||
null,
|
||||
],
|
||||
['', '', '', '', '', '', '', '', ''],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
@@ -231,6 +266,21 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
$this->assertEquals([], $allRows, 'Sheet with no cells should be correctly processed.');
|
||||
}
|
||||
|
||||
/**
|
||||
* @return void
|
||||
*/
|
||||
public function testReadShouldSkipFormulas()
|
||||
{
|
||||
$allRows = $this->getAllRowsForFile('sheet_with_formulas.xlsx');
|
||||
|
||||
$expectedRows = [
|
||||
['val1', 'val2', 'total1', 'total2'],
|
||||
[10, 20, 30, 21],
|
||||
[11, 21, 32, 41],
|
||||
];
|
||||
$this->assertEquals($expectedRows, $allRows);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $fileName
|
||||
* @return array All the read rows the given file
|
||||
|
||||
@@ -90,9 +90,18 @@ class SheetTest extends \PHPUnit_Framework_TestCase
|
||||
*/
|
||||
private function assertSheetNameEquals($expectedName, $resourcePath, $message = '')
|
||||
{
|
||||
$pathToWorkbookFile = $resourcePath . '#xl/workbook.xml';
|
||||
$pathToWorkbookFile = $resourcePath . $this->normalizePath('#xl/workbook.xml');
|
||||
$xmlContents = file_get_contents('zip://' . $pathToWorkbookFile);
|
||||
|
||||
$this->assertContains('<sheet name="' . $expectedName . '"', $xmlContents, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return string The path with the correct directory separators, as defined for the current platform
|
||||
*/
|
||||
private function normalizePath($path)
|
||||
{
|
||||
return str_replace('/', DIRECTORY_SEPARATOR, $path);
|
||||
}
|
||||
}
|
||||
@@ -408,7 +408,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
private function assertInlineDataWasWrittenToSheet($fileName, $sheetIndex, $inlineData, $message = '')
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToSheetFile = $resourcePath . '#xl/worksheets/sheet' . $sheetIndex . '.xml';
|
||||
$pathToSheetFile = $resourcePath . $this->normalizePath('#xl/worksheets/sheet' . $sheetIndex . '.xml');
|
||||
$xmlContents = file_get_contents('zip://' . $pathToSheetFile);
|
||||
|
||||
$this->assertContains((string)$inlineData, $xmlContents, $message);
|
||||
@@ -424,7 +424,7 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
private function assertInlineDataWasNotWrittenToSheet($fileName, $sheetIndex, $inlineData, $message = '')
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToSheetFile = $resourcePath . '#xl/worksheets/sheet' . $sheetIndex . '.xml';
|
||||
$pathToSheetFile = $resourcePath . $this->normalizePath('#xl/worksheets/sheet' . $sheetIndex . '.xml');
|
||||
$xmlContents = file_get_contents('zip://' . $pathToSheetFile);
|
||||
|
||||
$this->assertNotContains((string)$inlineData, $xmlContents, $message);
|
||||
@@ -439,9 +439,18 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
|
||||
private function assertSharedStringWasWritten($fileName, $sharedString, $message = '')
|
||||
{
|
||||
$resourcePath = $this->getGeneratedResourcePath($fileName);
|
||||
$pathToSharedStringsFile = $resourcePath . '#xl/sharedStrings.xml';
|
||||
$pathToSharedStringsFile = $resourcePath . $this->normalizePath('#xl/sharedStrings.xml');
|
||||
$xmlContents = file_get_contents('zip://' . $pathToSharedStringsFile);
|
||||
|
||||
$this->assertContains($sharedString, $xmlContents, $message);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param string $path
|
||||
* @return string The path with the correct directory separators, as defined for the current platform
|
||||
*/
|
||||
private function normalizePath($path)
|
||||
{
|
||||
return str_replace('/', DIRECTORY_SEPARATOR, $path);
|
||||
}
|
||||
}
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Reference in new issue
Block a user