Compare commits

...
18 Commits
Author SHA1 Message Date
Adrien Loison 3c3294061a Merge pull request #161 from garak/patch-1
support branch alias
2015-12-21 12:05:16 +01:00
Massimiliano Arione 1fde6d836a support branch alias 2015-12-21 10:54:15 +01:00
Adrien Loison 05489cda88 Merge pull request #160 from box/reference-wiki
Update README.md
2015-12-21 10:52:46 +01:00
Adrien Loison 128db45f22 Update README.md 2015-12-21 10:45:02 +01:00
Adrien Loison 9a85d84a2e Merge pull request #155 from KiNgMaR/Fix-Dates-Beyond-2037
XLSX Date Support / Fix for years beyond 2037
2015-12-17 10:59:45 -08:00
Ingmar Runge 4407cffeff XLSX Date Support / Test + Fix for years beyond 2037
This also fixes years < 1902 on 32-bit PHP systems.
2015-12-17 08:52:15 +01:00
Adrien Loison 8d27b3097d Merge pull request #154 from box/fix_font_color_check
Fix hasSetFontColor check
2015-12-08 10:35:49 -08:00
Adrien Loison 2c6cb1ffe5 Fix hasSetFontColor check 2015-12-08 10:31:16 -08:00
Adrien Loison 22daea5f9a Merge pull request #153 from box/improve_zip_for_mime_detection
Improve ZIP interface for better mime detection
2015-12-05 19:13:47 -08:00
Adrien Loison ed0e8f79cc Improve ZIP interface for better mime detection
The ZipHelper interface is now more generic and allow single files to be added.
It supports adding uncompressed files (for PHP7+), which is required to have the mime detection magic work with ODS files.
Also fixed a few issues with the created ODS file (thanks to https://odf-validator.rhcloud.com/)
2015-12-05 18:06:13 -08:00
Adrien Loison 44d72d8245 Merge pull request #152 from box/proper_mime_type_detection
Proper mime type detection for XLSX files
2015-12-05 00:30:28 -08:00
Adrien Loison 728dd3b399 Proper mime type detection for XLSX files
Heuristics to detect proper mime type for XLSX files expect to see
certain files at the beginning of the XLSX archive. The order in which
the XML files are added therefore matters.
Specifically, "[Content_Types].xml" should be added first, followed by the
files located in the "xl" folder (at least 1 file).
2015-12-05 00:20:40 -08:00
Adrien Loison 05a9a1b60a Merge pull request #146 from box/update_perf_tests_doc
Update README.md
2015-11-13 10:36:40 -08:00
Adrien Loison a76624a721 Update README.md 2015-11-13 10:14:10 -08:00
Adrien Loison 90cbb7b5a6 Merge pull request #145 from box/speed_up
Various speed improvements
2015-11-12 14:09:52 -08:00
Adrien Loison f55520661e Various speed improvements 2015-11-12 13:55:25 -08:00
Adrien Loison cb5dae22e4 Merge pull request #143 from box/xlsx_skip_empty_cells_on_write
[XLSX] Skip empty cells on write
2015-11-10 17:26:26 -08:00
Adrien Loison 2f6193ce20 [XLSX] Skip empty cells on write
Since cells are referenced by their coordinates (A2, B4...), it is not necessary to write empty cells.
This will reduce the final size of the generated XML and therefore XLSX file.
2015-11-10 17:17:54 -08:00
18 changed files with 388 additions and 93 deletions

No files matched your search

+8 -4
View File
@@ -2,7 +2,7 @@
[![Latest Stable Version](https://poser.pugx.org/box/spout/v/stable)](https://packagist.org/packages/box/spout)
[![Project Status](http://opensource.box.com/badges/active.svg)](http://opensource.box.com/badges)
[![Build Status](https://travis-ci.org/box/spout.svg?branch=master)](http://travis-ci.org/box/spout)
[![Build Status](https://travis-ci.org/box/spout.svg?branch=master)](https://travis-ci.org/box/spout)
[![Code Coverage](https://scrutinizer-ci.com/g/box/spout/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/box/spout/?branch=master)
[![Total Downloads](https://poser.pugx.org/box/spout/downloads)](https://packagist.org/packages/box/spout)
[![License](https://poser.pugx.org/box/spout/license)](https://packagist.org/packages/box/spout)
@@ -98,6 +98,8 @@ For XLSX and ODS files, the number of rows per sheet is limited to 1,048,576. By
## 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:
@@ -275,10 +277,12 @@ If you just want to check that everything is working as expected, executing the
If you want to run performance tests, you will need to checkout the `perf-tests` branch. Multiple test suites can then be run, depending on the expected output:
* `phpunit` - runs the whole test suite (unit + functional + performance tests)
* `phpunit --testuite no-perf-tests` - only runs the unit and functional tests
* `phpunit --testuite perf-tests` - only runs the performance tests
* `phpunit --exclude-group perf-tests` - only runs the unit and functional tests
* `phpunit --group perf-tests` - only runs the performance tests
For information, the performance tests take about one hour to run (processing 2 million rows files is not a quick thing).
For information, the performance tests take about 30 minutes to run (processing 1 million rows files is not a quick thing).
> Performance tests status: [![Build Status](https://travis-ci.org/box/spout.svg?branch=perf-tests)](https://travis-ci.org/box/spout)
## Frequently Asked Questions
+6
View File
@@ -29,5 +29,11 @@
"psr-4": {
"Box\\Spout\\": "src/Spout"
}
},
"extra": {
"branch-alias": {
"dev-master": "2.4.x-dev"
}
}
}
+31 -25
View File
@@ -12,6 +12,14 @@ use Box\Spout\Common\Exception\InvalidArgumentException;
*/
class CellHelper
{
// Using ord() is super slow... Using a pre-computed hash table instead.
private static $columnLetterToIndexMapping = [
'A' => 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6,
'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13,
'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20,
'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25,
];
/**
* Fills the missing indexes of an array with a given value.
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
@@ -50,34 +58,31 @@ class CellHelper
}
$columnIndex = 0;
$capitalAAsciiValue = ord('A');
$capitalZAsciiValue = ord('Z');
$step = $capitalZAsciiValue - $capitalAAsciiValue + 1;
// Remove row information
$column = preg_replace('/\d/', '', $cellIndex);
$columnLength = strlen($column);
$columnLetters = preg_replace('/\d/', '', $cellIndex);
/*
* This is how the following loop will process the data:
* A => 0
* Z => 25
* AA => 26 : (26^(2-1) * (0+1)) + 0
* AB => 27 : (26^(2-1) * (0+1)) + 1
* BC => 54 : (26^(2-1) * (1+1)) + 2
* BCZ => 1455 : (26^(3-1) * (1+1)) + (26^(2-1) * (2+1)) + 25
*/
foreach (str_split($column) as $single_cell_index)
{
$currentColumnIndex = ord($single_cell_index) - $capitalAAsciiValue;
// strlen() is super slow too... Using isset() is way faster and not too unreadable,
// since we checked before that there are between 1 and 3 letters.
$columnLength = isset($columnLetters[1]) ? (isset($columnLetters[2]) ? 3 : 2) : 1;
if ($columnLength === 1) {
$columnIndex += $currentColumnIndex;
} else {
$columnIndex += pow($step, ($columnLength - 1)) * ($currentColumnIndex + 1);
}
$columnLength--;
// Looping over the different letters of the column is slower than this method.
// Also, not using the pow() function because it's slooooow...
switch ($columnLength) {
case 1:
$columnIndex = (self::$columnLetterToIndexMapping[$columnLetters]);
break;
case 2:
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 26;
$secondLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[1]];
$columnIndex = $firstLetterIndex + $secondLetterIndex;
break;
case 3:
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 676;
$secondLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[1]] + 1) * 26;
$thirdLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[2]];
$columnIndex = $firstLetterIndex + $secondLetterIndex + $thirdLetterIndex;
break;
}
return $columnIndex;
@@ -86,12 +91,13 @@ class CellHelper
/**
* Returns whether a cell index is valid, in an Excel world.
* To be valid, the cell index should start with capital letters and be followed by numbers.
* There can only be 3 letters, as there can only be 16,384 rows, which is equivalent to 'XFE'.
*
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
* @return bool
*/
protected static function isValidCellIndex($cellIndex)
{
return (preg_match('/^[A-Z]+\d+$/', $cellIndex) === 1);
return (preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex) === 1);
}
}
@@ -28,7 +28,6 @@ class CellValueFormatter
const XML_ATTRIBUTE_STYLE_ID = 's';
/** Constants used for date formatting */
const NUM_DAYS_FROM_JAN_1_1900_TO_JAN_1_1970 = 25569; // actually 25567 but accommodating for an Excel bug with some bisextile years
const NUM_SECONDS_IN_ONE_DAY = 86400;
/**
@@ -183,17 +182,26 @@ class CellValueFormatter
*/
protected function formatExcelTimestampValue($nodeValue)
{
$numDaysSince1970Jan1 = $nodeValue - self::NUM_DAYS_FROM_JAN_1_1900_TO_JAN_1_1970;
// Fix for the erroneous leap year in Excel
if ($nodeValue < self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
$numDaysSince1970Jan1++;
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
--$nodeValue;
}
$unixTimestamp = round($numDaysSince1970Jan1 * self::NUM_SECONDS_IN_ONE_DAY);
// The value 1.0 represents 1900-01-01. Numbers below 1.0 are not valid Excel dates.
if ($nodeValue < 1.0) {
return null;
}
// Do not use any unix timestamps for calculation to prevent
// issues with numbers exceeding 2^31.
$secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY;
$secondsRemainder = round($secondsRemainder, 0);
try {
$cellValue = (new \DateTime())->setTimestamp($unixTimestamp);
$cellValue = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
$cellValue->modify('+' . intval($nodeValue) . 'days');
$cellValue->modify('+' . $secondsRemainder . 'seconds');
return $cellValue;
} catch (\Exception $e) {
return null;
@@ -143,7 +143,9 @@ class FileBasedStrategy implements CachingStrategyInterface
}
$sharedString = null;
if (array_key_exists($indexInFile, $this->inMemoryTempFileContents)) {
// Using isset here because it is way faster than array_key_exists...
if (isset($this->inMemoryTempFileContents[$indexInFile])) {
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
$sharedString = $this->unescapeLineFeed($escapedSharedString);
}
+4 -2
View File
@@ -148,7 +148,8 @@ class StyleHelper
// Default style (0) does not format numeric values as timestamps. Only custom styles do.
// Also if the style ID does not exist in the styles.xml file, format as numeric value.
if ($styleId === self::DEFAULT_STYLE_ID || !array_key_exists($styleId, $stylesAttributes)) {
// Using isset here because it is way faster than array_key_exists...
if ($styleId === self::DEFAULT_STYLE_ID || !isset($stylesAttributes[$styleId])) {
return false;
}
@@ -193,7 +194,8 @@ class StyleHelper
{
$customNumberFormats = $this->getCustomNumberFormats();
if (!array_key_exists($numFmtId, $customNumberFormats)) {
// Using isset here because it is way faster than array_key_exists...
if (!isset($customNumberFormats[$numFmtId])) {
return false;
}
+2 -1
View File
@@ -67,7 +67,8 @@ class SheetIterator implements IteratorInterface
*/
public function next()
{
if (array_key_exists($this->currentSheetIndex, $this->sheets)) {
// Using isset here because it is way faster than array_key_exists...
if (isset($this->sheets[$this->currentSheetIndex])) {
$currentSheet = $this->sheets[$this->currentSheetIndex];
$currentSheet->getRowIterator()->end();
@@ -56,7 +56,9 @@ abstract class AbstractStyleHelper
protected function hasStyleAlreadyBeenRegistered($style)
{
$serializedStyle = $style->serialize();
return array_key_exists($serializedStyle, $this->serializedStyleToStyleIdMappingTable);
// Using isset here because it is way faster than array_key_exists...
return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
}
/**
+12 -1
View File
@@ -10,6 +10,9 @@ namespace Box\Spout\Writer\Common\Helper;
*/
class CellHelper
{
/** @var array Cache containing the mapping column index => cell index */
private static $columnIndexToCellIndexCache = [];
/**
* Returns the cell index (base 26) associated to the base 10 column index.
* Excel uses A to Z letters for column indexing, where A is the 1st column,
@@ -21,6 +24,10 @@ class CellHelper
*/
public static function getCellIndexFromColumnIndex($columnIndex)
{
$originalColumnIndex = $columnIndex;
// Using isset here because it is way faster than array_key_exists...
if (!isset(self::$columnIndexToCellIndexCache[$originalColumnIndex])) {
$cellIndex = '';
$capitalAAsciiValue = ord('A');
@@ -30,9 +37,13 @@ class CellHelper
// substracting 1 because it's zero-based
$columnIndex = intval($columnIndex / 26) - 1;
} while ($columnIndex >= 0);
return $cellIndex;
self::$columnIndexToCellIndexCache[$originalColumnIndex] = $cellIndex;
}
return self::$columnIndexToCellIndexCache[$originalColumnIndex];
}
/**
+145 -29
View File
@@ -12,52 +12,142 @@ class ZipHelper
{
const ZIP_EXTENSION = '.zip';
/** Controls what to do when trying to add an existing file */
const EXISTING_FILES_SKIP = 'skip';
const EXISTING_FILES_OVERWRITE = 'overwrite';
/** @var string Path of the folder where the zip file will be created */
protected $tmpFolderPath;
/** @var \ZipArchive The ZipArchive instance */
protected $zip;
/**
* Zips the root folder and streams the contents of the zip into the given stream
*
* @param string $folderPath Path to the folder to be zipped
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
* @param string $tmpFolderPath Path of the temp folder where the zip file will be created
*/
public function zipFolderAndCopyToStream($folderPath, $streamPointer)
public function __construct($tmpFolderPath)
{
$zipFilePath = $this->getZipFilePath($folderPath);
$this->zipFolder($folderPath, $zipFilePath);
$this->copyZipToStream($zipFilePath, $streamPointer);
$this->tmpFolderPath = $tmpFolderPath;
}
/**
* Returns the already created ZipArchive instance or
* creates one if none exists.
*
* @return \ZipArchive
*/
protected function createOrGetZip()
{
if (!isset($this->zip)) {
$this->zip = new \ZipArchive();
$zipFilePath = $this->getZipFilePath();
$this->zip->open($zipFilePath, \ZipArchive::CREATE|\ZipArchive::OVERWRITE);
}
return $this->zip;
}
/**
* @param string $folderPathToZip Path to the folder to be zipped
* @return string Path where the zip file of the given folder will be created
*/
public function getZipFilePath($folderPathToZip)
public function getZipFilePath()
{
return $folderPathToZip . self::ZIP_EXTENSION;
return $this->tmpFolderPath . self::ZIP_EXTENSION;
}
/**
* Zips the given folder
* Adds the given file, located under the given root folder to the archive.
* The file will be compressed.
*
* @param string $folderPath Path of the folder to be zipped
* @param string $destinationPath Path where the zip file will be created
* Example of use:
* addFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
public function zipFolder($folderPath, $destinationPath)
public function addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$zip = new \ZipArchive();
if ($zip->open($destinationPath, \ZipArchive::CREATE)) {
$this->addFolderToZip($zip, $folderPath);
$zip->close();
$this->addFileToArchiveWithCompressionMethod(
$rootFolderPath,
$localFilePath,
$existingFileMode,
\ZipArchive::CM_DEFAULT
);
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will NOT be compressed.
*
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
public function addUncompressedFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$this->addFileToArchiveWithCompressionMethod(
$rootFolderPath,
$localFilePath,
$existingFileMode,
\ZipArchive::CM_STORE
);
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will NOT be compressed.
*
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string $existingFileMode Controls what to do when trying to add an existing file
* @param int $compressionMethod The compression method
* @return void
*/
protected function addFileToArchiveWithCompressionMethod($rootFolderPath, $localFilePath, $existingFileMode, $compressionMethod)
{
$zip = $this->createOrGetZip();
if (!$this->shouldSkipFile($zip, $localFilePath, $existingFileMode)) {
$normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath . '/' . $localFilePath);
$zip->addFile($normalizedFullFilePath, $localFilePath);
if (self::canChooseCompressionMethod()) {
$zip->setCompressionName($localFilePath, $compressionMethod);
}
}
}
/**
* @param \ZipArchive $zip
* @param string $folderPath Path of the folder to add to the zip
* @return bool Whether it is possible to choose the desired compression method to be used
*/
public static function canChooseCompressionMethod()
{
// setCompressionName() is a PHP7+ method...
return (method_exists(new \ZipArchive(), 'setCompressionName'));
}
/**
* @param string $folderPath Path to the folder to be zipped
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
protected function addFolderToZip($zip, $folderPath)
public function addFolderToArchive($folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$zip = $this->createOrGetZip();
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
@@ -65,14 +155,26 @@ class ZipHelper
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
if ($itemInfo->isFile()) {
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
$zip->addFile($itemRealPath, $itemLocalPath);
} else if ($itemInfo->isDir()) {
$zip->addEmptyDir($itemLocalPath);
}
}
}
/**
* @param \ZipArchive $zip
* @param string $itemLocalPath
* @param string $existingFileMode
* @return bool Whether the file should be added to the archive or skipped
*/
protected function shouldSkipFile($zip, $itemLocalPath, $existingFileMode)
{
// Skip files if:
// - EXISTING_FILES_SKIP mode chosen
// - File already exists in the archive
return ($existingFileMode === self::EXISTING_FILES_SKIP && $zip->locateName($itemLocalPath) !== false);
}
/**
* Returns canonicalized absolute pathname, containing only forward slashes.
*
@@ -85,16 +187,30 @@ class ZipHelper
return str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
}
/**
* Closes the archive and copies it into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function closeArchiveAndCopyToStream($streamPointer)
{
$zip = $this->createOrGetZip();
$zip->close();
unset($this->zip);
$this->copyZipToStream($streamPointer);
}
/**
* Streams the contents of the zip file into the given stream
*
* @param string $zipFilePath Path to the zip file
* @param resource $pointer Pointer to the stream to copy the zip
* @return void
*/
protected function copyZipToStream($zipFilePath, $pointer)
protected function copyZipToStream($pointer)
{
$zipFilePointer = fopen($zipFilePath, 'r');
$zipFilePointer = fopen($this->getZipFilePath(), 'r');
stream_copy_to_stream($zipFilePointer, $pointer);
fclose($zipFilePointer);
}
@@ -103,14 +103,12 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
protected function createManifestFile()
{
$manifestXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0">
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="mimetype" manifest:media-type="text/plain"/>
<manifest:file-entry manifest:full-path="META-INF/manifest.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
EOD;
@@ -265,10 +263,17 @@ EOD;
*/
public function zipRootFolderAndCopyToStream($streamPointer)
{
$zipHelper = new ZipHelper();
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
$zipHelper = new ZipHelper($this->rootFolder);
// In order to have the file's mime type detected properly, files need to be added
// to the zip file in a particular order.
// @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
$zipHelper->addUncompressedFileToArchive($this->rootFolder, self::MIMETYPE_FILE_NAME);
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
// once the zip is copied, remove it
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder));
$this->deleteFile($zipHelper->getZipFilePath());
}
}
+2 -1
View File
@@ -146,7 +146,8 @@ class Worksheet implements WorksheetInterface
for ($i = 0; $i < $cellsCount; $i++) {
$currentCellValue = $dataRow[$currentCellIndex];
if (!array_key_exists($nextCellIndex, $dataRow) || $currentCellValue !== $dataRow[$nextCellIndex]) {
// Using isset here because it is way faster than array_key_exists...
if (!isset($dataRow[$nextCellIndex]) || $currentCellValue !== $dataRow[$nextCellIndex]) {
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
$data .= $this->getCellContent($currentCellValue, $styleIndex, $numTimesValueRepeated);
+1 -1
View File
@@ -293,7 +293,7 @@ class Style
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
$mergedStyle->setFontSize($baseStyle->getFontSize());
}
if (!$this->hasSetFontSize && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
if (!$this->hasSetFontColor && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
$mergedStyle->setFontColor($baseStyle->getFontColor());
}
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
@@ -353,10 +353,19 @@ EOD;
*/
public function zipRootFolderAndCopyToStream($streamPointer)
{
$zipHelper = new ZipHelper();
$zipHelper->zipFolderAndCopyToStream($this->rootFolder, $streamPointer);
$zipHelper = new ZipHelper($this->rootFolder);
// In order to have the file's mime type detected properly, files need to be added
// to the zip file in a particular order.
// "[Content_Types].xml" then at least 2 files located in "xl" folder should be zipped first.
$zipHelper->addFileToArchive($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME);
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::WORKBOOK_XML_FILE_NAME);
$zipHelper->addFileToArchive($this->rootFolder, self::XL_FOLDER_NAME . '/' . self::STYLES_XML_FILE_NAME);
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
// once the zip is copied, remove it
$this->deleteFile($zipHelper->getZipFilePath($this->rootFolder));
$this->deleteFile($zipHelper->getZipFilePath());
}
}
+12 -10
View File
@@ -131,36 +131,38 @@ EOD;
$rowIndex = $this->lastWrittenRowIndex + 1;
$numCells = count($dataRow);
$data = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
$rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
foreach($dataRow as $cellValue) {
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
$data .= '<c r="' . $columnIndex . $rowIndex . '"';
$data .= ' s="' . $style->getId() . '"';
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
$cellXML .= ' s="' . $style->getId() . '"';
if (CellHelper::isNonEmptyString($cellValue)) {
if ($this->shouldUseInlineStrings) {
$data .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
$cellXML .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
} else {
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
$data .= ' t="s"><v>' . $sharedStringId . '</v></c>';
$cellXML .= ' t="s"><v>' . $sharedStringId . '</v></c>';
}
} else if (CellHelper::isBoolean($cellValue)) {
$data .= ' t="b"><v>' . $cellValue . '</v></c>';
$cellXML .= ' t="b"><v>' . $cellValue . '</v></c>';
} else if (CellHelper::isNumeric($cellValue)) {
$data .= '><v>' . $cellValue . '</v></c>';
$cellXML .= '><v>' . $cellValue . '</v></c>';
} else if (empty($cellValue)) {
$data .= '/>';
// don't write empty cells (not appending to $cellXML is the right behavior!)
$cellXML = '';
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
$rowXML .= $cellXML;
$cellNumber++;
}
$data .= '</row>';
$rowXML .= '</row>';
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $data);
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $rowXML);
if ($wasWriteSuccessful === false) {
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
}
@@ -0,0 +1,80 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
/**
* Class CellValueFormatterTest
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class CellValueFormatterTest extends \PHPUnit_Framework_TestCase
{
/**
* @return array
*/
public function dataProviderForExcelDateTest()
{
return [
[ CellValueFormatter::CELL_TYPE_NUMERIC, 42429, '2016-02-29 00:00:00' ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, '146098', '2299-12-31 00:00:00' ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, -700, null ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, 0, null ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, 0.5, null ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, 1, '1900-01-01 00:00:00' ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, 59.999988425926, '1900-02-28 23:59:59' ],
[ CellValueFormatter::CELL_TYPE_NUMERIC, 60.458333333333, '1900-02-28 11:00:00' ],
];
}
/**
* @dataProvider dataProviderForExcelDateTest
*
* @return void
*/
public function testExcelDate($cellType, $nodeValue, $expectedDateAsString)
{
$nodeListMock = $this->getMockBuilder('DOMNodeList')->disableOriginalConstructor()->getMock();
$nodeListMock
->expects($this->atLeastOnce())
->method('item')
->with(0)
->will($this->returnValue((object)[ 'nodeValue' => $nodeValue ]));
$nodeMock = $this->getMockBuilder('DOMElement')->disableOriginalConstructor()->getMock();
$nodeMock
->expects($this->atLeastOnce())
->method('getAttribute')
->will($this->returnValueMap([
[ CellValueFormatter::XML_ATTRIBUTE_TYPE, $cellType ],
[ CellValueFormatter::XML_ATTRIBUTE_STYLE_ID, 123 ],
]));
$nodeMock
->expects($this->atLeastOnce())
->method('getElementsByTagName')
->with(CellValueFormatter::XML_NODE_VALUE)
->will($this->returnValue($nodeListMock));
$styleHelperMock = $this->getMockBuilder(__NAMESPACE__ . '\StyleHelper')->disableOriginalConstructor()->getMock();
$styleHelperMock
->expects($this->once())
->method('shouldFormatNumericValueAsDate')
->with(123)
->will($this->returnValue(true));
$instance = new CellValueFormatter(null, $styleHelperMock);
$result = $instance->extractAndFormatNodeValue($nodeMock);
if ($expectedDateAsString === null) {
$this->assertNull($result);
} else {
$this->assertInstanceOf('DateTime', $result);
$this->assertSame($expectedDateAsString, $result->format('Y-m-d H:i:s'));
}
}
}
+25
View File
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\ODS;
use Box\Spout\Common\Type;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\TestUsingResource;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\WriterFactory;
/**
@@ -368,6 +369,30 @@ class WriterTest extends \PHPUnit_Framework_TestCase
$this->assertValueWasWrittenToSheet($fileName, 1, 'a dream');
}
/**
* @return void
*/
public function testGeneratedFileShouldHaveTheCorrectMimeType()
{
// Only PHP7+ gives the correct mime type since it requires adding
// uncompressed files to the final archive (which support was added in PHP7)
if (!ZipHelper::canChooseCompressionMethod()) {
$this->markTestSkipped(
'The PHP version used does not support setting the compression method of archived files,
resulting in the mime type to be detected incorrectly.'
);
}
$fileName = 'test_mime_type.ods';
$resourcePath = $this->getGeneratedResourcePath($fileName);
$dataRow = ['foo'];
$this->writeToODSFile([$dataRow], $fileName);
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$this->assertEquals('application/vnd.oasis.opendocument.spreadsheet', $finfo->file($resourcePath));
}
/**
* @param array $allRows
* @param string $fileName
+15
View File
@@ -385,6 +385,21 @@ class WriterTest extends \PHPUnit_Framework_TestCase
$this->assertInlineDataWasWrittenToSheet($fileName, 1, 'control&#039;s _x0015_ &quot;character&quot;');
}
/**
* @return void
*/
public function testGeneratedFileShouldHaveTheCorrectMimeType()
{
$fileName = 'test_mime_type.xlsx';
$resourcePath = $this->getGeneratedResourcePath($fileName);
$dataRows = [['foo']];
$this->writeToXLSXFile($dataRows, $fileName);
$finfo = new \finfo(FILEINFO_MIME_TYPE);
$this->assertEquals('application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', $finfo->file($resourcePath));
}
/**
* @param array $allRows
* @param string $fileName