first commit

This commit is contained in:
2024-11-05 12:22:50 +01:00
commit e5682a3912
19641 changed files with 2948548 additions and 0 deletions

View File

@@ -0,0 +1,146 @@
<?php
/**
* Class Psr4Autoloader
* @see https://github.com/php-fig/fig-standards/blob/master/accepted/PSR-4-autoloader-examples.md#class-example
*/
class Psr4Autoloader
{
/**
* An associative array where the key is a namespace prefix and the value
* is an array of base directories for classes in that namespace.
*
* @var array
*/
protected $prefixes = array();
/**
* Register loader with SPL autoloader stack.
*
* @return void
*/
public function register()
{
spl_autoload_register(array($this, 'loadClass'));
}
/**
* Adds a base directory for a namespace prefix.
*
* @param string $prefix The namespace prefix.
* @param string $baseDir A base directory for class files in the
* namespace.
* @param bool $prepend If true, prepend the base directory to the stack
* instead of appending it; this causes it to be searched first rather
* than last.
* @return void
*/
public function addNamespace($prefix, $baseDir, $prepend = false)
{
// normalize namespace prefix
$prefix = trim($prefix, '\\') . '\\';
// normalize the base directory with a trailing separator
$baseDir = rtrim($baseDir, DIRECTORY_SEPARATOR) . '/';
// initialize the namespace prefix array
if (isset($this->prefixes[$prefix]) === false) {
$this->prefixes[$prefix] = array();
}
// retain the base directory for the namespace prefix
if ($prepend) {
array_unshift($this->prefixes[$prefix], $baseDir);
} else {
array_push($this->prefixes[$prefix], $baseDir);
}
}
/**
* Loads the class file for a given class name.
*
* @param string $class The fully-qualified class name.
* @return mixed The mapped file name on success, or boolean false on
* failure.
*/
public function loadClass($class)
{
// the current namespace prefix
$prefix = $class;
// work backwards through the namespace names of the fully-qualified
// class name to find a mapped file name
while (false !== $pos = strrpos($prefix, '\\')) {
// retain the trailing namespace separator in the prefix
$prefix = substr($class, 0, $pos + 1);
// the rest is the relative class name
$relativeClass = substr($class, $pos + 1);
// try to load a mapped file for the prefix and relative class
$mappedFile = $this->loadMappedFile($prefix, $relativeClass);
if ($mappedFile !== false) {
return $mappedFile;
}
// remove the trailing namespace separator for the next iteration
// of strrpos()
$prefix = rtrim($prefix, '\\');
}
// never found a mapped file
return false;
}
/**
* Load the mapped file for a namespace prefix and relative class.
*
* @param string $prefix The namespace prefix.
* @param string $relativeClass The relative class name.
* @return mixed Boolean false if no mapped file can be loaded, or the
* name of the mapped file that was loaded.
*/
protected function loadMappedFile($prefix, $relativeClass)
{
// are there any base directories for this namespace prefix?
if (isset($this->prefixes[$prefix]) === false) {
return false;
}
// look through base directories for this namespace prefix
foreach ($this->prefixes[$prefix] as $baseDir) {
// replace the namespace prefix with the base directory,
// replace namespace separators with directory separators
// in the relative class name, append with .php
$file = $baseDir
. str_replace('\\', '/', $relativeClass)
. '.php';
// if the mapped file exists, require it
if ($this->requireFile($file)) {
// yes, we're done
return $file;
}
}
// never found it
return false;
}
/**
* If a file exists, require it from the file system.
*
* @param string $file The file to require.
* @return bool True if the file exists, false if not.
*/
protected function requireFile($file)
{
if (file_exists($file)) {
require $file;
return true;
}
return false;
}
}

View File

@@ -0,0 +1,38 @@
<?php
namespace Box\Spout\Common\Escaper;
/**
* Class CSV
* Provides functions to escape and unescape data for CSV files
*
* @package Box\Spout\Common\Escaper
*/
class CSV implements EscaperInterface
{
/**
* Escapes the given string to make it compatible with CSV
*
* @codeCoverageIgnore
*
* @param string $string The string to escape
* @return string The escaped string
*/
public function escape($string)
{
return $string;
}
/**
* Unescapes the given string to make it compatible with CSV
*
* @codeCoverageIgnore
*
* @param string $string The string to unescape
* @return string The unescaped string
*/
public function unescape($string)
{
return $string;
}
}

View File

@@ -0,0 +1,27 @@
<?php
namespace Box\Spout\Common\Escaper;
/**
* Interface EscaperInterface
*
* @package Box\Spout\Common\Escaper
*/
interface EscaperInterface
{
/**
* Escapes the given string to make it compatible with PHP
*
* @param string $string The string to escape
* @return string The escaped string
*/
public function escape($string);
/**
* Unescapes the given string to make it compatible with PHP
*
* @param string $string The string to unescape
* @return string The unescaped string
*/
public function unescape($string);
}

View File

@@ -0,0 +1,66 @@
<?php
namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/**
* Class ODS
* Provides functions to escape and unescape data for ODS files
*
* @package Box\Spout\Common\Escaper
*/
class ODS implements EscaperInterface
{
use Singleton;
/**
* Escapes the given string to make it compatible with XLSX
*
* @param string $string The string to escape
* @return string The escaped string
*/
public function escape($string)
{
if (defined('ENT_DISALLOWED')) {
// '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_NOQUOTES | ENT_DISALLOWED);
} else {
// 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".
$regexPattern = '[\x00-\x08' .
// skipping "\t" (0x9) and "\n" (0xA)
'\x0B-\x0C' .
// skipping "\r" (0xD)
'\x0E-\x1F]';
$replacedString = preg_replace("/$regexPattern/", '<27>', $escapedString);
}
return $replacedString;
}
/**
* Unescapes the given string to make it compatible with XLSX
*
* @param string $string The string to unescape
* @return string The unescaped string
*/
public function unescape($string)
{
// ==============
// = 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;
}
}

View File

@@ -0,0 +1,185 @@
<?php
namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/**
* Class XLSX
* Provides functions to escape and unescape data for XLSX files
*
* @package Box\Spout\Common\Escaper
*/
class XLSX implements EscaperInterface
{
use Singleton;
/** @var string Regex pattern to detect control characters that need to be escaped */
protected $escapableControlCharactersPattern;
/** @var string[] Map containing control characters to be escaped (key) and their escaped value (value) */
protected $controlCharactersEscapingMap;
/** @var string[] Map containing control characters to be escaped (value) and their escaped value (key) */
protected $controlCharactersEscapingReverseMap;
/**
* Initializes the singleton instance
*/
protected function init()
{
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
$this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
}
/**
* Escapes the given string to make it compatible with XLSX
*
* @param string $string The string to escape
* @return string The escaped string
*/
public function escape($string)
{
$escapedString = $this->escapeControlCharacters($string);
// @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;
}
/**
* Unescapes the given string to make it compatible with XLSX
*
* @param string $string The string to unescape
* @return string The unescaped string
*/
public function unescape($string)
{
// ==============
// = 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;
}
/**
* @return string Regex pattern containing all escapable control characters
*/
protected function getEscapableControlCharactersPattern()
{
// 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".
return '[\x00-\x08' .
// skipping "\t" (0x9) and "\n" (0xA)
'\x0B-\x0C' .
// skipping "\r" (0xD)
'\x0E-\x1F]';
}
/**
* Builds the map containing control characters to be escaped
* mapped to their escaped values.
* "\t", "\r" and "\n" don't need to be escaped.
*
* NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
* @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89
*
* @return string[]
*/
protected function getControlCharactersEscapingMap()
{
$controlCharactersEscapingMap = [];
// control characters values are from 0 to 1F (hex values) in the ASCII table
for ($charValue = 0x00; $charValue <= 0x1F; $charValue++) {
$character = chr($charValue);
if (preg_match("/{$this->escapableControlCharactersPattern}/", $character)) {
$charHexValue = dechex($charValue);
$escapedChar = '_x' . sprintf('%04s' , strtoupper($charHexValue)) . '_';
$controlCharactersEscapingMap[$escapedChar] = $character;
}
}
return $controlCharactersEscapingMap;
}
/**
* Converts PHP control characters from the given string to OpenXML escaped control characters
*
* Excel escapes control characters with _xHHHH_ and also escapes any
* literal strings of that type by encoding the leading underscore.
* So "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
*
* NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
* @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89
*
* @param string $string String to escape
* @return string
*/
protected function escapeControlCharacters($string)
{
$escapedString = $this->escapeEscapeCharacter($string);
// if no control characters
if (!preg_match("/{$this->escapableControlCharactersPattern}/", $escapedString)) {
return $escapedString;
}
return preg_replace_callback("/({$this->escapableControlCharactersPattern})/", function($matches) {
return $this->controlCharactersEscapingReverseMap[$matches[0]];
}, $escapedString);
}
/**
* Escapes the escape character: "_x0000_" -> "_x005F_x0000_"
*
* @param string $string String to escape
* @return string The escaped string
*/
protected function escapeEscapeCharacter($string)
{
return preg_replace('/_(x[\dA-F]{4})_/', '_x005F_$1_', $string);
}
/**
* Converts OpenXML escaped control characters from the given string to PHP control characters
*
* Excel escapes control characters with _xHHHH_ and also escapes any
* literal strings of that type by encoding the leading underscore.
* So "_x0000_" -> "\0" and "_x005F_x0000_" -> "_x0000_"
*
* NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
* @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89
*
* @param string $string String to unescape
* @return string
*/
protected function unescapeControlCharacters($string)
{
$unescapedString = $string;
foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) {
// only unescape characters that don't contain the escaped escape character for now
$unescapedString = preg_replace("/(?<!_x005F)($escapedCharValue)/", $charValue, $unescapedString);
}
return $this->unescapeEscapeCharacter($unescapedString);
}
/**
* Unecapes the escape character: "_x005F_x0000_" => "_x0000_"
*
* @param string $string String to unescape
* @return string The unescaped string
*/
protected function unescapeEscapeCharacter($string)
{
return preg_replace('/_x005F(_x[\dA-F]{4}_)/', '$1', $string);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class EncodingConversionException
*
* @api
* @package Box\Spout\Common\Exception
*/
class EncodingConversionException extends SpoutException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class IOException
*
* @api
* @package Box\Spout\Common\Exception
*/
class IOException extends SpoutException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class InvalidArgumentException
*
* @api
* @package Box\Spout\Common\Exception
*/
class InvalidArgumentException extends SpoutException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class SpoutException
*
* @package Box\Spout\Common\Exception
* @abstract
*/
abstract class SpoutException extends \Exception
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class UnsupportedTypeException
*
* @api
* @package Box\Spout\Common\Exception
*/
class UnsupportedTypeException extends SpoutException
{
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Box\Spout\Common\Helper;
use Box\Spout\Common\Exception\EncodingConversionException;
/**
* Class EncodingHelper
* This class provides helper functions to work with encodings.
*
* @package Box\Spout\Common\Helper
*/
class EncodingHelper
{
/** Definition of the encodings that can have a BOM */
const ENCODING_UTF8 = 'UTF-8';
const ENCODING_UTF16_LE = 'UTF-16LE';
const ENCODING_UTF16_BE = 'UTF-16BE';
const ENCODING_UTF32_LE = 'UTF-32LE';
const ENCODING_UTF32_BE = 'UTF-32BE';
/** Definition of the BOMs for the different encodings */
const BOM_UTF8 = "\xEF\xBB\xBF";
const BOM_UTF16_LE = "\xFF\xFE";
const BOM_UTF16_BE = "\xFE\xFF";
const BOM_UTF32_LE = "\xFF\xFE\x00\x00";
const BOM_UTF32_BE = "\x00\x00\xFE\xFF";
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var array Map representing the encodings supporting BOMs (key) and their associated BOM (value) */
protected $supportedEncodingsWithBom;
/**
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($globalFunctionsHelper)
{
$this->globalFunctionsHelper = $globalFunctionsHelper;
$this->supportedEncodingsWithBom = [
self::ENCODING_UTF8 => self::BOM_UTF8,
self::ENCODING_UTF16_LE => self::BOM_UTF16_LE,
self::ENCODING_UTF16_BE => self::BOM_UTF16_BE,
self::ENCODING_UTF32_LE => self::BOM_UTF32_LE,
self::ENCODING_UTF32_BE => self::BOM_UTF32_BE,
];
}
/**
* Returns the number of bytes to use as offset in order to skip the BOM.
*
* @param resource $filePointer Pointer to the file to check
* @param string $encoding Encoding of the file to check
* @return int Bytes offset to apply to skip the BOM (0 means no BOM)
*/
public function getBytesOffsetToSkipBOM($filePointer, $encoding)
{
$byteOffsetToSkipBom = 0;
if ($this->hasBOM($filePointer, $encoding)) {
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
// we skip the N first bytes
$byteOffsetToSkipBom = strlen($bomUsed);
}
return $byteOffsetToSkipBom;
}
/**
* Returns whether the file identified by the given pointer has a BOM.
*
* @param resource $filePointer Pointer to the file to check
* @param string $encoding Encoding of the file to check
* @return bool TRUE if the file has a BOM, FALSE otherwise
*/
protected function hasBOM($filePointer, $encoding)
{
$hasBOM = false;
$this->globalFunctionsHelper->rewind($filePointer);
if (array_key_exists($encoding, $this->supportedEncodingsWithBom)) {
$potentialBom = $this->supportedEncodingsWithBom[$encoding];
$numBytesInBom = strlen($potentialBom);
$hasBOM = ($this->globalFunctionsHelper->fgets($filePointer, $numBytesInBom + 1) === $potentialBom);
}
return $hasBOM;
}
/**
* Attempts to convert a non UTF-8 string into UTF-8.
*
* @param string $string Non UTF-8 string to be converted
* @param string $sourceEncoding The encoding used to encode the source string
* @return string The converted, UTF-8 string
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
*/
public function attemptConversionToUTF8($string, $sourceEncoding)
{
return $this->attemptConversion($string, $sourceEncoding, self::ENCODING_UTF8);
}
/**
* Attempts to convert a UTF-8 string into the given encoding.
*
* @param string $string UTF-8 string to be converted
* @param string $targetEncoding The encoding the string should be re-encoded into
* @return string The converted string, encoded with the given encoding
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
*/
public function attemptConversionFromUTF8($string, $targetEncoding)
{
return $this->attemptConversion($string, self::ENCODING_UTF8, $targetEncoding);
}
/**
* Attempts to convert the given string to the given encoding.
* Depending on what is installed on the server, we will try to iconv or mbstring.
*
* @param string $string string to be converted
* @param string $sourceEncoding The encoding used to encode the source string
* @param string $targetEncoding The encoding the string should be re-encoded into
* @return string The converted string, encoded with the given encoding
* @throws \Box\Spout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
*/
protected function attemptConversion($string, $sourceEncoding, $targetEncoding)
{
// if source and target encodings are the same, it's a no-op
if ($sourceEncoding === $targetEncoding) {
return $string;
}
$convertedString = null;
if ($this->canUseIconv()) {
$convertedString = $this->globalFunctionsHelper->iconv($string, $sourceEncoding, $targetEncoding);
} else if ($this->canUseMbString()) {
$convertedString = $this->globalFunctionsHelper->mb_convert_encoding($string, $sourceEncoding, $targetEncoding);
} else {
throw new EncodingConversionException("The conversion from $sourceEncoding to $targetEncoding is not supported. Please install \"iconv\" or \"PHP Intl\".");
}
if ($convertedString === false) {
throw new EncodingConversionException("The conversion from $sourceEncoding to $targetEncoding failed.");
}
return $convertedString;
}
/**
* Returns whether "iconv" can be used.
*
* @return bool TRUE if "iconv" is available and can be used, FALSE otherwise
*/
protected function canUseIconv()
{
return $this->globalFunctionsHelper->function_exists('iconv');
}
/**
* Returns whether "mb_string" functions can be used.
* These functions come with the PHP Intl package.
*
* @return bool TRUE if "mb_string" functions are available and can be used, FALSE otherwise
*/
protected function canUseMbString()
{
return $this->globalFunctionsHelper->function_exists('mb_convert_encoding');
}
}

View File

@@ -0,0 +1,133 @@
<?php
namespace Box\Spout\Common\Helper;
use Box\Spout\Common\Exception\IOException;
/**
* Class FileSystemHelper
* This class provides helper functions to help with the file system operations
* like files/folders creation & deletion
*
* @package Box\Spout\Common\Helper
*/
class FileSystemHelper
{
/** @var string Real path of the base folder where all the I/O can occur */
protected $baseFolderRealPath;
/**
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
*/
public function __construct($baseFolderPath)
{
$this->baseFolderRealPath = realpath($baseFolderPath);
}
/**
* Creates an empty folder with the given name under the given parent folder.
*
* @param string $parentFolderPath The parent folder path under which the folder is going to be created
* @param string $folderName The name of the folder to create
* @return string Path of the created folder
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or if the folder path is not inside of the base folder
*/
public function createFolder($parentFolderPath, $folderName)
{
$this->throwIfOperationNotInBaseFolder($parentFolderPath);
$folderPath = $parentFolderPath . '/' . $folderName;
$wasCreationSuccessful = mkdir($folderPath, 0777, true);
if (!$wasCreationSuccessful) {
throw new IOException("Unable to create folder: $folderPath");
}
return $folderPath;
}
/**
* Creates a file with the given name and content in the given folder.
* The parent folder must exist.
*
* @param string $parentFolderPath The parent folder path where the file is going to be created
* @param string $fileName The name of the file to create
* @param string $fileContents The contents of the file to create
* @return string Path of the created file
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file or if the file path is not inside of the base folder
*/
public function createFileWithContents($parentFolderPath, $fileName, $fileContents)
{
$this->throwIfOperationNotInBaseFolder($parentFolderPath);
$filePath = $parentFolderPath . '/' . $fileName;
$wasCreationSuccessful = file_put_contents($filePath, $fileContents);
if ($wasCreationSuccessful === false) {
throw new IOException("Unable to create file: $filePath");
}
return $filePath;
}
/**
* Delete the file at the given path
*
* @param string $filePath Path of the file to delete
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the file path is not inside of the base folder
*/
public function deleteFile($filePath)
{
$this->throwIfOperationNotInBaseFolder($filePath);
if (file_exists($filePath) && is_file($filePath)) {
unlink($filePath);
}
}
/**
* Delete the folder at the given path as well as all its contents
*
* @param string $folderPath Path of the folder to delete
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the folder path is not inside of the base folder
*/
public function deleteFolderRecursively($folderPath)
{
$this->throwIfOperationNotInBaseFolder($folderPath);
$itemIterator = new \RecursiveIteratorIterator(
new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS),
\RecursiveIteratorIterator::CHILD_FIRST
);
foreach ($itemIterator as $item) {
if ($item->isDir()) {
rmdir($item->getPathname());
} else {
unlink($item->getPathname());
}
}
rmdir($folderPath);
}
/**
* All I/O operations must occur inside the base folder, for security reasons.
* This function will throw an exception if the folder where the I/O operation
* should occur is not inside the base folder.
*
* @param string $operationFolderPath The path of the folder where the I/O operation should occur
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the folder where the I/O operation should occur is not inside the base folder
*/
protected function throwIfOperationNotInBaseFolder($operationFolderPath)
{
$operationFolderRealPath = realpath($operationFolderPath);
$isInBaseFolder = (strpos($operationFolderRealPath, $this->baseFolderRealPath) === 0);
if (!$isInBaseFolder) {
throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderRealPath}");
}
}
}

View File

@@ -0,0 +1,318 @@
<?php
namespace Box\Spout\Common\Helper;
/**
* Class GlobalFunctionsHelper
* This class wraps global functions to facilitate testing
*
* @codeCoverageIgnore
*
* @package Box\Spout\Common\Helper
*/
class GlobalFunctionsHelper
{
/**
* Wrapper around global function fopen()
* @see fopen()
*
* @param string $fileName
* @param string $mode
* @return resource|bool
*/
public function fopen($fileName, $mode)
{
return fopen($fileName, $mode);
}
/**
* Wrapper around global function fgets()
* @see fgets()
*
* @param resource $handle
* @param int|void $length
* @return string
*/
public function fgets($handle, $length = null)
{
return fgets($handle, $length);
}
/**
* Wrapper around global function fputs()
* @see fputs()
*
* @param resource $handle
* @param string $string
* @return int
*/
public function fputs($handle, $string)
{
return fputs($handle, $string);
}
/**
* Wrapper around global function fflush()
* @see fflush()
*
* @param resource $handle
* @return bool
*/
public function fflush($handle)
{
return fflush($handle);
}
/**
* Wrapper around global function fseek()
* @see fseek()
*
* @param resource $handle
* @param int $offset
* @return int
*/
public function fseek($handle, $offset)
{
return fseek($handle, $offset);
}
/**
* Wrapper around global function fgetcsv()
* @see fgetcsv()
*
* @param resource $handle
* @param int|void $length
* @param string|void $delimiter
* @param string|void $enclosure
* @return array
*/
public function fgetcsv($handle, $length = null, $delimiter = null, $enclosure = null)
{
return fgetcsv($handle, $length, $delimiter, $enclosure);
}
/**
* Wrapper around global function fputcsv()
* @see fputcsv()
*
* @param resource $handle
* @param array $fields
* @param string|void $delimiter
* @param string|void $enclosure
* @return int
*/
public function fputcsv($handle, array $fields, $delimiter = null, $enclosure = null)
{
return fputcsv($handle, $fields, $delimiter, $enclosure);
}
/**
* Wrapper around global function fwrite()
* @see fwrite()
*
* @param resource $handle
* @param string $string
* @return int
*/
public function fwrite($handle, $string)
{
return fwrite($handle, $string);
}
/**
* Wrapper around global function fclose()
* @see fclose()
*
* @param resource $handle
* @return bool
*/
public function fclose($handle)
{
return fclose($handle);
}
/**
* Wrapper around global function rewind()
* @see rewind()
*
* @param resource $handle
* @return bool
*/
public function rewind($handle)
{
return rewind($handle);
}
/**
* Wrapper around global function file_exists()
* @see file_exists()
*
* @param string $fileName
* @return bool
*/
public function file_exists($fileName)
{
return file_exists($fileName);
}
/**
* Wrapper around global function file_get_contents()
* @see file_get_contents()
*
* @param string $filePath
* @return string
*/
public function file_get_contents($filePath)
{
$realFilePath = $this->convertToUseRealPath($filePath);
return file_get_contents($realFilePath);
}
/**
* Updates the given file path to use a real path.
* This is to avoid issues on some Windows setup.
*
* @param string $filePath File path
* @return string The file path using a real path
*/
protected function convertToUseRealPath($filePath)
{
$realFilePath = $filePath;
if ($this->isZipStream($filePath)) {
if (preg_match('/zip:\/\/(.*)#(.*)/', $filePath, $matches)) {
$documentPath = $matches[1];
$documentInsideZipPath = $matches[2];
$realFilePath = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
}
} else {
$realFilePath = realpath($filePath);
}
return $realFilePath;
}
/**
* Returns whether the given path is a zip stream.
*
* @param string $path Path pointing to a document
* @return bool TRUE if path is a zip stream, FALSE otherwise
*/
protected function isZipStream($path)
{
return (strpos($path, 'zip://') === 0);
}
/**
* Wrapper around global function feof()
* @see feof()
*
* @param resource
* @return bool
*/
public function feof($handle)
{
return feof($handle);
}
/**
* Wrapper around global function is_readable()
* @see is_readable()
*
* @param string $fileName
* @return bool
*/
public function is_readable($fileName)
{
return is_readable($fileName);
}
/**
* Wrapper around global function basename()
* @see basename()
*
* @param string $path
* @param string|void $suffix
* @return string
*/
public function basename($path, $suffix = null)
{
return basename($path, $suffix);
}
/**
* Wrapper around global function header()
* @see header()
*
* @param string $string
* @return void
*/
public function header($string)
{
header($string);
}
/**
* Wrapper around global function ob_end_clean()
* @see ob_end_clean()
*
* @return void
*/
public function ob_end_clean()
{
if (ob_get_length() > 0) {
ob_end_clean();
}
}
/**
* Wrapper around global function iconv()
* @see iconv()
*
* @param string $string The string to be converted
* @param string $sourceEncoding The encoding of the source string
* @param string $targetEncoding The encoding the source string should be converted to
* @return string|bool the converted string or FALSE on failure.
*/
public function iconv($string, $sourceEncoding, $targetEncoding)
{
return iconv($sourceEncoding, $targetEncoding, $string);
}
/**
* Wrapper around global function mb_convert_encoding()
* @see mb_convert_encoding()
*
* @param string $string The string to be converted
* @param string $sourceEncoding The encoding of the source string
* @param string $targetEncoding The encoding the source string should be converted to
* @return string|bool the converted string or FALSE on failure.
*/
public function mb_convert_encoding($string, $sourceEncoding, $targetEncoding)
{
return mb_convert_encoding($string, $targetEncoding, $sourceEncoding);
}
/**
* Wrapper around global function stream_get_wrappers()
* @see stream_get_wrappers()
*
* @return array
*/
public function stream_get_wrappers()
{
return stream_get_wrappers();
}
/**
* Wrapper around global function function_exists()
* @see function_exists()
*
* @param string $functionName
* @return bool
*/
public function function_exists($functionName)
{
return function_exists($functionName);
}
}

View File

@@ -0,0 +1,71 @@
<?php
namespace Box\Spout\Common\Helper;
/**
* Class StringHelper
* This class provides helper functions to work with strings and multibyte strings.
*
* @codeCoverageIgnore
*
* @package Box\Spout\Common\Helper
*/
class StringHelper
{
/** @var bool Whether the mbstring extension is loaded */
protected $hasMbstringSupport;
/**
*
*/
public function __construct()
{
$this->hasMbstringSupport = extension_loaded('mbstring');
}
/**
* Returns the length of the given string.
* It uses the multi-bytes function is available.
* @see strlen
* @see mb_strlen
*
* @param string $string
* @return int
*/
public function getStringLength($string)
{
return $this->hasMbstringSupport ? mb_strlen($string) : strlen($string);
}
/**
* Returns the position of the first occurrence of the given character/substring within the given string.
* It uses the multi-bytes function is available.
* @see strpos
* @see mb_strpos
*
* @param string $char Needle
* @param string $string Haystack
* @return int Char/substring's first occurrence position within the string if found (starts at 0) or -1 if not found
*/
public function getCharFirstOccurrencePosition($char, $string)
{
$position = $this->hasMbstringSupport ? mb_strpos($string, $char) : strpos($string, $char);
return ($position !== false) ? $position : -1;
}
/**
* Returns the position of the last occurrence of the given character/substring within the given string.
* It uses the multi-bytes function is available.
* @see strrpos
* @see mb_strrpos
*
* @param string $char Needle
* @param string $string Haystack
* @return int Char/substring's last occurrence position within the string if found (starts at 0) or -1 if not found
*/
public function getCharLastOccurrencePosition($char, $string)
{
$position = $this->hasMbstringSupport ? mb_strrpos($string, $char) : strrpos($string, $char);
return ($position !== false) ? $position : -1;
}
}

View File

@@ -0,0 +1,41 @@
<?php
namespace Box\Spout\Common;
/**
* Class Singleton
* Defines a class as a singleton.
*
* @package Box\Spout\Common
*/
trait Singleton
{
protected static $instance;
/**
* @return static
*/
final public static function getInstance()
{
return isset(static::$instance)
? static::$instance
: static::$instance = new static;
}
/**
* Singleton constructor.
*/
final private function __construct()
{
$this->init();
}
/**
* Initializes the singleton
* @return void
*/
protected function init() {}
final private function __wakeup() {}
final private function __clone() {}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Common;
/**
* Class Type
* This class references the supported types
*
* @api
*/
abstract class Type
{
const CSV = 'csv';
const XLSX = 'xlsx';
const ODS = 'ods';
}

View File

@@ -0,0 +1,238 @@
<?php
namespace Box\Spout\Reader;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\ReaderNotOpenedException;
/**
* Class AbstractReader
*
* @package Box\Spout\Reader
* @abstract
*/
abstract class AbstractReader implements ReaderInterface
{
/** @var bool Indicates whether the stream is currently open */
protected $isStreamOpened = false;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var \Box\Spout\Reader\Common\ReaderOptions Reader's customized options */
protected $options;
/**
* Returns the reader's current options
*
* @return \Box\Spout\Reader\Common\ReaderOptions
*/
abstract protected function getOptions();
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
abstract protected function doesSupportStreamWrapper();
/**
* Opens the file at the given file path to make it ready to be read
*
* @param string $filePath Path of the file to be read
* @return void
*/
abstract protected function openReader($filePath);
/**
* Returns an iterator to iterate over sheets.
*
* @return \Iterator To iterate over sheets
*/
abstract protected function getConcreteSheetIterator();
/**
* Closes the reader. To be used after reading the file.
*
* @return AbstractReader
*/
abstract protected function closeReader();
/**
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @return AbstractReader
*/
public function setGlobalFunctionsHelper($globalFunctionsHelper)
{
$this->globalFunctionsHelper = $globalFunctionsHelper;
return $this;
}
/**
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
*
* @api
* @param bool $shouldFormatDates
* @return AbstractReader
*/
public function setShouldFormatDates($shouldFormatDates)
{
$this->getOptions()->setShouldFormatDates($shouldFormatDates);
return $this;
}
/**
* Sets whether empty rows should be returned or skipped.
*
* @api
* @param bool $shouldPreserveEmptyRows
* @return AbstractReader
*/
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
{
$this->getOptions()->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
return $this;
}
/**
* Prepares the reader to read the given file. It also makes sure
* that the file exists and is readable.
*
* @api
* @param string $filePath Path of the file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path does not exist, is not readable or is corrupted
*/
public function open($filePath)
{
if ($this->isStreamWrapper($filePath) && (!$this->doesSupportStreamWrapper() || !$this->isSupportedStreamWrapper($filePath))) {
throw new IOException("Could not open $filePath for reading! Stream wrapper used is not supported for this type of file.");
}
if (!$this->isPhpStream($filePath)) {
// we skip the checks if the provided file path points to a PHP stream
if (!$this->globalFunctionsHelper->file_exists($filePath)) {
throw new IOException("Could not open $filePath for reading! File does not exist.");
} else if (!$this->globalFunctionsHelper->is_readable($filePath)) {
throw new IOException("Could not open $filePath for reading! File is not readable.");
}
}
try {
$fileRealPath = $this->getFileRealPath($filePath);
$this->openReader($fileRealPath);
$this->isStreamOpened = true;
} catch (\Exception $exception) {
throw new IOException("Could not open $filePath for reading! ({$exception->getMessage()})");
}
}
/**
* Returns the real path of the given path.
* If the given path is a valid stream wrapper, returns the path unchanged.
*
* @param string $filePath
* @return string
*/
protected function getFileRealPath($filePath)
{
if ($this->isSupportedStreamWrapper($filePath)) {
return $filePath;
}
// Need to use realpath to fix "Can't open file" on some Windows setup
return realpath($filePath);
}
/**
* Returns the scheme of the custom stream wrapper, if the path indicates a stream wrapper is used.
* For example, php://temp => php, s3://path/to/file => s3...
*
* @param string $filePath Path of the file to be read
* @return string|null The stream wrapper scheme or NULL if not a stream wrapper
*/
protected function getStreamWrapperScheme($filePath)
{
$streamScheme = null;
if (preg_match('/^(\w+):\/\//', $filePath, $matches)) {
$streamScheme = $matches[1];
}
return $streamScheme;
}
/**
* Checks if the given path is an unsupported stream wrapper
* (like local path, php://temp, mystream://foo/bar...).
*
* @param string $filePath Path of the file to be read
* @return bool Whether the given path is an unsupported stream wrapper
*/
protected function isStreamWrapper($filePath)
{
return ($this->getStreamWrapperScheme($filePath) !== null);
}
/**
* Checks if the given path is an supported stream wrapper
* (like php://temp, mystream://foo/bar...).
* If the given path is a local path, returns true.
*
* @param string $filePath Path of the file to be read
* @return bool Whether the given path is an supported stream wrapper
*/
protected function isSupportedStreamWrapper($filePath)
{
$streamScheme = $this->getStreamWrapperScheme($filePath);
return ($streamScheme !== null) ?
in_array($streamScheme, $this->globalFunctionsHelper->stream_get_wrappers()) :
true;
}
/**
* Checks if a path is a PHP stream (like php://output, php://memory, ...)
*
* @param string $filePath Path of the file to be read
* @return bool Whether the given path maps to a PHP stream
*/
protected function isPhpStream($filePath)
{
$streamScheme = $this->getStreamWrapperScheme($filePath);
return ($streamScheme === 'php');
}
/**
* Returns an iterator to iterate over sheets.
*
* @api
* @return \Iterator To iterate over sheets
* @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If called before opening the reader
*/
public function getSheetIterator()
{
if (!$this->isStreamOpened) {
throw new ReaderNotOpenedException('Reader should be opened first.');
}
return $this->getConcreteSheetIterator();
}
/**
* Closes the reader, preventing any additional reading
*
* @api
* @return void
*/
public function close()
{
if ($this->isStreamOpened) {
$this->closeReader();
$sheetIterator = $this->getConcreteSheetIterator();
if ($sheetIterator) {
$sheetIterator->end();
}
$this->isStreamOpened = false;
}
}
}

View File

@@ -0,0 +1,149 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Reader\AbstractReader;
use Box\Spout\Common\Exception\IOException;
/**
* Class Reader
* This class provides support to read data from a CSV file.
*
* @package Box\Spout\Reader\CSV
*/
class Reader extends AbstractReader
{
/** @var resource Pointer to the file to be written */
protected $filePointer;
/** @var SheetIterator To iterator over the CSV unique "sheet" */
protected $sheetIterator;
/** @var string Original value for the "auto_detect_line_endings" INI value */
protected $originalAutoDetectLineEndings;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* Sets the field delimiter for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldDelimiter Character that delimits fields
* @return Reader
*/
public function setFieldDelimiter($fieldDelimiter)
{
$this->getOptions()->setFieldDelimiter($fieldDelimiter);
return $this;
}
/**
* Sets the field enclosure for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldEnclosure Character that enclose fields
* @return Reader
*/
public function setFieldEnclosure($fieldEnclosure)
{
$this->getOptions()->setFieldEnclosure($fieldEnclosure);
return $this;
}
/**
* Sets the encoding of the CSV file to be read.
* Needs to be called before opening the reader.
*
* @param string $encoding Encoding of the CSV file to be read
* @return Reader
*/
public function setEncoding($encoding)
{
$this->getOptions()->setEncoding($encoding);
return $this;
}
/**
* Sets the EOL for the CSV.
* Needs to be called before opening the reader.
*
* @param string $endOfLineCharacter used to properly get lines from the CSV file.
* @return Reader
*/
public function setEndOfLineCharacter($endOfLineCharacter)
{
$this->getOptions()->setEndOfLineCharacter($endOfLineCharacter);
return $this;
}
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return true;
}
/**
* Opens the file at the given path to make it ready to be read.
* If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
*
* @param string $filePath Path of the CSV file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException
*/
protected function openReader($filePath)
{
$this->originalAutoDetectLineEndings = ini_get('auto_detect_line_endings');
ini_set('auto_detect_line_endings', '1');
$this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
if (!$this->filePointer) {
throw new IOException("Could not open file $filePath for reading.");
}
$this->sheetIterator = new SheetIterator(
$this->filePointer,
$this->getOptions(),
$this->globalFunctionsHelper
);
}
/**
* Returns an iterator to iterate over sheets.
*
* @return SheetIterator To iterate over sheets
*/
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
/**
* Closes the reader. To be used after reading the file.
*
* @return void
*/
protected function closeReader()
{
if ($this->filePointer) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
ini_set('auto_detect_line_endings', $this->originalAutoDetectLineEndings);
}
}

View File

@@ -0,0 +1,110 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Common\Helper\EncodingHelper;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\CSV
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
/** @var string Defines the character used to delimit fields (one character only) */
protected $fieldDelimiter = ',';
/** @var string Defines the character used to enclose fields (one character only) */
protected $fieldEnclosure = '"';
/** @var string Encoding of the CSV file to be read */
protected $encoding = EncodingHelper::ENCODING_UTF8;
/** @var string Defines the End of line */
protected $endOfLineCharacter = "\n";
/**
* @return string
*/
public function getFieldDelimiter()
{
return $this->fieldDelimiter;
}
/**
* Sets the field delimiter for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldDelimiter Character that delimits fields
* @return ReaderOptions
*/
public function setFieldDelimiter($fieldDelimiter)
{
$this->fieldDelimiter = $fieldDelimiter;
return $this;
}
/**
* @return string
*/
public function getFieldEnclosure()
{
return $this->fieldEnclosure;
}
/**
* Sets the field enclosure for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldEnclosure Character that enclose fields
* @return ReaderOptions
*/
public function setFieldEnclosure($fieldEnclosure)
{
$this->fieldEnclosure = $fieldEnclosure;
return $this;
}
/**
* @return string
*/
public function getEncoding()
{
return $this->encoding;
}
/**
* Sets the encoding of the CSV file to be read.
* Needs to be called before opening the reader.
*
* @param string $encoding Encoding of the CSV file to be read
* @return ReaderOptions
*/
public function setEncoding($encoding)
{
$this->encoding = $encoding;
return $this;
}
/**
* @return string EOL for the CSV
*/
public function getEndOfLineCharacter()
{
return $this->endOfLineCharacter;
}
/**
* Sets the EOL for the CSV.
* Needs to be called before opening the reader.
*
* @param string $endOfLineCharacter used to properly get lines from the CSV file.
* @return ReaderOptions
*/
public function setEndOfLineCharacter($endOfLineCharacter)
{
$this->endOfLineCharacter = $endOfLineCharacter;
return $this;
}
}

View File

@@ -0,0 +1,260 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Common\Helper\EncodingHelper;
/**
* Class RowIterator
* Iterate over CSV rows.
*
* @package Box\Spout\Reader\CSV
*/
class RowIterator implements IteratorInterface
{
/**
* Value passed to fgetcsv. 0 means "unlimited" (slightly slower but accomodates for very long lines).
*/
const MAX_READ_BYTES_PER_LINE = 0;
/** @var resource Pointer to the CSV file to read */
protected $filePointer;
/** @var int Number of read rows */
protected $numReadRows = 0;
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
protected $rowDataBuffer = null;
/** @var bool Indicates whether all rows have been read */
protected $hasReachedEndOfFile = false;
/** @var string Defines the character used to delimit fields (one character only) */
protected $fieldDelimiter;
/** @var string Defines the character used to enclose fields (one character only) */
protected $fieldEnclosure;
/** @var string Encoding of the CSV file to be read */
protected $encoding;
/** @var string End of line delimiter, given by the user as input. */
protected $inputEOLDelimiter;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var \Box\Spout\Common\Helper\EncodingHelper Helper to work with different encodings */
protected $encodingHelper;
/** @var string End of line delimiter, encoded using the same encoding as the CSV */
protected $encodedEOLDelimiter;
/**
* @param resource $filePointer Pointer to the CSV file to read
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->filePointer = $filePointer;
$this->fieldDelimiter = $options->getFieldDelimiter();
$this->fieldEnclosure = $options->getFieldEnclosure();
$this->encoding = $options->getEncoding();
$this->inputEOLDelimiter = $options->getEndOfLineCharacter();
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
$this->globalFunctionsHelper = $globalFunctionsHelper;
$this->encodingHelper = new EncodingHelper($globalFunctionsHelper);
}
/**
* Rewind the Iterator to the first element
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
*/
public function rewind()
{
$this->rewindAndSkipBom();
$this->numReadRows = 0;
$this->rowDataBuffer = null;
$this->next();
}
/**
* This rewinds and skips the BOM if inserted at the beginning of the file
* by moving the file pointer after it, so that it is not read.
*
* @return void
*/
protected function rewindAndSkipBom()
{
$byteOffsetToSkipBom = $this->encodingHelper->getBytesOffsetToSkipBOM($this->filePointer, $this->encoding);
// sets the cursor after the BOM (0 means no BOM, so rewind it)
$this->globalFunctionsHelper->fseek($this->filePointer, $byteOffsetToSkipBom);
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return ($this->filePointer && !$this->hasReachedEndOfFile);
}
/**
* Move forward to next element. Reads data for the next unprocessed row.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
*/
public function next()
{
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
if (!$this->hasReachedEndOfFile) {
$this->readDataForNextRow();
}
}
/**
* @return void
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
*/
protected function readDataForNextRow()
{
do {
$rowData = $this->getNextUTF8EncodedRow();
} while ($this->shouldReadNextRow($rowData));
if ($rowData !== false) {
// str_replace will replace NULL values by empty strings
$this->rowDataBuffer = str_replace(null, null, $rowData);
$this->numReadRows++;
} else {
// If we reach this point, it means end of file was reached.
// This happens when the last lines are empty lines.
$this->hasReachedEndOfFile = true;
}
}
/**
* @param array|bool $currentRowData
* @return bool Whether the data for the current row can be returned or if we need to keep reading
*/
protected function shouldReadNextRow($currentRowData)
{
$hasSuccessfullyFetchedRowData = ($currentRowData !== false);
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
$isEmptyLine = $this->isEmptyLine($currentRowData);
return (
(!$hasSuccessfullyFetchedRowData && !$hasNowReachedEndOfFile) ||
(!$this->shouldPreserveEmptyRows && $isEmptyLine)
);
}
/**
* Returns the next row, converted if necessary to UTF-8.
* As fgetcsv() does not manage correctly encoding for non UTF-8 data,
* we remove manually whitespace with ltrim or rtrim (depending on the order of the bytes)
*
* @return array|false The row for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
*/
protected function getNextUTF8EncodedRow()
{
$encodedRowData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $this->fieldDelimiter, $this->fieldEnclosure);
if ($encodedRowData === false) {
return false;
}
foreach ($encodedRowData as $cellIndex => $cellValue) {
switch($this->encoding) {
case EncodingHelper::ENCODING_UTF16_LE:
case EncodingHelper::ENCODING_UTF32_LE:
// remove whitespace from the beginning of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
$cellValue = ltrim($cellValue);
break;
case EncodingHelper::ENCODING_UTF16_BE:
case EncodingHelper::ENCODING_UTF32_BE:
// remove whitespace from the end of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
$cellValue = rtrim($cellValue);
break;
}
$encodedRowData[$cellIndex] = $this->encodingHelper->attemptConversionToUTF8($cellValue, $this->encoding);
}
return $encodedRowData;
}
/**
* Returns the end of line delimiter, encoded using the same encoding as the CSV.
* The return value is cached.
*
* @return string
*/
protected function getEncodedEOLDelimiter()
{
if (!isset($this->encodedEOLDelimiter)) {
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8($this->inputEOLDelimiter, $this->encoding);
}
return $this->encodedEOLDelimiter;
}
/**
* @param array|bool $lineData Array containing the cells value for the line
* @return bool Whether the given line is empty
*/
protected function isEmptyLine($lineData)
{
return (is_array($lineData) && count($lineData) === 1 && $lineData[0] === null);
}
/**
* Return the current element from the buffer
* @link http://php.net/manual/en/iterator.current.php
*
* @return array|null
*/
public function current()
{
return $this->rowDataBuffer;
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return $this->numReadRows;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
// do nothing
}
}

View File

@@ -0,0 +1,62 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Reader\SheetInterface;
/**
* Class Sheet
*
* @package Box\Spout\Reader\CSV
*/
class Sheet implements SheetInterface
{
/** @var \Box\Spout\Reader\CSV\RowIterator To iterate over the CSV's rows */
protected $rowIterator;
/**
* @param resource $filePointer Pointer to the CSV file to read
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->rowIterator = new RowIterator($filePointer, $options, $globalFunctionsHelper);
}
/**
* @api
* @return \Box\Spout\Reader\CSV\RowIterator
*/
public function getRowIterator()
{
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;
}
}

View File

@@ -0,0 +1,95 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Reader\IteratorInterface;
/**
* Class SheetIterator
* Iterate over CSV unique "sheet".
*
* @package Box\Spout\Reader\CSV
*/
class SheetIterator implements IteratorInterface
{
/** @var \Box\Spout\Reader\CSV\Sheet The CSV unique "sheet" */
protected $sheet;
/** @var bool Whether the unique "sheet" has already been read */
protected $hasReadUniqueSheet = false;
/**
* @param resource $filePointer
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->sheet = new Sheet($filePointer, $options, $globalFunctionsHelper);
}
/**
* Rewind the Iterator to the first element
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
*/
public function rewind()
{
$this->hasReadUniqueSheet = false;
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return (!$this->hasReadUniqueSheet);
}
/**
* Move forward to next element
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
*/
public function next()
{
$this->hasReadUniqueSheet = true;
}
/**
* Return the current element
* @link http://php.net/manual/en/iterator.current.php
*
* @return \Box\Spout\Reader\CSV\Sheet
*/
public function current()
{
return $this->sheet;
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return 1;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
// do nothing
}
}

View File

@@ -0,0 +1,58 @@
<?php
namespace Box\Spout\Reader\Common;
/**
* Class ReaderOptions
* Readers' common options
*
* @package Box\Spout\Reader\Common
*/
class ReaderOptions
{
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates = false;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows = false;
/**
* @return bool Whether date/time values should be returned as PHP objects or be formatted as strings.
*/
public function shouldFormatDates()
{
return $this->shouldFormatDates;
}
/**
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
*
* @param bool $shouldFormatDates
* @return ReaderOptions
*/
public function setShouldFormatDates($shouldFormatDates)
{
$this->shouldFormatDates = $shouldFormatDates;
return $this;
}
/**
* @return bool Whether empty rows should be returned or skipped.
*/
public function shouldPreserveEmptyRows()
{
return $this->shouldPreserveEmptyRows;
}
/**
* Sets whether empty rows should be returned or skipped.
*
* @param bool $shouldPreserveEmptyRows
* @return ReaderOptions
*/
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
{
$this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows;
return $this;
}
}

View File

@@ -0,0 +1,152 @@
<?php
namespace Box\Spout\Reader\Common;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class XMLProcessor
* Helps process XML files
*
* @package Box\Spout\Reader\Common
*/
class XMLProcessor
{
/* Node types */
const NODE_TYPE_START = XMLReader::ELEMENT;
const NODE_TYPE_END = XMLReader::END_ELEMENT;
/* Keys associated to reflection attributes to invoke a callback */
const CALLBACK_REFLECTION_METHOD = 'reflectionMethod';
const CALLBACK_REFLECTION_OBJECT = 'reflectionObject';
/* Values returned by the callbacks to indicate what the processor should do next */
const PROCESSING_CONTINUE = 1;
const PROCESSING_STOP = 2;
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var array Registered callbacks */
private $callbacks = [];
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object
*/
public function __construct($xmlReader)
{
$this->xmlReader = $xmlReader;
}
/**
* @param string $nodeName A callback may be triggered when a node with this name is read
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @param callable $callback Callback to execute when the read node has the given name and type
* @return XMLProcessor
*/
public function registerCallback($nodeName, $nodeType, $callback)
{
$callbackKey = $this->getCallbackKey($nodeName, $nodeType);
$this->callbacks[$callbackKey] = $this->getInvokableCallbackData($callback);
return $this;
}
/**
* @param string $nodeName Name of the node
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @return string Key used to store the associated callback
*/
private function getCallbackKey($nodeName, $nodeType)
{
return "$nodeName$nodeType";
}
/**
* Because the callback can be a "protected" function, we don't want to use call_user_func() directly
* but instead invoke the callback using Reflection. This allows the invocation of "protected" functions.
* Since some functions can be called a lot, we pre-process the callback to only return the elements that
* will be needed to invoke the callback later.
*
* @param callable $callback Array reference to a callback: [OBJECT, METHOD_NAME]
* @return array Associative array containing the elements needed to invoke the callback using Reflection
*/
private function getInvokableCallbackData($callback)
{
$callbackObject = $callback[0];
$callbackMethodName = $callback[1];
$reflectionMethod = new \ReflectionMethod(get_class($callbackObject), $callbackMethodName);
$reflectionMethod->setAccessible(true);
return [
self::CALLBACK_REFLECTION_METHOD => $reflectionMethod,
self::CALLBACK_REFLECTION_OBJECT => $callbackObject,
];
}
/**
* Resumes the reading of the XML file where it was left off.
* Stops whenever a callback indicates that reading should stop or at the end of the file.
*
* @return void
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
*/
public function readUntilStopped()
{
while ($this->xmlReader->read()) {
$nodeType = $this->xmlReader->nodeType;
$nodeNamePossiblyWithPrefix = $this->xmlReader->name;
$nodeNameWithoutPrefix = $this->xmlReader->localName;
$callbackData = $this->getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType);
if ($callbackData !== null) {
$callbackResponse = $this->invokeCallback($callbackData, [$this->xmlReader]);
if ($callbackResponse === self::PROCESSING_STOP) {
// stop reading
break;
}
}
}
}
/**
* @param string $nodeNamePossiblyWithPrefix Name of the node, possibly prefixed
* @param string $nodeNameWithoutPrefix Name of the same node, un-prefixed
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @return array|null Callback data to be used for execution when a node of the given name/type is read or NULL if none found
*/
private function getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType)
{
// With prefixed nodes, we should match if (by order of preference):
// 1. the callback was registered with the prefixed node name (e.g. "x:worksheet")
// 2. the callback was registered with the un-prefixed node name (e.g. "worksheet")
$callbackKeyForPossiblyPrefixedName = $this->getCallbackKey($nodeNamePossiblyWithPrefix, $nodeType);
$callbackKeyForUnPrefixedName = $this->getCallbackKey($nodeNameWithoutPrefix, $nodeType);
$hasPrefix = ($nodeNamePossiblyWithPrefix !== $nodeNameWithoutPrefix);
$callbackKeyToUse = $callbackKeyForUnPrefixedName;
if ($hasPrefix && isset($this->callbacks[$callbackKeyForPossiblyPrefixedName])) {
$callbackKeyToUse = $callbackKeyForPossiblyPrefixedName;
}
// Using isset here because it is way faster than array_key_exists...
return isset($this->callbacks[$callbackKeyToUse]) ? $this->callbacks[$callbackKeyToUse] : null;
}
/**
* @param array $callbackData Associative array containing data to invoke the callback using Reflection
* @param array $args Arguments to pass to the callback
* @return int Callback response
*/
private function invokeCallback($callbackData, $args)
{
$reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD];
$callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT];
return $reflectionMethod->invokeArgs($callbackObject, $args);
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class IteratorNotRewindableException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class IteratorNotRewindableException extends ReaderException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class NoSheetsFoundException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class NoSheetsFoundException extends ReaderException
{
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Box\Spout\Reader\Exception;
use Box\Spout\Common\Exception\SpoutException;
/**
* Class ReaderException
*
* @package Box\Spout\Reader\Exception
* @abstract
*/
abstract class ReaderException extends SpoutException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class ReaderNotOpenedException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class ReaderNotOpenedException extends ReaderException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class SharedStringNotFoundException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class SharedStringNotFoundException extends ReaderException
{
}

View File

@@ -0,0 +1,12 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class XMLProcessingException
*
* @package Box\Spout\Reader\Exception
*/
class XMLProcessingException extends ReaderException
{
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Box\Spout\Reader;
/**
* Interface IteratorInterface
*
* @package Box\Spout\Reader
*/
interface IteratorInterface extends \Iterator
{
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end();
}

View File

@@ -0,0 +1,231 @@
<?php
namespace Box\Spout\Reader\ODS\Helper;
/**
* Class CellValueFormatter
* This class provides helper functions to format cell values
*
* @package Box\Spout\Reader\ODS\Helper
*/
class CellValueFormatter
{
/** Definition of all possible cell types */
const CELL_TYPE_STRING = 'string';
const CELL_TYPE_FLOAT = 'float';
const CELL_TYPE_BOOLEAN = 'boolean';
const CELL_TYPE_DATE = 'date';
const CELL_TYPE_TIME = 'time';
const CELL_TYPE_CURRENCY = 'currency';
const CELL_TYPE_PERCENTAGE = 'percentage';
const CELL_TYPE_VOID = 'void';
/** Definition of XML nodes names used to parse data */
const XML_NODE_P = 'p';
const XML_NODE_S = 'text:s';
const XML_NODE_A = 'text:a';
const XML_NODE_SPAN = 'text:span';
/** 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';
const XML_ATTRIBUTE_DATE_VALUE = 'office:date-value';
const XML_ATTRIBUTE_TIME_VALUE = 'office:time-value';
const XML_ATTRIBUTE_CURRENCY = 'office:currency';
const XML_ATTRIBUTE_C = 'text:c';
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
protected $escaper;
/**
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/
public function __construct($shouldFormatDates)
{
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
* @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
*/
public function extractAndFormatNodeValue($node)
{
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE);
switch ($cellType) {
case self::CELL_TYPE_STRING:
return $this->formatStringCellValue($node);
case self::CELL_TYPE_FLOAT:
return $this->formatFloatCellValue($node);
case self::CELL_TYPE_BOOLEAN:
return $this->formatBooleanCellValue($node);
case self::CELL_TYPE_DATE:
return $this->formatDateCellValue($node);
case self::CELL_TYPE_TIME:
return $this->formatTimeCellValue($node);
case self::CELL_TYPE_CURRENCY:
return $this->formatCurrencyCellValue($node);
case self::CELL_TYPE_PERCENTAGE:
return $this->formatPercentageCellValue($node);
case self::CELL_TYPE_VOID:
default:
return '';
}
}
/**
* Returns the cell String value.
*
* @param \DOMNode $node
* @return string The value associated with the cell
*/
protected function formatStringCellValue($node)
{
$pNodeValues = [];
$pNodes = $node->getElementsByTagName(self::XML_NODE_P);
foreach ($pNodes as $pNode) {
$currentPValue = '';
foreach ($pNode->childNodes as $childNode) {
if ($childNode instanceof \DOMText) {
$currentPValue .= $childNode->nodeValue;
} else if ($childNode->nodeName === self::XML_NODE_S) {
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
$currentPValue .= str_repeat(' ', $numSpaces);
} else if ($childNode->nodeName === self::XML_NODE_A || $childNode->nodeName === self::XML_NODE_SPAN) {
$currentPValue .= $childNode->nodeValue;
}
}
$pNodeValues[] = $currentPValue;
}
$escapedCellValue = implode("\n", $pNodeValues);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell Numeric value from the given node.
*
* @param \DOMNode $node
* @return int|float The value associated with the cell
*/
protected function formatFloatCellValue($node)
{
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
$nodeIntValue = intval($nodeValue);
// The "==" is intentionally not a "===" because only the value matters, not the type
$cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
return $cellValue;
}
/**
* Returns the cell Boolean value from the given node.
*
* @param \DOMNode $node
* @return bool The value associated with the cell
*/
protected function formatBooleanCellValue($node)
{
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_BOOLEAN_VALUE);
// !! is similar to boolval()
$cellValue = !!$nodeValue;
return $cellValue;
}
/**
* Returns the cell Date value from the given node.
*
* @param \DOMNode $node
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatDateCellValue($node)
{
// The XML node looks like this:
// <table:table-cell calcext:value-type="date" office:date-value="2016-05-19T16:39:00" office:value-type="date">
// <text:p>05/19/16 04:39 PM</text:p>
// </table:table-cell>
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "date-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
return new \DateTime($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}
/**
* Returns the cell Time value from the given node.
*
* @param \DOMNode $node
* @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
*/
protected function formatTimeCellValue($node)
{
// The XML node looks like this:
// <table:table-cell calcext:value-type="time" office:time-value="PT13H24M00S" office:value-type="time">
// <text:p>01:24:00 PM</text:p>
// </table:table-cell>
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "time-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
return new \DateInterval($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}
/**
* Returns the cell Currency value from the given node.
*
* @param \DOMNode $node
* @return string The value associated with the cell (e.g. "100 USD" or "9.99 EUR")
*/
protected function formatCurrencyCellValue($node)
{
$value = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
$currency = $node->getAttribute(self::XML_ATTRIBUTE_CURRENCY);
return "$value $currency";
}
/**
* Returns the cell Percentage value from the given node.
*
* @param \DOMNode $node
* @return int|float The value associated with the cell
*/
protected function formatPercentageCellValue($node)
{
// percentages are formatted like floats
return $this->formatFloatCellValue($node);
}
}

View File

@@ -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;
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\AbstractReader;
/**
* Class Reader
* This class provides support to read data from a ODS file
*
* @package Box\Spout\Reader\ODS
*/
class Reader extends AbstractReader
{
/** @var \ZipArchive */
protected $zip;
/** @var SheetIterator To iterator over the ODS sheets */
protected $sheetIterator;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return false;
}
/**
* Opens the file at the given file path to make it ready to be read.
*
* @param string $filePath Path of the file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
protected function openReader($filePath)
{
$this->zip = new \ZipArchive();
if ($this->zip->open($filePath) === true) {
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions());
} else {
throw new IOException("Could not open $filePath for reading.");
}
}
/**
* Returns an iterator to iterate over sheets.
*
* @return SheetIterator To iterate over sheets
*/
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
/**
* Closes the reader. To be used after reading the file.
*
* @return void
*/
protected function closeReader()
{
if ($this->zip) {
$this->zip->close();
}
}
}

View File

@@ -0,0 +1,14 @@
<?php
namespace Box\Spout\Reader\ODS;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\ODS
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
// No extra options
}

View File

@@ -0,0 +1,352 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\IteratorNotRewindableException;
use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\Common\XMLProcessor;
/**
* Class RowIterator
*
* @package Box\Spout\Reader\ODS
*/
class RowIterator implements IteratorInterface
{
/** Definition of XML nodes names used to parse data */
const XML_NODE_TABLE = 'table:table';
const XML_NODE_ROW = 'table:table-row';
const XML_NODE_CELL = 'table:table-cell';
const MAX_COLUMNS_EXCEL = 16384;
/** Definition of XML attribute used to parse data */
const XML_ATTRIBUTE_NUM_ROWS_REPEATED = 'table:number-rows-repeated';
const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated';
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
protected $xmlProcessor;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var Helper\CellValueFormatter Helper to format cell values */
protected $cellValueFormatter;
/** @var bool Whether the iterator has already been rewound once */
protected $hasAlreadyBeenRewound = false;
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
protected $currentlyProcessedRowData = [];
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
protected $rowDataBuffer = null;
/** @var bool Indicates whether all rows have been read */
protected $hasReachedEndOfFile = false;
/** @var int Last row index processed (one-based) */
protected $lastRowIndexProcessed = 0;
/** @var int Row index to be processed next (one-based) */
protected $nextRowIndexToBeProcessed = 1;
/** @var mixed|null Value of the last processed cell (because when reading cell at column N+1, cell N is processed) */
protected $lastProcessedCellValue = null;
/** @var int Number of times the last processed row should be repeated */
protected $numRowsRepeated = 1;
/** @var int Number of times the last cell value should be copied to the cells on its right */
protected $numColumnsRepeated = 1;
/** @var bool Whether at least one cell has been read for the row currently being processed */
protected $hasAlreadyReadOneCellInCurrentRow = false;
/**
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
*/
public function __construct($xmlReader, $options)
{
$this->xmlReader = $xmlReader;
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
$this->cellValueFormatter = new CellValueFormatter($options->shouldFormatDates());
// Register all callbacks to process different nodes when reading the XML file
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_TABLE, XMLProcessor::NODE_TYPE_END, [$this, 'processTableEndingNode']);
}
/**
* Rewind the Iterator to the first element.
* NOTE: It can only be done once, as it is not possible to read an XML file backwards.
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
* @throws \Box\Spout\Reader\Exception\IteratorNotRewindableException If the iterator is rewound more than once
*/
public function rewind()
{
// Because sheet and row data is located in the file, we can't rewind both the
// sheet iterator and the row iterator, as XML file cannot be read backwards.
// Therefore, rewinding the row iterator has been disabled.
if ($this->hasAlreadyBeenRewound) {
throw new IteratorNotRewindableException();
}
$this->hasAlreadyBeenRewound = true;
$this->lastRowIndexProcessed = 0;
$this->nextRowIndexToBeProcessed = 1;
$this->rowDataBuffer = null;
$this->hasReachedEndOfFile = false;
$this->next();
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return (!$this->hasReachedEndOfFile);
}
/**
* Move forward to next element. Empty rows will be skipped.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
public function next()
{
if ($this->doesNeedDataForNextRowToBeProcessed()) {
$this->readDataForNextRow();
}
$this->lastRowIndexProcessed++;
}
/**
* Returns whether we need data for the next row to be processed.
* We DO need to read data if:
* - we have not read any rows yet
* OR
* - the next row to be processed immediately follows the last read row
*
* @return bool Whether we need data for the next row to be processed.
*/
protected function doesNeedDataForNextRowToBeProcessed()
{
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
return (
!$hasReadAtLeastOneRow ||
$this->lastRowIndexProcessed === $this->nextRowIndexToBeProcessed - 1
);
}
/**
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
protected function readDataForNextRow()
{
$this->currentlyProcessedRowData = [];
try {
$this->xmlProcessor->readUntilStopped();
} catch (XMLProcessingException $exception) {
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
}
$this->rowDataBuffer = $this->currentlyProcessedRowData;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowStartingNode($xmlReader)
{
// Reset data from current row
$this->hasAlreadyReadOneCellInCurrentRow = false;
$this->lastProcessedCellValue = null;
$this->numColumnsRepeated = 1;
$this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader);
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processCellStartingNode($xmlReader)
{
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
// NOTE: expand() will automatically decode all XML entities of the child nodes
$node = $xmlReader->expand();
$currentCellValue = $this->getCellValue($node);
// process cell N only after having read cell N+1 (see below why)
if ($this->hasAlreadyReadOneCellInCurrentRow) {
for ($i = 0; $i < $this->numColumnsRepeated; $i++) {
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
}
}
$this->hasAlreadyReadOneCellInCurrentRow = true;
$this->lastProcessedCellValue = $currentCellValue;
$this->numColumnsRepeated = $currentNumColumnsRepeated;
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowEndingNode()
{
$isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRowData, $this->lastProcessedCellValue);
// if the fetched row is empty and we don't want to preserve it...
if (!$this->shouldPreserveEmptyRows && $isEmptyRow) {
// ... skip it
return XMLProcessor::PROCESSING_CONTINUE;
}
// if the row is empty, we don't want to return more than one cell
$actualNumColumnsRepeated = (!$isEmptyRow) ? $this->numColumnsRepeated : 1;
// Only add the value if the last read cell is not a trailing empty cell repeater in Excel.
// The current count of read columns is determined by counting the values in "$this->currentlyProcessedRowData".
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with
// always 16384 cells.
if ((count($this->currentlyProcessedRowData) + $actualNumColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
for ($i = 0; $i < $actualNumColumnsRepeated; $i++) {
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
}
}
// If we are processing row N and the row is repeated M times,
// then the next row to be processed will be row (N+M).
$this->nextRowIndexToBeProcessed += $this->numRowsRepeated;
// at this point, we have all the data we need for the row
// so that we can populate the buffer
return XMLProcessor::PROCESSING_STOP;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processTableEndingNode()
{
// The closing "</table:table>" marks the end of the file
$this->hasReachedEndOfFile = true;
return XMLProcessor::PROCESSING_STOP;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
* @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing
*/
protected function getNumRowsRepeatedForCurrentNode($xmlReader)
{
$numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED);
return ($numRowsRepeated !== null) ? intval($numRowsRepeated) : 1;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
* @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
*/
protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
{
$numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
*/
protected function getCellValue($node)
{
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
}
/**
* After finishing processing each cell, a row is considered empty if it contains
* no cells or if the value of the last read cell is an empty string.
* After finishing processing each cell, the last read cell is not part of the
* row data yet (as we still need to apply the "num-columns-repeated" attribute).
*
* @param array $rowData
* @param string|int|float|bool|\DateTime|\DateInterval|null The value of the last read cell
* @return bool Whether the row is empty
*/
protected function isEmptyRow($rowData, $lastReadCellValue)
{
return (
count($rowData) === 0 &&
(!isset($lastReadCellValue) || trim($lastReadCellValue) === '')
);
}
/**
* Return the current element, from the buffer.
* @link http://php.net/manual/en/iterator.current.php
*
* @return array|null
*/
public function current()
{
return $this->rowDataBuffer;
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return $this->lastRowIndexProcessed;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
$this->xmlReader->close();
}
}

View File

@@ -0,0 +1,81 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Reader\SheetInterface;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class Sheet
* Represents a sheet within a ODS file
*
* @package Box\Spout\Reader\ODS
*/
class Sheet implements SheetInterface
{
/** @var \Box\Spout\Reader\ODS\RowIterator To iterate over sheet's rows */
protected $rowIterator;
/** @var int ID of the sheet */
protected $id;
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
protected $index;
/** @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 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, $isSheetActive, $options)
{
$this->rowIterator = new RowIterator($xmlReader, $options);
$this->index = $sheetIndex;
$this->name = $sheetName;
$this->isActive = $isSheetActive;
}
/**
* @api
* @return \Box\Spout\Reader\ODS\RowIterator
*/
public function getRowIterator()
{
return $this->rowIterator;
}
/**
* @api
* @return int Index of the sheet, based on order in the workbook (zero-based)
*/
public function getIndex()
{
return $this->index;
}
/**
* @api
* @return string Name of the sheet
*/
public function getName()
{
return $this->name;
}
/**
* @api
* @return bool Whether the sheet was defined as active
*/
public function isActive()
{
return $this->isActive;
}
}

View File

@@ -0,0 +1,168 @@
<?php
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;
/**
* Class SheetIterator
* Iterate over ODS sheet.
*
* @package Box\Spout\Reader\ODS
*/
class SheetIterator implements IteratorInterface
{
const CONTENT_XML_FILE_PATH = 'content.xml';
/** Definition of XML nodes name and attribute used to parse sheet data */
const XML_NODE_TABLE = 'table:table';
const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
/** @var string $filePath Path of the file to be read */
protected $filePath;
/** @var \Box\Spout\Reader\ODS\ReaderOptions Reader's current options */
protected $options;
/** @var XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
protected $escaper;
/** @var bool Whether there are still at least a sheet to be read */
protected $hasFoundSheet;
/** @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
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
public function __construct($filePath, $options)
{
$this->filePath = $filePath;
$this->options = $options;
$this->xmlReader = new XMLReader();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
$settingsHelper = new SettingsHelper();
$this->activeSheetName = $settingsHelper->getActiveSheetName($filePath);
}
/**
* Rewind the Iterator to the first element
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to open the XML file containing sheets' data
*/
public function rewind()
{
$this->xmlReader->close();
if ($this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH) === false) {
$contentXmlFilePath = $this->filePath . '#' . self::CONTENT_XML_FILE_PATH;
throw new IOException("Could not open \"{$contentXmlFilePath}\".");
}
try {
$this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
} catch (XMLProcessingException $exception) {
throw new IOException("The content.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
}
$this->currentSheetIndex = 0;
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return $this->hasFoundSheet;
}
/**
* Move forward to next element
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
*/
public function next()
{
$this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
if ($this->hasFoundSheet) {
$this->currentSheetIndex++;
}
}
/**
* Return the current element
* @link http://php.net/manual/en/iterator.current.php
*
* @return \Box\Spout\Reader\ODS\Sheet
*/
public function current()
{
$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, $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)
);
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return $this->currentSheetIndex + 1;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
$this->xmlReader->close();
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Box\Spout\Reader;
use Box\Spout\Common\Exception\UnsupportedTypeException;
use Box\Spout\Common\Helper\GlobalFunctionsHelper;
use Box\Spout\Common\Type;
/**
* Class ReaderFactory
* This factory is used to create readers, based on the type of the file to be read.
* It supports CSV and XLSX formats.
*
* @package Box\Spout\Reader
*/
class ReaderFactory
{
/**
* This creates an instance of the appropriate reader, given the type of the file to be read
*
* @api
* @param string $readerType Type of the reader to instantiate
* @return ReaderInterface
* @throws \Box\Spout\Common\Exception\UnsupportedTypeException
*/
public static function create($readerType)
{
$reader = null;
switch ($readerType) {
case Type::CSV:
$reader = new CSV\Reader();
break;
case Type::XLSX:
$reader = new XLSX\Reader();
break;
case Type::ODS:
$reader = new ODS\Reader();
break;
default:
throw new UnsupportedTypeException('No readers supporting the given type: ' . $readerType);
}
$reader->setGlobalFunctionsHelper(new GlobalFunctionsHelper());
return $reader;
}
}

View File

@@ -0,0 +1,36 @@
<?php
namespace Box\Spout\Reader;
/**
* Interface ReaderInterface
*
* @package Box\Spout\Reader
*/
interface ReaderInterface
{
/**
* Prepares the reader to read the given file. It also makes sure
* that the file exists and is readable.
*
* @param string $filePath Path of the file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException
*/
public function open($filePath);
/**
* Returns an iterator to iterate over sheets.
*
* @return \Iterator To iterate over sheets
* @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If called before opening the reader
*/
public function getSheetIterator();
/**
* Closes the reader, preventing any additional reading
*
* @return void
*/
public function close();
}

View File

@@ -0,0 +1,18 @@
<?php
namespace Box\Spout\Reader;
/**
* Interface SheetInterface
*
* @package Box\Spout\Reader
*/
interface SheetInterface
{
/**
* Returns an iterator to iterate over the sheet's rows.
*
* @return \Iterator
*/
public function getRowIterator();
}

View File

@@ -0,0 +1,82 @@
<?php
namespace Box\Spout\Reader\Wrapper;
use Box\Spout\Reader\Exception\XMLProcessingException;
/**
* Trait XMLInternalErrorsHelper
*
* @package Box\Spout\Reader\Wrapper
*/
trait XMLInternalErrorsHelper
{
/** @var bool Stores whether XML errors were initially stored internally - used to reset */
protected $initialUseInternalErrorsValue;
/**
* To avoid displaying lots of warning/error messages on screen,
* stores errors internally instead.
*
* @return void
*/
protected function useXMLInternalErrors()
{
libxml_clear_errors();
$this->initialUseInternalErrorsValue = libxml_use_internal_errors(true);
}
/**
* Throws an XMLProcessingException if an error occured.
* It also always resets the "libxml_use_internal_errors" setting back to its initial value.
*
* @return void
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
*/
protected function resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured()
{
if ($this->hasXMLErrorOccured()) {
$this->resetXMLInternalErrorsSetting();
throw new XMLProcessingException($this->getLastXMLErrorMessage());
}
$this->resetXMLInternalErrorsSetting();
}
/**
* Returns whether the a XML error has occured since the last time errors were cleared.
*
* @return bool TRUE if an error occured, FALSE otherwise
*/
private function hasXMLErrorOccured()
{
return (libxml_get_last_error() !== false);
}
/**
* Returns the error message for the last XML error that occured.
* @see libxml_get_last_error
*
* @return String|null Last XML error message or null if no error
*/
private function getLastXMLErrorMessage()
{
$errorMessage = null;
$error = libxml_get_last_error();
if ($error !== false) {
$errorMessage = trim($error->message);
}
return $errorMessage;
}
/**
* @return void
*/
protected function resetXMLInternalErrorsSetting()
{
libxml_use_internal_errors($this->initialUseInternalErrorsValue);
}
}

View File

@@ -0,0 +1,175 @@
<?php
namespace Box\Spout\Reader\Wrapper;
use DOMNode;
/**
* Class XMLReader
* Wrapper around the built-in XMLReader
* @see \XMLReader
*
* @package Box\Spout\Reader\Wrapper
*/
class XMLReader extends \XMLReader
{
use XMLInternalErrorsHelper;
const ZIP_WRAPPER = 'zip://';
/**
* Opens the XML Reader to read a file located inside a ZIP file.
*
* @param string $zipFilePath Path to the ZIP file
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
* @return bool TRUE on success or FALSE on failure
*/
public function openFileInZip($zipFilePath, $fileInsideZipPath)
{
$wasOpenSuccessful = false;
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
// 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);
}
return $wasOpenSuccessful;
}
/**
* Returns the real path for the given path components.
* This is useful to avoid issues on some Windows setup.
*
* @param string $zipFilePath Path to the ZIP file
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
* @return string The real path URI
*/
public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
{
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
}
/**
* Returns whether the file at the given location exists
*
* @param string $zipStreamURI URI of a zip stream, e.g. "zip://file.zip#path/inside.xml"
* @return bool TRUE if the file exists, FALSE otherwise
*/
protected function fileExistsWithinZip($zipStreamURI)
{
$doesFileExists = false;
$pattern = '/zip:\/\/([^#]+)#(.*)/';
if (preg_match($pattern, $zipStreamURI, $matches)) {
$zipFilePath = $matches[1];
$innerFilePath = $matches[2];
$zip = new \ZipArchive();
if ($zip->open($zipFilePath) === true) {
$doesFileExists = ($zip->locateName($innerFilePath) !== false);
$zip->close();
}
}
return $doesFileExists;
}
/**
* Move to next node in document
* @see \XMLReader::read
*
* @return bool TRUE on success or FALSE on failure
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
*/
public function read()
{
$this->useXMLInternalErrors();
$wasReadSuccessful = parent::read();
$this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
return $wasReadSuccessful;
}
/**
* Read until the element with the given name is found, or the end of the file.
*
* @param string $nodeName Name of the node to find
* @return bool TRUE on success or FALSE on failure
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
*/
public function readUntilNodeFound($nodeName)
{
do {
$wasReadSuccessful = $this->read();
$isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
} while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
return $wasReadSuccessful;
}
/**
* Move cursor to next node skipping all subtrees
* @see \XMLReader::next
*
* @param string|void $localName The name of the next node to move to
* @return bool TRUE on success or FALSE on failure
* @throws \Box\Spout\Reader\Exception\XMLProcessingException If an error/warning occurred
*/
public function next($localName = null)
{
$this->useXMLInternalErrors();
$wasNextSuccessful = parent::next($localName);
$this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
return $wasNextSuccessful;
}
/**
* @param string $nodeName
* @return bool Whether the XML Reader is currently positioned on the starting node with given name
*/
public function isPositionedOnStartingNode($nodeName)
{
return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
}
/**
* @param string $nodeName
* @return bool Whether the XML Reader is currently positioned on the ending node with given name
*/
public function isPositionedOnEndingNode($nodeName)
{
return $this->isPositionedOnNode($nodeName, XMLReader::END_ELEMENT);
}
/**
* @param string $nodeName
* @param int $nodeType
* @return bool Whether the XML Reader is currently positioned on the node with given name and type
*/
private function isPositionedOnNode($nodeName, $nodeType)
{
// In some cases, the node has a prefix (for instance, "<sheet>" can also be "<x:sheet>").
// So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
// @see https://github.com/box/spout/issues/233
$hasPrefix = (strpos($nodeName, ':') !== false);
$currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
}
/**
* @return string The name of the current node, un-prefixed
*/
public function getCurrentNodeName()
{
return $this->localName;
}
}

View File

@@ -0,0 +1,106 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Common\Exception\InvalidArgumentException;
/**
* Class CellHelper
* This class provides helper functions when working with cells
*
* @package Box\Spout\Reader\XLSX\Helper
*/
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;
* Calling fillMissingArrayIndexes($dataArray, 'FILL') will return this array: ['FILL', 1, 'FILL', 3]
*
* @param array $dataArray The array to fill
* @param string|void $fillValue optional
* @return array
*/
public static function fillMissingArrayIndexes($dataArray, $fillValue = '')
{
if (empty($dataArray)) {
return [];
}
$existingIndexes = array_keys($dataArray);
$newIndexes = array_fill_keys(range(0, max($existingIndexes)), $fillValue);
$dataArray += $newIndexes;
ksort($dataArray);
return $dataArray;
}
/**
* Returns the base 10 column index associated to the cell index (base 26).
* Excel uses A to Z letters for column indexing, where A is the 1st column,
* Z is the 26th and AA is the 27th.
* The mapping is zero based, so that A1 maps to 0, B2 maps to 1, Z13 to 25 and AA4 to 26.
*
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
* @return int
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
public static function getColumnIndexFromCellIndex($cellIndex)
{
if (!self::isValidCellIndex($cellIndex)) {
throw new InvalidArgumentException('Cannot get column index from an invalid cell index.');
}
$columnIndex = 0;
// Remove row information
$columnLetters = preg_replace('/\d/', '', $cellIndex);
// 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;
// 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;
}
/**
* 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]{1,3}\d+$/', $cellIndex) === 1);
}
}

View File

@@ -0,0 +1,300 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
/**
* Class CellValueFormatter
* This class provides helper functions to format cell values
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class CellValueFormatter
{
/** Definition of all possible cell types */
const CELL_TYPE_INLINE_STRING = 'inlineStr';
const CELL_TYPE_STR = 'str';
const CELL_TYPE_SHARED_STRING = 's';
const CELL_TYPE_BOOLEAN = 'b';
const CELL_TYPE_NUMERIC = 'n';
const CELL_TYPE_DATE = 'd';
const CELL_TYPE_ERROR = 'e';
/** Definition of XML nodes names used to parse data */
const XML_NODE_VALUE = 'v';
const XML_NODE_INLINE_STRING_VALUE = 't';
/** Definition of XML attributes used to parse data */
const XML_ATTRIBUTE_TYPE = 't';
const XML_ATTRIBUTE_STYLE_ID = 's';
/** Constants used for date formatting */
const NUM_SECONDS_IN_ONE_DAY = 86400;
const NUM_SECONDS_IN_ONE_HOUR = 3600;
const NUM_SECONDS_IN_ONE_MINUTE = 60;
/**
* February 29th, 1900 is NOT a leap year but Excel thinks it is...
* @see https://en.wikipedia.org/wiki/Year_1900_problem#Microsoft_Excel
*/
const ERRONEOUS_EXCEL_LEAP_YEAR_DAY = 60;
/** @var SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var StyleHelper Helper to work with styles */
protected $styleHelper;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
protected $escaper;
/**
* @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
* @param StyleHelper $styleHelper Helper to work with styles
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/
public function __construct($sharedStringsHelper, $styleHelper, $shouldFormatDates)
{
$this->sharedStringsHelper = $sharedStringsHelper;
$this->styleHelper = $styleHelper;
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|null The value associated with the cell (null when the cell has an error)
*/
public function extractAndFormatNodeValue($node)
{
// Default cell type is "n"
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
$cellStyleId = intval($node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID));
$vNodeValue = $this->getVNodeValue($node);
if (($vNodeValue === '') && ($cellType !== self::CELL_TYPE_INLINE_STRING)) {
return $vNodeValue;
}
switch ($cellType) {
case self::CELL_TYPE_INLINE_STRING:
return $this->formatInlineStringCellValue($node);
case self::CELL_TYPE_SHARED_STRING:
return $this->formatSharedStringCellValue($vNodeValue);
case self::CELL_TYPE_STR:
return $this->formatStrCellValue($vNodeValue);
case self::CELL_TYPE_BOOLEAN:
return $this->formatBooleanCellValue($vNodeValue);
case self::CELL_TYPE_NUMERIC:
return $this->formatNumericCellValue($vNodeValue, $cellStyleId);
case self::CELL_TYPE_DATE:
return $this->formatDateCellValue($vNodeValue);
default:
return null;
}
}
/**
* Returns the cell's string value from a node's nested value node
*
* @param \DOMNode $node
* @return string The value associated with the cell
*/
protected function getVNodeValue($node)
{
// for cell types having a "v" tag containing the value.
// if not, the returned value should be empty string.
$vNode = $node->getElementsByTagName(self::XML_NODE_VALUE)->item(0);
return ($vNode !== null) ? $vNode->nodeValue : '';
}
/**
* Returns the cell String value where string is inline.
*
* @param \DOMNode $node
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatInlineStringCellValue($node)
{
// inline strings are formatted this way:
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
$cellValue = $this->escaper->unescape($tNode->nodeValue);
return $cellValue;
}
/**
* Returns the cell String value from shared-strings file using nodeValue index.
*
* @param string $nodeValue
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatSharedStringCellValue($nodeValue)
{
// shared strings are formatted this way:
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
$sharedStringIndex = intval($nodeValue);
$escapedCellValue = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell String value, where string is stored in value node.
*
* @param string $nodeValue
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatStrCellValue($nodeValue)
{
$escapedCellValue = trim($nodeValue);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell Numeric value from string of nodeValue.
* The value can also represent a timestamp and a DateTime will be returned.
*
* @param string $nodeValue
* @param int $cellStyleId 0 being the default style
* @return int|float|\DateTime|null The value associated with the cell
*/
protected function formatNumericCellValue($nodeValue, $cellStyleId)
{
// Numeric values can represent numbers as well as timestamps.
// We need to look at the style of the cell to determine whether it is one or the other.
$shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId);
if ($shouldFormatAsDate) {
return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId);
} else {
$nodeIntValue = intval($nodeValue);
return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
}
}
/**
* Returns a cell's PHP Date value, associated to the given timestamp.
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
* NOTE: The timestamp can also represent a time, if it is a value between 0 and 1.
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
*/
protected function formatExcelTimestampValue($nodeValue, $cellStyleId)
{
// Fix for the erroneous leap year in Excel
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
--$nodeValue;
}
if ($nodeValue >= 1) {
// Values greater than 1 represent "dates". The value 1.0 representing the "base" date: 1900-01-01.
return $this->formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId);
} else if ($nodeValue >= 0) {
// Values between 0 and 1 represent "times".
return $this->formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId);
} else {
// invalid date
return null;
}
}
/**
* Returns a cell's PHP DateTime value, associated to the given timestamp.
* Only the time value matters. The date part is set to Jan 1st, 1900 (base Excel date).
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|string The value associated with the cell
*/
protected function formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId)
{
$time = round($nodeValue * self::NUM_SECONDS_IN_ONE_DAY);
$hours = floor($time / self::NUM_SECONDS_IN_ONE_HOUR);
$minutes = floor($time / self::NUM_SECONDS_IN_ONE_MINUTE) - ($hours * self::NUM_SECONDS_IN_ONE_MINUTE);
$seconds = $time - ($hours * self::NUM_SECONDS_IN_ONE_HOUR) - ($minutes * self::NUM_SECONDS_IN_ONE_MINUTE);
// using the base Excel date (Jan 1st, 1900) - not relevant here
$dateObj = new \DateTime('1900-01-01');
$dateObj->setTime($hours, $minutes, $seconds);
if ($this->shouldFormatDates) {
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
return $dateObj->format($phpDateFormat);
} else {
return $dateObj;
}
}
/**
* Returns a cell's PHP Date value, associated to the given timestamp.
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId)
{
// Do not use any unix timestamps for calculation to prevent
// issues with numbers exceeding 2^31.
$secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY;
$secondsRemainder = round($secondsRemainder, 0);
try {
$dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
$dateObj->modify('+' . intval($nodeValue) . 'days');
$dateObj->modify('+' . $secondsRemainder . 'seconds');
if ($this->shouldFormatDates) {
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
return $dateObj->format($phpDateFormat);
} else {
return $dateObj;
}
} catch (\Exception $e) {
return null;
}
}
/**
* Returns the cell Boolean value from a specific node's Value.
*
* @param string $nodeValue
* @return bool The value associated with the cell
*/
protected function formatBooleanCellValue($nodeValue)
{
// !! is similar to boolval()
$cellValue = !!$nodeValue;
return $cellValue;
}
/**
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
* @see ECMA-376 Part 1 - §18.17.4
*
* @param string $nodeValue ISO 8601 Date string
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatDateCellValue($nodeValue)
{
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
try {
return ($this->shouldFormatDates) ? $nodeValue : new \DateTime($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}

View File

@@ -0,0 +1,124 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
/**
* Class DateFormatHelper
* This class provides helper functions to format Excel dates
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class DateFormatHelper
{
const KEY_GENERAL = 'general';
const KEY_HOUR_12 = '12h';
const KEY_HOUR_24 = '24h';
/**
* This map is used to replace Excel format characters by their PHP equivalent.
* Keys should be ordered from longest to smallest.
*
* @var array Mapping between Excel format characters and PHP format characters
*/
private static $excelDateFormatToPHPDateFormatMapping = [
self::KEY_GENERAL => [
// Time
'am/pm' => 'A', // Uppercase Ante meridiem and Post meridiem
':mm' => ':i', // Minutes with leading zeros - if preceded by a ":" (otherwise month)
'mm:' => 'i:', // Minutes with leading zeros - if followed by a ":" (otherwise month)
'ss' => 's', // Seconds, with leading zeros
'.s' => '', // Ignore (fractional seconds format does not exist in PHP)
// Date
'e' => 'Y', // Full numeric representation of a year, 4 digits
'yyyy' => 'Y', // Full numeric representation of a year, 4 digits
'yy' => 'y', // Two digit representation of a year
'mmmmm' => 'M', // Short textual representation of a month, three letters ("mmmmm" should only contain the 1st letter...)
'mmmm' => 'F', // Full textual representation of a month
'mmm' => 'M', // Short textual representation of a month, three letters
'mm' => 'm', // Numeric representation of a month, with leading zeros
'm' => 'n', // Numeric representation of a month, without leading zeros
'dddd' => 'l', // Full textual representation of the day of the week
'ddd' => 'D', // Textual representation of a day, three letters
'dd' => 'd', // Day of the month, 2 digits with leading zeros
'd' => 'j', // Day of the month without leading zeros
],
self::KEY_HOUR_12 => [
'hh' => 'h', // 12-hour format of an hour without leading zeros
'h' => 'g', // 12-hour format of an hour without leading zeros
],
self::KEY_HOUR_24 => [
'hh' => 'H', // 24-hour hours with leading zero
'h' => 'G', // 24-hour format of an hour without leading zeros
],
];
/**
* Converts the given Excel date format to a format understandable by the PHP date function.
*
* @param string $excelDateFormat Excel date format
* @return string PHP date format (as defined here: http://php.net/manual/en/function.date.php)
*/
public static function toPHPDateFormat($excelDateFormat)
{
// Remove brackets potentially present at the beginning of the format string
// and text portion of the format at the end of it (starting with ";")
// See §18.8.31 of ECMA-376 for more detail.
$dateFormat = preg_replace('/^(?:\[\$[^\]]+?\])?([^;]*).*/', '$1', $excelDateFormat);
// Double quotes are used to escape characters that must not be interpreted.
// For instance, ["Day " dd] should result in "Day 13" and we should not try to interpret "D", "a", "y"
// By exploding the format string using double quote as a delimiter, we can get all parts
// that must be transformed (even indexes) and all parts that must not be (odd indexes).
$dateFormatParts = explode('"', $dateFormat);
foreach ($dateFormatParts as $partIndex => $dateFormatPart) {
// do not look at odd indexes
if ($partIndex % 2 === 1) {
continue;
}
// Make sure all characters are lowercase, as the mapping table is using lowercase characters
$transformedPart = strtolower($dateFormatPart);
// Remove escapes related to non-format characters
$transformedPart = str_replace('\\', '', $transformedPart);
// Apply general transformation first...
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_GENERAL]);
// ... then apply hour transformation, for 12-hour or 24-hour format
if (self::has12HourFormatMarker($dateFormatPart)) {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_12]);
} else {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_24]);
}
// overwrite the parts array with the new transformed part
$dateFormatParts[$partIndex] = $transformedPart;
}
// Merge all transformed parts back together
$phpDateFormat = implode('"', $dateFormatParts);
// Finally, to have the date format compatible with the DateTime::format() function, we need to escape
// all characters that are inside double quotes (and double quotes must be removed).
// For instance, ["Day " dd] should become [\D\a\y\ dd]
$phpDateFormat = preg_replace_callback('/"(.+?)"/', function($matches) {
$stringToEscape = $matches[1];
$letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY);
return '\\' . implode('\\', $letters);
}, $phpDateFormat);
return $phpDateFormat;
}
/**
* @param string $excelDateFormat Date format as defined by Excel
* @return bool Whether the given date format has the 12-hour format marker
*/
private static function has12HourFormatMarker($excelDateFormat)
{
return (stripos($excelDateFormat, 'am/pm') !== false);
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
/**
* Class CachingStrategyFactory
*
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
*/
class CachingStrategyFactory
{
/**
* The memory amount needed to store a string was obtained empirically from this data:
*
* ------------------------------------
* | Number of chars⁺ | Memory needed |
* ------------------------------------
* | 3,000 | 1 MB |
* | 15,000 | 2 MB |
* | 30,000 | 5 MB |
* | 75,000 | 11 MB |
* | 150,000 | 21 MB |
* | 300,000 | 43 MB |
* | 750,000 | 105 MB |
* | 1,500,000 | 210 MB |
* | 2,250,000 | 315 MB |
* | 3,000,000 | 420 MB |
* | 4,500,000 | 630 MB |
* ------------------------------------
*
* ⁺ All characters were 1 byte long
*
* This gives a linear graph where each 1-byte character requires about 150 bytes to be stored.
* Given that some characters can take up to 4 bytes, we need 600 bytes per character to be safe.
* Also, there is on average about 20 characters per cell (this is entirely empirical data...).
*
* This means that in order to store one shared string in memory, the memory amount needed is:
* => 20 * 600 ≈ 12KB
*/
const AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB = 12;
/**
* To avoid running out of memory when extracting a huge number of shared strings, they can be saved to temporary files
* instead of in memory. Then, when accessing a string, the corresponding file contents will be loaded in memory
* and the string will be quickly retrieved.
* The performance bottleneck is not when creating these temporary files, but rather when loading their content.
* Because the contents of the last loaded file stays in memory until another file needs to be loaded, it works
* best when the indexes of the shared strings are sorted in the sheet data.
* 10,000 was chosen because it creates small files that are fast to be loaded in memory.
*/
const MAX_NUM_STRINGS_PER_TEMP_FILE = 10000;
/** @var CachingStrategyFactory|null Singleton instance */
protected static $instance = null;
/**
* Private constructor for singleton
*/
private function __construct()
{
}
/**
* Returns the singleton instance of the factory
*
* @return CachingStrategyFactory
*/
public static function getInstance()
{
if (self::$instance === null) {
self::$instance = new CachingStrategyFactory();
}
return self::$instance;
}
/**
* Returns the best caching strategy, given the number of unique shared strings
* and the amount of memory available.
*
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
* @return CachingStrategyInterface The best caching strategy
*/
public function getBestCachingStrategy($sharedStringsUniqueCount, $tempFolder = null)
{
if ($this->isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)) {
return new InMemoryStrategy($sharedStringsUniqueCount);
} else {
return new FileBasedStrategy($tempFolder, self::MAX_NUM_STRINGS_PER_TEMP_FILE);
}
}
/**
* Returns whether it is safe to use in-memory caching, given the number of unique shared strings
* and the amount of memory available.
*
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @return bool
*/
protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)
{
// if the number of shared strings in unknown, do not use "in memory" strategy
if ($sharedStringsUniqueCount === null) {
return false;
}
$memoryAvailable = $this->getMemoryLimitInKB();
if ($memoryAvailable === -1) {
// if cannot get memory limit or if memory limit set as unlimited, don't trust and play safe
return ($sharedStringsUniqueCount < self::MAX_NUM_STRINGS_PER_TEMP_FILE);
} else {
$memoryNeeded = $sharedStringsUniqueCount * self::AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB;
return ($memoryAvailable > $memoryNeeded);
}
}
/**
* Returns the PHP "memory_limit" in Kilobytes
*
* @return float
*/
protected function getMemoryLimitInKB()
{
$memoryLimitFormatted = $this->getMemoryLimitFromIni();
$memoryLimitFormatted = strtolower(trim($memoryLimitFormatted));
// No memory limit
if ($memoryLimitFormatted === '-1') {
return -1;
}
if (preg_match('/(\d+)([bkmgt])b?/', $memoryLimitFormatted, $matches)) {
$amount = intval($matches[1]);
$unit = $matches[2];
switch ($unit) {
case 'b': return ($amount / 1024);
case 'k': return $amount;
case 'm': return ($amount * 1024);
case 'g': return ($amount * 1024 * 1024);
case 't': return ($amount * 1024 * 1024 * 1024);
}
}
return -1;
}
/**
* Returns the formatted "memory_limit" value
*
* @return string
*/
protected function getMemoryLimitFromIni()
{
return ini_get('memory_limit');
}
}

View File

@@ -0,0 +1,44 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
/**
* Interface CachingStrategyInterface
*
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
*/
interface CachingStrategyInterface
{
/**
* Adds the given string to the cache.
*
* @param string $sharedString The string to be added to the cache
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return void
*/
public function addStringForIndex($sharedString, $sharedStringIndex);
/**
* Closes the cache after the last shared string was added.
* This prevents any additional string from being added to the cache.
*
* @return void
*/
public function closeCache();
/**
* Returns the string located at the given index from the cache.
*
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return string The shared string at the given index
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
*/
public function getStringAtIndex($sharedStringIndex);
/**
* Destroys the cache, freeing memory and removing any created artifacts
*
* @return void
*/
public function clearCache();
}

View File

@@ -0,0 +1,193 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
use Box\Spout\Common\Helper\FileSystemHelper;
use Box\Spout\Common\Helper\GlobalFunctionsHelper;
use Box\Spout\Reader\Exception\SharedStringNotFoundException;
/**
* Class FileBasedStrategy
*
* This class implements the file-based caching strategy for shared strings.
* Shared strings are stored in small files (with a max number of strings per file).
* This strategy is slower than an in-memory strategy but is used to avoid out of memory crashes.
*
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
*/
class FileBasedStrategy implements CachingStrategyInterface
{
/** Value to use to escape the line feed character ("\n") */
const ESCAPED_LINE_FEED_CHARACTER = '_x000A_';
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var \Box\Spout\Common\Helper\FileSystemHelper Helper to perform file system operations */
protected $fileSystemHelper;
/** @var string Temporary folder where the temporary files will be created */
protected $tempFolder;
/**
* @var int Maximum number of strings that can be stored in one temp file
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
*/
protected $maxNumStringsPerTempFile;
/** @var resource Pointer to the last temp file a shared string was written to */
protected $tempFilePointer;
/**
* @var string Path of the temporary file whose contents is currently stored in memory
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
*/
protected $inMemoryTempFilePath;
/**
* @var array Contents of the temporary file that was last read
* @see CachingStrategyFactory::MAX_NUM_STRINGS_PER_TEMP_FILE
*/
protected $inMemoryTempFileContents;
/**
* @param string|null $tempFolder Temporary folder where the temporary files to store shared strings will be stored
* @param int $maxNumStringsPerTempFile Maximum number of strings that can be stored in one temp file
*/
public function __construct($tempFolder, $maxNumStringsPerTempFile)
{
$rootTempFolder = ($tempFolder) ?: sys_get_temp_dir();
$this->fileSystemHelper = new FileSystemHelper($rootTempFolder);
$this->tempFolder = $this->fileSystemHelper->createFolder($rootTempFolder, uniqid('sharedstrings'));
$this->maxNumStringsPerTempFile = $maxNumStringsPerTempFile;
$this->globalFunctionsHelper = new GlobalFunctionsHelper();
$this->tempFilePointer = null;
}
/**
* Adds the given string to the cache.
*
* @param string $sharedString The string to be added to the cache
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return void
*/
public function addStringForIndex($sharedString, $sharedStringIndex)
{
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
if ($this->tempFilePointer) {
$this->globalFunctionsHelper->fclose($this->tempFilePointer);
}
$this->tempFilePointer = $this->globalFunctionsHelper->fopen($tempFilePath, 'w');
}
// The shared string retrieval logic expects each cell data to be on one line only
// Encoding the line feed character allows to preserve this assumption
$lineFeedEncodedSharedString = $this->escapeLineFeed($sharedString);
$this->globalFunctionsHelper->fwrite($this->tempFilePointer, $lineFeedEncodedSharedString . PHP_EOL);
}
/**
* Returns the path for the temp file that should contain the string for the given index
*
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return string The temp file path for the given index
*/
protected function getSharedStringTempFilePath($sharedStringIndex)
{
$numTempFile = intval($sharedStringIndex / $this->maxNumStringsPerTempFile);
return $this->tempFolder . '/sharedstrings' . $numTempFile;
}
/**
* Closes the cache after the last shared string was added.
* This prevents any additional string from being added to the cache.
*
* @return void
*/
public function closeCache()
{
// close pointer to the last temp file that was written
if ($this->tempFilePointer) {
$this->globalFunctionsHelper->fclose($this->tempFilePointer);
}
}
/**
* Returns the string located at the given index from the cache.
*
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return string The shared string at the given index
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
*/
public function getStringAtIndex($sharedStringIndex)
{
$tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
$indexInFile = $sharedStringIndex % $this->maxNumStringsPerTempFile;
if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
throw new SharedStringNotFoundException("Shared string temp file not found: $tempFilePath ; for index: $sharedStringIndex");
}
if ($this->inMemoryTempFilePath !== $tempFilePath) {
// free memory
unset($this->inMemoryTempFileContents);
$this->inMemoryTempFileContents = explode(PHP_EOL, $this->globalFunctionsHelper->file_get_contents($tempFilePath));
$this->inMemoryTempFilePath = $tempFilePath;
}
$sharedString = null;
// 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);
}
if ($sharedString === null) {
throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex");
}
return rtrim($sharedString, PHP_EOL);
}
/**
* Escapes the line feed characters (\n)
*
* @param string $unescapedString
* @return string
*/
private function escapeLineFeed($unescapedString)
{
return str_replace("\n", self::ESCAPED_LINE_FEED_CHARACTER, $unescapedString);
}
/**
* Unescapes the line feed characters (\n)
*
* @param string $escapedString
* @return string
*/
private function unescapeLineFeed($escapedString)
{
return str_replace(self::ESCAPED_LINE_FEED_CHARACTER, "\n", $escapedString);
}
/**
* Destroys the cache, freeing memory and removing any created artifacts
*
* @return void
*/
public function clearCache()
{
if ($this->tempFolder) {
$this->fileSystemHelper->deleteFolderRecursively($this->tempFolder);
}
}
}

View File

@@ -0,0 +1,83 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper\SharedStringsCaching;
use Box\Spout\Reader\Exception\SharedStringNotFoundException;
/**
* Class InMemoryStrategy
*
* This class implements the in-memory caching strategy for shared strings.
* This strategy is used when the number of unique strings is low, compared to the memory available.
*
* @package Box\Spout\Reader\XLSX\Helper\SharedStringsCaching
*/
class InMemoryStrategy implements CachingStrategyInterface
{
/** @var \SplFixedArray Array used to cache the shared strings */
protected $inMemoryCache;
/** @var bool Whether the cache has been closed */
protected $isCacheClosed;
/**
* @param int $sharedStringsUniqueCount Number of unique shared strings
*/
public function __construct($sharedStringsUniqueCount)
{
$this->inMemoryCache = new \SplFixedArray($sharedStringsUniqueCount);
$this->isCacheClosed = false;
}
/**
* Adds the given string to the cache.
*
* @param string $sharedString The string to be added to the cache
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return void
*/
public function addStringForIndex($sharedString, $sharedStringIndex)
{
if (!$this->isCacheClosed) {
$this->inMemoryCache->offsetSet($sharedStringIndex, $sharedString);
}
}
/**
* Closes the cache after the last shared string was added.
* This prevents any additional string from being added to the cache.
*
* @return void
*/
public function closeCache()
{
$this->isCacheClosed = true;
}
/**
* Returns the string located at the given index from the cache.
*
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return string The shared string at the given index
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
*/
public function getStringAtIndex($sharedStringIndex)
{
try {
return $this->inMemoryCache->offsetGet($sharedStringIndex);
} catch (\RuntimeException $e) {
throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex");
}
}
/**
* Destroys the cache, freeing memory and removing any created artifacts
*
* @return void
*/
public function clearCache()
{
unset($this->inMemoryCache);
$this->isCacheClosed = false;
}
}

View File

@@ -0,0 +1,234 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyFactory;
use Box\Spout\Reader\XLSX\Helper\SharedStringsCaching\CachingStrategyInterface;
/**
* Class SharedStringsHelper
* This class provides helper functions for reading sharedStrings XML file
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class SharedStringsHelper
{
/** Path of sharedStrings XML file inside the XLSX file */
const SHARED_STRINGS_XML_FILE_PATH = 'xl/sharedStrings.xml';
/** 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;
/** @var string Temporary folder where the temporary files to store shared strings will be stored */
protected $tempFolder;
/** @var CachingStrategyInterface The best caching strategy for storing shared strings */
protected $cachingStrategy;
/**
* @param string $filePath Path of the XLSX file being read
* @param string|null|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
*/
public function __construct($filePath, $tempFolder = null)
{
$this->filePath = $filePath;
$this->tempFolder = $tempFolder;
}
/**
* 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 sheet.
* All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'.
* It is then accessed by the sheet data, via the string index in the built table.
*
* More documentation available here: http://msdn.microsoft.com/en-us/library/office/gg278314.aspx
*
* 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.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml can't be read
*/
public function extractSharedStrings()
{
$xmlReader = new XMLReader();
$sharedStringIndex = 0;
if ($xmlReader->openFileInZip($this->filePath, self::SHARED_STRINGS_XML_FILE_PATH) === false) {
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
}
try {
$sharedStringsUniqueCount = $this->getSharedStringsUniqueCount($xmlReader);
$this->cachingStrategy = $this->getBestSharedStringsCachingStrategy($sharedStringsUniqueCount);
$xmlReader->readUntilNodeFound(self::XML_NODE_SI);
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SI) {
$this->processSharedStringsItem($xmlReader, $sharedStringIndex);
$sharedStringIndex++;
// jump to the next '<si>' tag
$xmlReader->next(self::XML_NODE_SI);
}
$this->cachingStrategy->closeCache();
} catch (XMLProcessingException $exception) {
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
}
$xmlReader->close();
}
/**
* Returns the shared strings unique count, as specified in <sst> tag.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
* @return int|null Number of unique shared strings in the sharedStrings.xml file
* @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
*/
protected function getSharedStringsUniqueCount($xmlReader)
{
$xmlReader->next(self::XML_NODE_SST);
// Iterate over the "sst" elements to get the actual "sst ELEMENT" (skips any DOCTYPE)
while ($xmlReader->getCurrentNodeName() === self::XML_NODE_SST && $xmlReader->nodeType !== XMLReader::ELEMENT) {
$xmlReader->read();
}
$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(self::XML_ATTRIBUTE_COUNT);
}
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
}
/**
* Returns the best shared strings caching strategy.
*
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @return CachingStrategyInterface
*/
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
{
return CachingStrategyFactory::getInstance()
->getBestCachingStrategy($sharedStringsUniqueCount, $this->tempFolder);
}
/**
* Processes the shared strings item XML node which the given XML reader is positioned on.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on a "<si>" node
* @param int $sharedStringIndex Index of the processed shared strings item
* @return void
*/
protected function processSharedStringsItem($xmlReader, $sharedStringIndex)
{
$sharedStringValue = '';
// NOTE: expand() will automatically decode all XML entities of the child nodes
$siNode = $xmlReader->expand();
$textNodes = $siNode->getElementsByTagName(self::XML_NODE_T);
foreach ($textNodes as $textNode) {
if ($this->shouldExtractTextNodeValue($textNode)) {
$textNodeValue = $textNode->nodeValue;
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
$sharedStringValue .= ($shouldPreserveWhitespace) ? $textNodeValue : trim($textNodeValue);
}
}
$this->cachingStrategy->addStringForIndex($sharedStringValue, $sharedStringIndex);
}
/**
* 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 \DOMElement $textNode Text node to check
* @return bool Whether the given text node's value must be extracted
*/
protected function shouldExtractTextNodeValue($textNode)
{
$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 \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(self::XML_ATTRIBUTE_XML_SPACE);
return ($spaceValue === self::XML_ATTRIBUTE_VALUE_PRESERVE);
}
/**
* Returns the shared string at the given index, using the previously chosen caching strategy.
*
* @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
* @return string The shared string at the given index
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
*/
public function getStringAtIndex($sharedStringIndex)
{
return $this->cachingStrategy->getStringAtIndex($sharedStringIndex);
}
/**
* Destroys the cache, freeing memory and removing any created artifacts
*
* @return void
*/
public function cleanup()
{
if ($this->cachingStrategy) {
$this->cachingStrategy->clearCache();
}
}
}

View File

@@ -0,0 +1,156 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Sheet;
/**
* Class SheetHelper
* This class provides helper functions related to XLSX sheets
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class SheetHelper
{
/** Paths of XML files relative to the XLSX file root */
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;
/** @var \Box\Spout\Reader\XLSX\ReaderOptions Reader's current options */
protected $options;
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/**
* @param string $filePath Path of the XLSX file being read
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
{
$this->filePath = $filePath;
$this->options = $options;
$this->sharedStringsHelper = $sharedStringsHelper;
$this->globalFunctionsHelper = $globalFunctionsHelper;
}
/**
* Returns the sheets metadata of the file located at the previously given file path.
* The paths to the sheets' data are read from the [Content_Types].xml file.
*
* @return Sheet[] Sheets within the XLSX file
*/
public function getSheets()
{
$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(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(self::XML_NODE_SHEETS)) {
// stop reading once all sheets have been read
break;
}
}
$xmlReader->close();
}
return $sheets;
}
/**
* Returns an instance of a sheet, given the XML node describing the sheet - from "workbook.xml".
* We can find the XML file path describing the sheet inside "workbook.xml.res", by mapping with the sheet ID
* ("r:id" in "workbook.xml", "Id" in "workbook.xml.res").
*
* @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, $isSheetActive)
{
$sheetId = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_R_ID);
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME);
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$sheetName = $escaper->unescape($escapedSheetName);
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
return new Sheet(
$this->filePath, $sheetDataXMLFilePath,
$sheetIndexZeroBased, $sheetName, $isSheetActive,
$this->options, $this->sharedStringsHelper
);
}
/**
* @param string $sheetId The sheet ID, as defined in "workbook.xml"
* @return string The XML file path describing the sheet inside "workbook.xml.res", for the given sheet ID
*/
protected function getSheetDataXMLFilePathForSheetId($sheetId)
{
$sheetDataXMLFilePath = '';
// find the file path of the sheet, by looking at the "workbook.xml.res" file
$xmlReader = new XMLReader();
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_RELS_FILE_PATH)) {
while ($xmlReader->read()) {
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(self::XML_ATTRIBUTE_TARGET);
// sometimes, the sheet data file path already contains "/xl/"...
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
$sheetDataXMLFilePath = '/xl/' . $sheetDataXMLFilePath;
break;
}
}
}
}
$xmlReader->close();
}
return $sheetDataXMLFilePath;
}
}

View File

@@ -0,0 +1,330 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class StyleHelper
* This class provides helper functions related to XLSX styles
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class StyleHelper
{
/** Paths of XML files relative to the XLSX file root */
const STYLES_XML_FILE_PATH = 'xl/styles.xml';
/** Nodes used to find relevant information in the styles XML file */
const XML_NODE_NUM_FMTS = 'numFmts';
const XML_NODE_NUM_FMT = 'numFmt';
const XML_NODE_CELL_XFS = 'cellXfs';
const XML_NODE_XF = 'xf';
/** Attributes used to find relevant information in the styles XML file */
const XML_ATTRIBUTE_NUM_FMT_ID = 'numFmtId';
const XML_ATTRIBUTE_FORMAT_CODE = 'formatCode';
const XML_ATTRIBUTE_APPLY_NUMBER_FORMAT = 'applyNumberFormat';
/** By convention, default style ID is 0 */
const DEFAULT_STYLE_ID = 0;
const NUMBER_FORMAT_GENERAL = 'General';
/**
* @see https://msdn.microsoft.com/en-us/library/ff529597(v=office.12).aspx
* @var array Mapping between built-in numFmtId and the associated format - for dates only
*/
protected static $builtinNumFmtIdToNumFormatMapping = [
14 => 'm/d/yyyy', // @NOTE: ECMA spec is 'mm-dd-yy'
15 => 'd-mmm-yy',
16 => 'd-mmm',
17 => 'mmm-yy',
18 => 'h:mm AM/PM',
19 => 'h:mm:ss AM/PM',
20 => 'h:mm',
21 => 'h:mm:ss',
22 => 'm/d/yyyy h:mm', // @NOTE: ECMA spec is 'm/d/yy h:mm',
45 => 'mm:ss',
46 => '[h]:mm:ss',
47 => 'mm:ss.0', // @NOTE: ECMA spec is 'mmss.0',
];
/** @var string Path of the XLSX file being read */
protected $filePath;
/** @var array Array containing the IDs of built-in number formats indicating a date */
protected $builtinNumFmtIdIndicatingDates;
/** @var array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */
protected $customNumberFormats;
/** @var array Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */
protected $stylesAttributes;
/** @var array Cache containing a mapping NUM_FMT_ID => IS_DATE_FORMAT. Used to avoid lots of recalculations */
protected $numFmtIdToIsDateFormatCache = [];
/**
* @param string $filePath Path of the XLSX file being read
*/
public function __construct($filePath)
{
$this->filePath = $filePath;
$this->builtinNumFmtIdIndicatingDates = array_keys(self::$builtinNumFmtIdToNumFormatMapping);
}
/**
* Returns whether the style with the given ID should consider
* numeric values as timestamps and format the cell as a date.
*
* @param int $styleId Zero-based style ID
* @return bool Whether the cell with the given cell should display a date instead of a numeric value
*/
public function shouldFormatNumericValueAsDate($styleId)
{
$stylesAttributes = $this->getStylesAttributes();
// 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.
// Using isset here because it is way faster than array_key_exists...
if ($styleId === self::DEFAULT_STYLE_ID || !isset($stylesAttributes[$styleId])) {
return false;
}
$styleAttributes = $stylesAttributes[$styleId];
return $this->doesStyleIndicateDate($styleAttributes);
}
/**
* Reads the styles.xml file and extract the relevant information from the file.
*
* @return void
*/
protected function extractRelevantInfo()
{
$this->customNumberFormats = [];
$this->stylesAttributes = [];
$xmlReader = new XMLReader();
if ($xmlReader->openFileInZip($this->filePath, self::STYLES_XML_FILE_PATH)) {
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
$this->extractNumberFormats($xmlReader);
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
$this->extractStyleAttributes($xmlReader);
}
}
$xmlReader->close();
}
}
/**
* Extracts number formats from the "numFmt" nodes.
* For simplicity, the styles attributes are kept in memory. This is possible thanks
* to the reuse of formats. So 1 million cells should not use 1 million formats.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "numFmts" node
* @return void
*/
protected function extractNumberFormats($xmlReader)
{
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMT)) {
$numFmtId = intval($xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
$formatCode = $xmlReader->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
$this->customNumberFormats[$numFmtId] = $formatCode;
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_NUM_FMTS)) {
// Once done reading "numFmts" node's children
break;
}
}
}
/**
* Extracts style attributes from the "xf" nodes, inside the "cellXfs" section.
* For simplicity, the styles attributes are kept in memory. This is possible thanks
* to the reuse of styles. So 1 million cells should not use 1 million styles.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "cellXfs" node
* @return void
*/
protected function extractStyleAttributes($xmlReader)
{
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_XF)) {
$numFmtId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID);
$normalizedNumFmtId = ($numFmtId !== null) ? intval($numFmtId) : null;
$applyNumberFormat = $xmlReader->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT);
$normalizedApplyNumberFormat = ($applyNumberFormat !== null) ? !!$applyNumberFormat : null;
$this->stylesAttributes[] = [
self::XML_ATTRIBUTE_NUM_FMT_ID => $normalizedNumFmtId,
self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => $normalizedApplyNumberFormat,
];
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_CELL_XFS)) {
// Once done reading "cellXfs" node's children
break;
}
}
}
/**
* @return array The custom number formats
*/
protected function getCustomNumberFormats()
{
if (!isset($this->customNumberFormats)) {
$this->extractRelevantInfo();
}
return $this->customNumberFormats;
}
/**
* @return array The styles attributes
*/
protected function getStylesAttributes()
{
if (!isset($this->stylesAttributes)) {
$this->extractRelevantInfo();
}
return $this->stylesAttributes;
}
/**
* @param array $styleAttributes Array containing the style attributes (2 keys: "applyNumberFormat" and "numFmtId")
* @return bool Whether the style with the given attributes indicates that the number is a date
*/
protected function doesStyleIndicateDate($styleAttributes)
{
$applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
// A style may apply a date format if it has:
// - "applyNumberFormat" attribute not set to "false"
// - "numFmtId" attribute set
// This is a preliminary check, as having "numFmtId" set just means the style should apply a specific number format,
// but this is not necessarily a date.
if ($applyNumberFormat === false || $numFmtId === null) {
return false;
}
return $this->doesNumFmtIdIndicateDate($numFmtId);
}
/**
* Returns whether the number format ID indicates that the number is a date.
* The result is cached to avoid recomputing the same thing over and over, as
* "numFmtId" attributes can be shared between multiple styles.
*
* @param int $numFmtId
* @return bool Whether the number format ID indicates that the number is a date
*/
protected function doesNumFmtIdIndicateDate($numFmtId)
{
if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) {
$formatCode = $this->getFormatCodeForNumFmtId($numFmtId);
$this->numFmtIdToIsDateFormatCache[$numFmtId] = (
$this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
$this->isFormatCodeCustomDateFormat($formatCode)
);
}
return $this->numFmtIdToIsDateFormatCache[$numFmtId];
}
/**
* @param int $numFmtId
* @return string|null The custom number format or NULL if none defined for the given numFmtId
*/
protected function getFormatCodeForNumFmtId($numFmtId)
{
$customNumberFormats = $this->getCustomNumberFormats();
// Using isset here because it is way faster than array_key_exists...
return (isset($customNumberFormats[$numFmtId])) ? $customNumberFormats[$numFmtId] : null;
}
/**
* @param int $numFmtId
* @return bool Whether the number format ID indicates that the number is a date
*/
protected function isNumFmtIdBuiltInDateFormat($numFmtId)
{
return in_array($numFmtId, $this->builtinNumFmtIdIndicatingDates);
}
/**
* @param string|null $formatCode
* @return bool Whether the given format code indicates that the number is a date
*/
protected function isFormatCodeCustomDateFormat($formatCode)
{
// if no associated format code or if using the default "General" format
if ($formatCode === null || strcasecmp($formatCode, self::NUMBER_FORMAT_GENERAL) === 0) {
return false;
}
return $this->isFormatCodeMatchingDateFormatPattern($formatCode);
}
/**
* @param string $formatCode
* @return bool Whether the given format code matches a date format pattern
*/
protected function isFormatCodeMatchingDateFormatPattern($formatCode)
{
// Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\")
$pattern = '((?<!\\\)\[.+?(?<!\\\)\])';
$formatCode = preg_replace($pattern, '', $formatCode);
// custom date formats contain specific characters to represent the date:
// e - yy - m - d - h - s
// and all of their variants (yyyy - mm - dd...)
$dateFormatCharacters = ['e', 'yy', 'm', 'd', 'h', 's'];
$hasFoundDateFormatCharacter = false;
foreach ($dateFormatCharacters as $dateFormatCharacter) {
// character not preceded by "\" (case insensitive)
$pattern = '/(?<!\\\)' . $dateFormatCharacter . '/i';
if (preg_match($pattern, $formatCode)) {
$hasFoundDateFormatCharacter = true;
break;
}
}
return $hasFoundDateFormatCharacter;
}
/**
* Returns the format as defined in "styles.xml" of the given style.
* NOTE: It is assumed that the style DOES have a number format associated to it.
*
* @param int $styleId Zero-based style ID
* @return string The number format code associated with the given style
*/
public function getNumberFormatCode($styleId)
{
$stylesAttributes = $this->getStylesAttributes();
$styleAttributes = $stylesAttributes[$styleId];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
$numberFormatCode = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
} else {
$customNumberFormats = $this->getCustomNumberFormats();
$numberFormatCode = $customNumberFormats[$numFmtId];
}
return $numberFormatCode;
}
}

View File

@@ -0,0 +1,113 @@
<?php
namespace Box\Spout\Reader\XLSX;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\AbstractReader;
use Box\Spout\Reader\XLSX\Helper\SharedStringsHelper;
/**
* Class Reader
* This class provides support to read data from a XLSX file
*
* @package Box\Spout\Reader\XLSX
*/
class Reader extends AbstractReader
{
/** @var \ZipArchive */
protected $zip;
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var SheetIterator To iterator over the XLSX sheets */
protected $sheetIterator;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* @param string $tempFolder Temporary folder where the temporary files will be created
* @return Reader
*/
public function setTempFolder($tempFolder)
{
$this->getOptions()->setTempFolder($tempFolder);
return $this;
}
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return false;
}
/**
* Opens the file at the given file path to make it ready to be read.
* It also parses the sharedStrings.xml file to get all the shared strings available in memory
* and fetches all the available sheets.
*
* @param string $filePath Path of the file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
protected function openReader($filePath)
{
$this->zip = new \ZipArchive();
if ($this->zip->open($filePath) === true) {
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->getOptions()->getTempFolder());
if ($this->sharedStringsHelper->hasSharedStrings()) {
// Extracts all the strings from the sheets for easy access in the future
$this->sharedStringsHelper->extractSharedStrings();
}
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions(), $this->sharedStringsHelper, $this->globalFunctionsHelper);
} else {
throw new IOException("Could not open $filePath for reading.");
}
}
/**
* Returns an iterator to iterate over sheets.
*
* @return SheetIterator To iterate over sheets
*/
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
/**
* Closes the reader. To be used after reading the file.
*
* @return void
*/
protected function closeReader()
{
if ($this->zip) {
$this->zip->close();
}
if ($this->sharedStringsHelper) {
$this->sharedStringsHelper->cleanup();
}
}
}

View File

@@ -0,0 +1,33 @@
<?php
namespace Box\Spout\Reader\XLSX;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\XLSX
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
/** @var string|null Temporary folder where the temporary files will be created */
protected $tempFolder = null;
/**
* @return string|null Temporary folder where the temporary files will be created
*/
public function getTempFolder()
{
return $this->tempFolder;
}
/**
* @param string|null $tempFolder Temporary folder where the temporary files will be created
* @return ReaderOptions
*/
public function setTempFolder($tempFolder)
{
$this->tempFolder = $tempFolder;
return $this;
}
}

View File

@@ -0,0 +1,405 @@
<?php
namespace Box\Spout\Reader\XLSX;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Helper\CellHelper;
use Box\Spout\Reader\XLSX\Helper\CellValueFormatter;
use Box\Spout\Reader\XLSX\Helper\StyleHelper;
use Box\Spout\Reader\Common\XMLProcessor;
/**
* Class RowIterator
*
* @package Box\Spout\Reader\XLSX
*/
class RowIterator implements IteratorInterface
{
/** Definition of XML nodes names used to parse data */
const XML_NODE_DIMENSION = 'dimension';
const XML_NODE_WORKSHEET = 'worksheet';
const XML_NODE_ROW = 'row';
const XML_NODE_CELL = 'c';
/** Definition of XML attributes used to parse data */
const XML_ATTRIBUTE_REF = 'ref';
const XML_ATTRIBUTE_SPANS = 'spans';
const XML_ATTRIBUTE_ROW_INDEX = 'r';
const XML_ATTRIBUTE_CELL_INDEX = 'r';
/** @var string Path of the XLSX file being read */
protected $filePath;
/** @var string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml */
protected $sheetDataXMLFilePath;
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
protected $xmlProcessor;
/** @var Helper\CellValueFormatter Helper to format cell values */
protected $cellValueFormatter;
/** @var Helper\StyleHelper $styleHelper Helper to work with styles */
protected $styleHelper;
/**
* TODO: This variable can be deleted when row indices get preserved
* @var int Number of read rows
*/
protected $numReadRows = 0;
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
protected $currentlyProcessedRowData = [];
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
protected $rowDataBuffer = null;
/** @var bool Indicates whether all rows have been read */
protected $hasReachedEndOfFile = false;
/** @var int The number of columns the sheet has (0 meaning undefined) */
protected $numColumns = 0;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var int Last row index processed (one-based) */
protected $lastRowIndexProcessed = 0;
/** @var int Row index to be processed next (one-based) */
protected $nextRowIndexToBeProcessed = 0;
/** @var int Last column index processed (zero-based) */
protected $lastColumnIndexProcessed = -1;
/**
* @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 \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
*/
public function __construct($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper)
{
$this->filePath = $filePath;
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
$this->xmlReader = new XMLReader();
$this->styleHelper = new StyleHelper($filePath);
$this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $options->shouldFormatDates());
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
// Register all callbacks to process different nodes when reading the XML file
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
$this->xmlProcessor->registerCallback(self::XML_NODE_DIMENSION, XMLProcessor::NODE_TYPE_START, [$this, 'processDimensionStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_WORKSHEET, XMLProcessor::NODE_TYPE_END, [$this, 'processWorksheetEndingNode']);
}
/**
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @return string Path of the XML file containing the sheet data,
* without the leading slash.
*/
protected function normalizeSheetDataXMLFilePath($sheetDataXMLFilePath)
{
return ltrim($sheetDataXMLFilePath, '/');
}
/**
* Rewind the Iterator to the first element.
* Initializes the XMLReader object that reads the associated sheet data.
* The XMLReader is configured to be safe from billion laughs attack.
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data XML cannot be read
*/
public function rewind()
{
$this->xmlReader->close();
if ($this->xmlReader->openFileInZip($this->filePath, $this->sheetDataXMLFilePath) === false) {
throw new IOException("Could not open \"{$this->sheetDataXMLFilePath}\".");
}
$this->numReadRows = 0;
$this->lastRowIndexProcessed = 0;
$this->nextRowIndexToBeProcessed = 0;
$this->rowDataBuffer = null;
$this->hasReachedEndOfFile = false;
$this->numColumns = 0;
$this->next();
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return (!$this->hasReachedEndOfFile);
}
/**
* Move forward to next element. Reads data describing the next unprocessed row.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
public function next()
{
$this->nextRowIndexToBeProcessed++;
if ($this->doesNeedDataForNextRowToBeProcessed()) {
$this->readDataForNextRow();
}
}
/**
* Returns whether we need data for the next row to be processed.
* We don't need to read data if:
* we have already read at least one row
* AND
* we need to preserve empty rows
* AND
* the last row that was read is not the row that need to be processed
* (i.e. if we need to return empty rows)
*
* @return bool Whether we need data for the next row to be processed.
*/
protected function doesNeedDataForNextRowToBeProcessed()
{
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
return (
!$hasReadAtLeastOneRow ||
!$this->shouldPreserveEmptyRows ||
$this->lastRowIndexProcessed < $this->nextRowIndexToBeProcessed
);
}
/**
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
protected function readDataForNextRow()
{
$this->currentlyProcessedRowData = [];
try {
$this->xmlProcessor->readUntilStopped();
} catch (XMLProcessingException $exception) {
throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
}
$this->rowDataBuffer = $this->currentlyProcessedRowData;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<dimension>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processDimensionStartingNode($xmlReader)
{
// Read dimensions of the sheet
$dimensionRef = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); // returns 'A1:M13' for instance (or 'A1' for empty sheet)
if (preg_match('/[A-Z]+\d+:([A-Z]+\d+)/', $dimensionRef, $matches)) {
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($matches[1]) + 1;
}
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowStartingNode($xmlReader)
{
// Reset index of the last processed column
$this->lastColumnIndexProcessed = -1;
// Mark the last processed row as the one currently being read
$this->lastRowIndexProcessed = $this->getRowIndex($xmlReader);
// Read spans info if present
$numberOfColumnsForRow = $this->numColumns;
$spans = $xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
if ($spans) {
list(, $numberOfColumnsForRow) = explode(':', $spans);
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
}
$this->currentlyProcessedRowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<cell>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processCellStartingNode($xmlReader)
{
$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;
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowEndingNode()
{
// if the fetched row is empty and we don't want to preserve it..,
if (!$this->shouldPreserveEmptyRows && $this->isEmptyRow($this->currentlyProcessedRowData)) {
// ... skip it
return XMLProcessor::PROCESSING_CONTINUE;
}
$this->numReadRows++;
// If needed, we fill the empty cells
if ($this->numColumns === 0) {
$this->currentlyProcessedRowData = CellHelper::fillMissingArrayIndexes($this->currentlyProcessedRowData);
}
// at this point, we have all the data we need for the row
// so that we can populate the buffer
return XMLProcessor::PROCESSING_STOP;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processWorksheetEndingNode()
{
// The closing "</worksheet>" marks the end of the file
$this->hasReachedEndOfFile = true;
return XMLProcessor::PROCESSING_STOP;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" node
* @return int Row index
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
protected function getRowIndex($xmlReader)
{
// Get "r" attribute if present (from something like <row r="3"...>
$currentRowIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ROW_INDEX);
return ($currentRowIndex !== null) ?
intval($currentRowIndex) :
$this->lastRowIndexProcessed + 1;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<c>" node
* @return int Column index
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
protected function getColumnIndex($xmlReader)
{
// Get "r" attribute if present (from something like <c r="A1"...>
$currentCellIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
return ($currentCellIndex !== null) ?
CellHelper::getColumnIndexFromCellIndex($currentCellIndex) :
$this->lastColumnIndexProcessed + 1;
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|null The value associated with the cell (null when the cell has an error)
*/
protected function getCellValue($node)
{
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
}
/**
* @param array $rowData
* @return bool Whether the given row is empty
*/
protected function isEmptyRow($rowData)
{
return (count($rowData) === 1 && key($rowData) === '');
}
/**
* Return the current element, either an empty row or from the buffer.
* @link http://php.net/manual/en/iterator.current.php
*
* @return array|null
*/
public function current()
{
$rowDataForRowToBeProcessed = $this->rowDataBuffer;
if ($this->shouldPreserveEmptyRows) {
// when we need to preserve empty rows, we will either return
// an empty row or the last row read. This depends whether the
// index of last row that was read matches the index of the last
// row whose value should be returned.
if ($this->lastRowIndexProcessed !== $this->nextRowIndexToBeProcessed) {
// return empty row if mismatch between last processed row
// and the row that needs to be returned
$rowDataForRowToBeProcessed = [''];
}
}
return $rowDataForRowToBeProcessed;
}
/**
* Return the key of the current element. Here, the row index.
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
// TODO: This should return $this->nextRowIndexToBeProcessed
// but to avoid a breaking change, the return value for
// this function has been kept as the number of rows read.
return $this->shouldPreserveEmptyRows ?
$this->nextRowIndexToBeProcessed :
$this->numReadRows;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
$this->xmlReader->close();
}
}

View File

@@ -0,0 +1,79 @@
<?php
namespace Box\Spout\Reader\XLSX;
use Box\Spout\Reader\SheetInterface;
/**
* Class Sheet
* Represents a sheet within a XLSX file
*
* @package Box\Spout\Reader\XLSX
*/
class Sheet implements SheetInterface
{
/** @var \Box\Spout\Reader\XLSX\RowIterator To iterate over sheet's rows */
protected $rowIterator;
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
protected $index;
/** @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, $isSheetActive, $options, $sharedStringsHelper)
{
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
$this->index = $sheetIndex;
$this->name = $sheetName;
$this->isActive = $isSheetActive;
}
/**
* @api
* @return \Box\Spout\Reader\XLSX\RowIterator
*/
public function getRowIterator()
{
return $this->rowIterator;
}
/**
* @api
* @return int Index of the sheet, based on order in the workbook (zero-based)
*/
public function getIndex()
{
return $this->index;
}
/**
* @api
* @return string Name of the sheet
*/
public function getName()
{
return $this->name;
}
/**
* @api
* @return bool Whether the sheet was defined as active
*/
public function isActive()
{
return $this->isActive;
}
}

View File

@@ -0,0 +1,114 @@
<?php
namespace Box\Spout\Reader\XLSX;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\XLSX\Helper\SheetHelper;
use Box\Spout\Reader\Exception\NoSheetsFoundException;
/**
* Class SheetIterator
* Iterate over XLSX sheet.
*
* @package Box\Spout\Reader\XLSX
*/
class SheetIterator implements IteratorInterface
{
/** @var \Box\Spout\Reader\XLSX\Sheet[] The list of sheet present in the file */
protected $sheets;
/** @var int The index of the sheet being read (zero-based) */
protected $currentSheetIndex;
/**
* @param string $filePath Path of the file to be read
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
{
// Fetch all available sheets
$sheetHelper = new SheetHelper($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper);
$this->sheets = $sheetHelper->getSheets();
if (count($this->sheets) === 0) {
throw new NoSheetsFoundException('The file must contain at least one sheet.');
}
}
/**
* Rewind the Iterator to the first element
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
*/
public function rewind()
{
$this->currentSheetIndex = 0;
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return ($this->currentSheetIndex < count($this->sheets));
}
/**
* Move forward to next element
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
*/
public function next()
{
// 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();
$this->currentSheetIndex++;
}
}
/**
* Return the current element
* @link http://php.net/manual/en/iterator.current.php
*
* @return \Box\Spout\Reader\XLSX\Sheet
*/
public function current()
{
return $this->sheets[$this->currentSheetIndex];
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return $this->currentSheetIndex + 1;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
// make sure we are not leaking memory in case the iteration stopped before the end
foreach ($this->sheets as $sheet) {
$sheet->getRowIterator()->end();
}
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Box\Spout\Writer;
use Box\Spout\Writer\Exception\WriterNotOpenedException;
/**
* Class AbstractMultiSheetsWriter
*
* @package Box\Spout\Writer
* @abstract
*/
abstract class AbstractMultiSheetsWriter extends AbstractWriter
{
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically = true;
/**
* @return Common\Internal\WorkbookInterface The workbook representing the file to be written
*/
abstract protected function getWorkbook();
/**
* Sets whether new sheets should be automatically created when the max rows limit per sheet is reached.
* This must be set before opening the writer.
*
* @api
* @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached
* @return AbstractMultiSheetsWriter
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
return $this;
}
/**
* Returns all the workbook's sheets
*
* @api
* @return Common\Sheet[] All the workbook's sheets
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function getSheets()
{
$this->throwIfBookIsNotAvailable();
$externalSheets = [];
$worksheets = $this->getWorkbook()->getWorksheets();
/** @var Common\Internal\WorksheetInterface $worksheet */
foreach ($worksheets as $worksheet) {
$externalSheets[] = $worksheet->getExternalSheet();
}
return $externalSheets;
}
/**
* Creates a new sheet and make it the current sheet. The data will now be written to this sheet.
*
* @api
* @return Common\Sheet The created sheet
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function addNewSheetAndMakeItCurrent()
{
$this->throwIfBookIsNotAvailable();
$worksheet = $this->getWorkbook()->addNewSheetAndMakeItCurrent();
return $worksheet->getExternalSheet();
}
/**
* Returns the current sheet
*
* @api
* @return Common\Sheet The current sheet
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function getCurrentSheet()
{
$this->throwIfBookIsNotAvailable();
return $this->getWorkbook()->getCurrentWorksheet()->getExternalSheet();
}
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @api
* @param Common\Sheet $sheet The sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet)
{
$this->throwIfBookIsNotAvailable();
$this->getWorkbook()->setCurrentSheet($sheet);
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
*/
protected function throwIfBookIsNotAvailable()
{
if (!$this->getWorkbook()) {
throw new WriterNotOpenedException('The writer must be opened before performing this action.');
}
}
}

View File

@@ -0,0 +1,384 @@
<?php
namespace Box\Spout\Writer;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\SpoutException;
use Box\Spout\Common\Helper\FileSystemHelper;
use Box\Spout\Writer\Exception\WriterAlreadyOpenedException;
use Box\Spout\Writer\Exception\WriterNotOpenedException;
use Box\Spout\Writer\Style\StyleBuilder;
/**
* Class AbstractWriter
*
* @package Box\Spout\Writer
* @abstract
*/
abstract class AbstractWriter implements WriterInterface
{
/** @var string Path to the output file */
protected $outputFilePath;
/** @var resource Pointer to the file/stream we will write to */
protected $filePointer;
/** @var bool Indicates whether the writer has been opened or not */
protected $isWriterOpened = false;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var Style\Style Style to be applied to the next written row(s) */
protected $rowStyle;
/** @var Style\Style Default row style. Each writer can have its own default style */
protected $defaultRowStyle;
/** @var string Content-Type value for the header - to be defined by child class */
protected static $headerContentType;
/**
* Opens the streamer and makes it ready to accept data.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened
*/
abstract protected function openWriter();
/**
* Adds data to the currently openned writer.
*
* @param array $dataRow Array containing data to be streamed.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param Style\Style $style Style to be applied to the written row
* @return void
*/
abstract protected function addRowToWriter(array $dataRow, $style);
/**
* Closes the streamer, preventing any additional writing.
*
* @return void
*/
abstract protected function closeWriter();
/**
*
*/
public function __construct()
{
$this->defaultRowStyle = $this->getDefaultRowStyle();
$this->resetRowStyleToDefault();
}
/**
* Sets the default styles for all rows added with "addRow".
* Overriding the default style instead of using "addRowWithStyle" improves performance by 20%.
* @see https://github.com/box/spout/issues/272
*
* @param Style\Style $defaultStyle
* @return AbstractWriter
*/
public function setDefaultRowStyle($defaultStyle)
{
$this->defaultRowStyle = $defaultStyle;
$this->resetRowStyleToDefault();
return $this;
}
/**
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @return AbstractWriter
*/
public function setGlobalFunctionsHelper($globalFunctionsHelper)
{
$this->globalFunctionsHelper = $globalFunctionsHelper;
return $this;
}
/**
* Inits the writer and opens it to accept data.
* By using this method, the data will be written to a file.
*
* @api
* @param string $outputFilePath Path of the output file that will contain the data
* @return AbstractWriter
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened or if the given path is not writable
*/
public function openToFile($outputFilePath)
{
$this->outputFilePath = $outputFilePath;
$this->filePointer = $this->globalFunctionsHelper->fopen($this->outputFilePath, 'wb+');
$this->throwIfFilePointerIsNotAvailable();
$this->openWriter();
$this->isWriterOpened = true;
return $this;
}
/**
* Inits the writer and opens it to accept data.
* By using this method, the data will be outputted directly to the browser.
*
* @codeCoverageIgnore
*
* @api
* @param string $outputFileName Name of the output file that will contain the data. If a path is passed in, only the file name will be kept
* @return AbstractWriter
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened
*/
public function openToBrowser($outputFileName)
{
$this->outputFilePath = $this->globalFunctionsHelper->basename($outputFileName);
$this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
$this->throwIfFilePointerIsNotAvailable();
// Clear any previous output (otherwise the generated file will be corrupted)
// @see https://github.com/box/spout/issues/241
$this->globalFunctionsHelper->ob_end_clean();
// Set headers
$this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType);
$this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"');
/*
* When forcing the download of a file over SSL,IE8 and lower browsers fail
* if the Cache-Control and Pragma headers are not set.
*
* @see http://support.microsoft.com/KB/323308
* @see https://github.com/liuggio/ExcelBundle/issues/45
*/
$this->globalFunctionsHelper->header('Cache-Control: max-age=0');
$this->globalFunctionsHelper->header('Pragma: public');
$this->openWriter();
$this->isWriterOpened = true;
return $this;
}
/**
* Checks if the pointer to the file/stream to write to is available.
* Will throw an exception if not available.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the pointer is not available
*/
protected function throwIfFilePointerIsNotAvailable()
{
if (!$this->filePointer) {
throw new IOException('File pointer has not be opened');
}
}
/**
* Checks if the writer has already been opened, since some actions must be done before it gets opened.
* Throws an exception if already opened.
*
* @param string $message Error message
* @return void
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened and must not be.
*/
protected function throwIfWriterAlreadyOpened($message)
{
if ($this->isWriterOpened) {
throw new WriterAlreadyOpenedException($message);
}
}
/**
* Write given data to the output. New data will be appended to end of stream.
*
* @param array $dataRow Array containing data to be streamed.
* If empty, no data is added (i.e. not even as a blank row)
* Example: $dataRow = ['data1', 1234, null, '', 'data5', false];
* @api
* @return AbstractWriter
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
* @throws \Box\Spout\Common\Exception\SpoutException If anything else goes wrong while writing data
*/
public function addRow(array $dataRow)
{
if ($this->isWriterOpened) {
// empty $dataRow should not add an empty line
if (!empty($dataRow)) {
try {
$this->addRowToWriter($dataRow, $this->rowStyle);
} catch (SpoutException $e) {
// if an exception occurs while writing data,
// close the writer and remove all files created so far.
$this->closeAndAttemptToCleanupAllFiles();
// re-throw the exception to alert developers of the error
throw $e;
}
}
} else {
throw new WriterNotOpenedException('The writer needs to be opened before adding row.');
}
return $this;
}
/**
* Write given data to the output and apply the given style.
* @see addRow
*
* @api
* @param array $dataRow Array of array containing data to be streamed.
* @param Style\Style $style Style to be applied to the row.
* @return AbstractWriter
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRowWithStyle(array $dataRow, $style)
{
if (!$style instanceof Style\Style) {
throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.');
}
$this->setRowStyle($style);
$this->addRow($dataRow);
$this->resetRowStyleToDefault();
return $this;
}
/**
* Write given data to the output. New data will be appended to end of stream.
*
* @api
* @param array $dataRows Array of array containing data to be streamed.
* If a row is empty, it won't be added (i.e. not even as a blank row)
* Example: $dataRows = [
* ['data11', 12, , '', 'data13'],
* ['data21', 'data22', null, false],
* ];
* @return AbstractWriter
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRows(array $dataRows)
{
if (!empty($dataRows)) {
$firstRow = reset($dataRows);
if (!is_array($firstRow)) {
throw new InvalidArgumentException('The input should be an array of arrays');
}
foreach ($dataRows as $dataRow) {
$this->addRow($dataRow);
}
}
return $this;
}
/**
* Write given data to the output and apply the given style.
* @see addRows
*
* @api
* @param array $dataRows Array of array containing data to be streamed.
* @param Style\Style $style Style to be applied to the rows.
* @return AbstractWriter
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRowsWithStyle(array $dataRows, $style)
{
if (!$style instanceof Style\Style) {
throw new InvalidArgumentException('The "$style" argument must be a Style instance and cannot be NULL.');
}
$this->setRowStyle($style);
$this->addRows($dataRows);
$this->resetRowStyleToDefault();
return $this;
}
/**
* Returns the default style to be applied to rows.
* Can be overriden by children to have a custom style.
*
* @return Style\Style
*/
protected function getDefaultRowStyle()
{
return (new StyleBuilder())->build();
}
/**
* Sets the style to be applied to the next written rows
* until it is changed or reset.
*
* @param Style\Style $style
* @return void
*/
private function setRowStyle($style)
{
// Merge given style with the default one to inherit custom properties
$this->rowStyle = $style->mergeWith($this->defaultRowStyle);
}
/**
* Resets the style to be applied to the next written rows.
*
* @return void
*/
private function resetRowStyleToDefault()
{
$this->rowStyle = $this->defaultRowStyle;
}
/**
* Closes the writer. This will close the streamer as well, preventing new data
* to be written to the file.
*
* @api
* @return void
*/
public function close()
{
if (!$this->isWriterOpened) {
return;
}
$this->closeWriter();
if (is_resource($this->filePointer)) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
$this->isWriterOpened = false;
}
/**
* Closes the writer and attempts to cleanup all files that were
* created during the writing process (temp files & final file).
*
* @return void
*/
private function closeAndAttemptToCleanupAllFiles()
{
// close the writer, which should remove all temp files
$this->close();
// remove output file if it was created
if ($this->globalFunctionsHelper->file_exists($this->outputFilePath)) {
$outputFolderPath = dirname($this->outputFilePath);
$fileSystemHelper = new FileSystemHelper($outputFolderPath);
$fileSystemHelper->deleteFile($this->outputFilePath);
}
}
}

View File

@@ -0,0 +1,118 @@
<?php
namespace Box\Spout\Writer\CSV;
use Box\Spout\Writer\AbstractWriter;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Helper\EncodingHelper;
/**
* Class Writer
* This class provides support to write data to CSV files
*
* @package Box\Spout\Writer\CSV
*/
class Writer extends AbstractWriter
{
/** Number of rows to write before flushing */
const FLUSH_THRESHOLD = 500;
/** @var string Content-Type value for the header */
protected static $headerContentType = 'text/csv; charset=UTF-8';
/** @var string Defines the character used to delimit fields (one character only) */
protected $fieldDelimiter = ',';
/** @var string Defines the character used to enclose fields (one character only) */
protected $fieldEnclosure = '"';
/** @var int */
protected $lastWrittenRowIndex = 0;
/** @var bool */
protected $shouldAddBOM = true;
/**
* Sets the field delimiter for the CSV
*
* @api
* @param string $fieldDelimiter Character that delimits fields
* @return Writer
*/
public function setFieldDelimiter($fieldDelimiter)
{
$this->fieldDelimiter = $fieldDelimiter;
return $this;
}
/**
* Sets the field enclosure for the CSV
*
* @api
* @param string $fieldEnclosure Character that enclose fields
* @return Writer
*/
public function setFieldEnclosure($fieldEnclosure)
{
$this->fieldEnclosure = $fieldEnclosure;
return $this;
}
/**
* Set if a BOM has to be added to the file
*
* @param bool $shouldAddBOM
* @return Writer
*/
public function setShouldAddBOM($shouldAddBOM)
{
$this->shouldAddBOM = (bool) $shouldAddBOM;
return $this;
}
/**
* Opens the CSV streamer and makes it ready to accept data.
*
* @return void
*/
protected function openWriter()
{
if ($this->shouldAddBOM) {
// Adds UTF-8 BOM for Unicode compatibility
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
}
}
/**
* Adds data to the currently opened writer.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Ignored here since CSV does not support styling.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
protected function addRowToWriter(array $dataRow, $style)
{
$wasWriteSuccessful = $this->globalFunctionsHelper->fputcsv($this->filePointer, $dataRow, $this->fieldDelimiter, $this->fieldEnclosure);
if ($wasWriteSuccessful === false) {
throw new IOException('Unable to write data');
}
$this->lastWrittenRowIndex++;
if ($this->lastWrittenRowIndex % self::FLUSH_THRESHOLD === 0) {
$this->globalFunctionsHelper->fflush($this->filePointer);
}
}
/**
* Closes the CSV streamer, preventing any additional writing.
* If set, sets the headers and redirects output to the browser.
*
* @return void
*/
protected function closeWriter()
{
$this->lastWrittenRowIndex = 0;
}
}

View File

@@ -0,0 +1,138 @@
<?php
namespace Box\Spout\Writer\Common\Helper;
/**
* Class AbstractStyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\Common\Helper
*/
abstract class AbstractStyleHelper
{
/** @var array [SERIALIZED_STYLE] => [STYLE_ID] mapping table, keeping track of the registered styles */
protected $serializedStyleToStyleIdMappingTable = [];
/** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */
protected $styleIdToStyleMappingTable = [];
/**
* @param \Box\Spout\Writer\Style\Style $defaultStyle
*/
public function __construct($defaultStyle)
{
// This ensures that the default style is the first one to be registered
$this->registerStyle($defaultStyle);
}
/**
* Registers the given style as a used style.
* Duplicate styles won't be registered more than once.
*
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
*/
public function registerStyle($style)
{
$serializedStyle = $style->serialize();
if (!$this->hasStyleAlreadyBeenRegistered($style)) {
$nextStyleId = count($this->serializedStyleToStyleIdMappingTable);
$style->setId($nextStyleId);
$this->serializedStyleToStyleIdMappingTable[$serializedStyle] = $nextStyleId;
$this->styleIdToStyleMappingTable[$nextStyleId] = $style;
}
return $this->getStyleFromSerializedStyle($serializedStyle);
}
/**
* Returns whether the given style has already been registered.
*
* @param \Box\Spout\Writer\Style\Style $style
* @return bool
*/
protected function hasStyleAlreadyBeenRegistered($style)
{
$serializedStyle = $style->serialize();
// Using isset here because it is way faster than array_key_exists...
return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
}
/**
* Returns the registered style associated to the given serialization.
*
* @param string $serializedStyle The serialized style from which the actual style should be fetched from
* @return \Box\Spout\Writer\Style\Style
*/
protected function getStyleFromSerializedStyle($serializedStyle)
{
$styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle];
return $this->styleIdToStyleMappingTable[$styleId];
}
/**
* @return \Box\Spout\Writer\Style\Style[] List of registered styles
*/
protected function getRegisteredStyles()
{
return array_values($this->styleIdToStyleMappingTable);
}
/**
* Returns the default style
*
* @return \Box\Spout\Writer\Style\Style Default style
*/
protected function getDefaultStyle()
{
// By construction, the default style has ID 0
return $this->styleIdToStyleMappingTable[0];
}
/**
* Apply additional styles if the given row needs it.
* Typically, set "wrap text" if a cell contains a new line.
*
* @param \Box\Spout\Writer\Style\Style $style The original style
* @param array $dataRow The row the style will be applied to
* @return \Box\Spout\Writer\Style\Style The updated style
*/
public function applyExtraStylesIfNeeded($style, $dataRow)
{
$updatedStyle = $this->applyWrapTextIfCellContainsNewLine($style, $dataRow);
return $updatedStyle;
}
/**
* Set the "wrap text" option if a cell of the given row contains a new line.
*
* @NOTE: There is a bug on the Mac version of Excel (2011 and below) where new lines
* are ignored even when the "wrap text" option is set. This only occurs with
* inline strings (shared strings do work fine).
* A workaround would be to encode "\n" as "_x000D_" but it does not work
* on the Windows version of Excel...
*
* @param \Box\Spout\Writer\Style\Style $style The original style
* @param array $dataRow The row the style will be applied to
* @return \Box\Spout\Writer\Style\Style The eventually updated style
*/
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
{
// if the "wrap text" option is already set, no-op
if ($style->hasSetWrapText()) {
return $style;
}
foreach ($dataRow as $cell) {
if (is_string($cell) && strpos($cell, "\n") !== false) {
$style->setShouldWrapText();
break;
}
}
return $style;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Box\Spout\Writer\Common\Helper;
/**
* Class CellHelper
* This class provides helper functions when working with cells
*
* @package 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,
* Z is the 26th and AA is the 27th.
* The mapping is zero based, so that 0 maps to A, B maps to 1, Z to 25 and AA to 26.
*
* @param int $columnIndex The Excel column index (0, 42, ...)
* @return string The associated cell index ('A', 'BC', ...)
*/
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');
do {
$modulus = $columnIndex % 26;
$cellIndex = chr($capitalAAsciiValue + $modulus) . $cellIndex;
// substracting 1 because it's zero-based
$columnIndex = intval($columnIndex / 26) - 1;
} while ($columnIndex >= 0);
self::$columnIndexToCellIndexCache[$originalColumnIndex] = $cellIndex;
}
return self::$columnIndexToCellIndexCache[$originalColumnIndex];
}
/**
* @param $value
* @return bool Whether the given value is considered "empty"
*/
public static function isEmpty($value)
{
return ($value === null || $value === '');
}
/**
* @param $value
* @return bool Whether the given value is a non empty string
*/
public static function isNonEmptyString($value)
{
return (gettype($value) === 'string' && $value !== '');
}
/**
* Returns whether the given value is numeric.
* A numeric value is from type "integer" or "double" ("float" is not returned by gettype).
*
* @param $value
* @return bool Whether the given value is numeric
*/
public static function isNumeric($value)
{
$valueType = gettype($value);
return ($valueType === 'integer' || $valueType === 'double');
}
/**
* Returns whether the given value is boolean.
* "true"/"false" and 0/1 are not booleans.
*
* @param $value
* @return bool Whether the given value is boolean
*/
public static function isBoolean($value)
{
return gettype($value) === 'boolean';
}
}

View File

@@ -0,0 +1,217 @@
<?php
namespace Box\Spout\Writer\Common\Helper;
/**
* Class ZipHelper
* This class provides helper functions to create zip files
*
* @package Box\Spout\Writer\Common\Helper
*/
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;
/**
* @param string $tmpFolderPath Path of the temp folder where the zip file will be created
*/
public function __construct($tmpFolderPath)
{
$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;
}
/**
* @return string Path where the zip file of the given folder will be created
*/
public function getZipFilePath()
{
return $this->tmpFolderPath . self::ZIP_EXTENSION;
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will be compressed.
*
* 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 addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$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);
}
}
}
/**
* @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
*/
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);
foreach ($itemIterator as $itemInfo) {
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
$zip->addFile($itemRealPath, $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.
*
* @param string $path Path to normalize
* @return string Normalized and canonicalized path
*/
protected function getNormalizedRealPath($path)
{
$realPath = realpath($path);
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 resource $pointer Pointer to the stream to copy the zip
* @return void
*/
protected function copyZipToStream($pointer)
{
$zipFilePointer = fopen($this->getZipFilePath(), 'r');
stream_copy_to_stream($zipFilePointer, $pointer);
fclose($zipFilePointer);
}
}

View File

@@ -0,0 +1,192 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
use Box\Spout\Writer\Exception\SheetNotFoundException;
/**
* Class Workbook
* Represents a workbook within a spreadsheet file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\Common
*/
abstract class AbstractWorkbook implements WorkbookInterface
{
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically;
/** @var string Timestamp based unique ID identifying the workbook */
protected $internalId;
/** @var WorksheetInterface[] Array containing the workbook's sheets */
protected $worksheets = [];
/** @var WorksheetInterface The worksheet where data will be written to */
protected $currentWorksheet;
/**
* @param bool $shouldCreateNewSheetsAutomatically
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function __construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
$this->internalId = uniqid();
}
/**
* @return \Box\Spout\Writer\Common\Helper\AbstractStyleHelper The specific style helper
*/
abstract protected function getStyleHelper();
/**
* @return int Maximum number of rows/columns a sheet can contain
*/
abstract protected function getMaxRowsPerWorksheet();
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
abstract public function addNewSheet();
/**
* Creates a new sheet in the workbook and make it the current sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheetAndMakeItCurrent()
{
$worksheet = $this->addNewSheet();
$this->setCurrentWorksheet($worksheet);
return $worksheet;
}
/**
* @return WorksheetInterface[] All the workbook's sheets
*/
public function getWorksheets()
{
return $this->worksheets;
}
/**
* Returns the current sheet
*
* @return WorksheetInterface The current sheet
*/
public function getCurrentWorksheet()
{
return $this->currentWorksheet;
}
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet)
{
$worksheet = $this->getWorksheetFromExternalSheet($sheet);
if ($worksheet !== null) {
$this->currentWorksheet = $worksheet;
} else {
throw new SheetNotFoundException('The given sheet does not exist in the workbook.');
}
}
/**
* @param WorksheetInterface $worksheet
* @return void
*/
protected function setCurrentWorksheet($worksheet)
{
$this->currentWorksheet = $worksheet;
}
/**
* Returns the worksheet associated to the given external sheet.
*
* @param \Box\Spout\Writer\Common\Sheet $sheet
* @return WorksheetInterface|null The worksheet associated to the given external sheet or null if not found.
*/
protected function getWorksheetFromExternalSheet($sheet)
{
$worksheetFound = null;
foreach ($this->worksheets as $worksheet) {
if ($worksheet->getExternalSheet() === $sheet) {
$worksheetFound = $worksheet;
break;
}
}
return $worksheetFound;
}
/**
* Adds data to the current sheet.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
*/
public function addRowToCurrentWorksheet($dataRow, $style)
{
$currentWorksheet = $this->getCurrentWorksheet();
$hasReachedMaxRows = $this->hasCurrentWorkseetReachedMaxRows();
$styleHelper = $this->getStyleHelper();
// if we reached the maximum number of rows for the current sheet...
if ($hasReachedMaxRows) {
// ... continue writing in a new sheet if option set
if ($this->shouldCreateNewSheetsAutomatically) {
$currentWorksheet = $this->addNewSheetAndMakeItCurrent();
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
$currentWorksheet->addRow($dataRow, $registeredStyle);
} else {
// otherwise, do nothing as the data won't be read anyways
}
} else {
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
$currentWorksheet->addRow($dataRow, $registeredStyle);
}
}
/**
* @return bool Whether the current worksheet has reached the maximum number of rows per sheet.
*/
protected function hasCurrentWorkseetReachedMaxRows()
{
$currentWorksheet = $this->getCurrentWorksheet();
return ($currentWorksheet->getLastWrittenRowIndex() >= $this->getMaxRowsPerWorksheet());
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
abstract public function close($finalFilePointer);
}

View File

@@ -0,0 +1,74 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
/**
* Interface WorkbookInterface
*
* @package Box\Spout\Writer\Common\Internal
*/
interface WorkbookInterface
{
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheet();
/**
* Creates a new sheet in the workbook and make it the current sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheetAndMakeItCurrent();
/**
* @return WorksheetInterface[] All the workbook's sheets
*/
public function getWorksheets();
/**
* Returns the current sheet
*
* @return WorksheetInterface The current sheet
*/
public function getCurrentWorksheet();
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet);
/**
* Adds data to the current sheet.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
*/
public function addRowToCurrentWorksheet($dataRow, $style);
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
public function close($finalFilePointer);
}

View File

@@ -0,0 +1,40 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
/**
* Interface WorksheetInterface
*
* @package Box\Spout\Writer\Common\Internal
*/
interface WorksheetInterface
{
/**
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
*/
public function getExternalSheet();
/**
* @return int The index of the last written row
*/
public function getLastWrittenRowIndex();
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @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
*/
public function addRow($dataRow, $style);
/**
* Closes the worksheet
*
* @return void
*/
public function close();
}

View File

@@ -0,0 +1,183 @@
<?php
namespace Box\Spout\Writer\Common;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Exception\InvalidSheetNameException;
/**
* Class Sheet
* External representation of a worksheet
*
* @package Box\Spout\Writer\Common
*/
class Sheet
{
const DEFAULT_SHEET_NAME_PREFIX = 'Sheet';
/** Sheet name should not exceed 31 characters */
const MAX_LENGTH_SHEET_NAME = 31;
/** @var array Invalid characters that cannot be contained in the sheet name */
private static $INVALID_CHARACTERS_IN_SHEET_NAME = ['\\', '/', '?', '*', ':', '[', ']'];
/** @var array Associative array [WORKBOOK_ID] => [[SHEET_INDEX] => [SHEET_NAME]] keeping track of sheets' name to enforce uniqueness per workbook */
protected static $SHEETS_NAME_USED = [];
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
protected $index;
/** @var string ID of the sheet's associated workbook. Used to restrict sheet name uniqueness enforcement to a single workbook */
protected $associatedWorkbookId;
/** @var string Name of the sheet */
protected $name;
/** @var \Box\Spout\Common\Helper\StringHelper */
protected $stringHelper;
/**
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $associatedWorkbookId ID of the sheet's associated workbook
*/
public function __construct($sheetIndex, $associatedWorkbookId)
{
$this->index = $sheetIndex;
$this->associatedWorkbookId = $associatedWorkbookId;
if (!isset(self::$SHEETS_NAME_USED[$associatedWorkbookId])) {
self::$SHEETS_NAME_USED[$associatedWorkbookId] = [];
}
$this->stringHelper = new StringHelper();
$this->setName(self::DEFAULT_SHEET_NAME_PREFIX . ($sheetIndex + 1));
}
/**
* @api
* @return int Index of the sheet, based on order in the workbook (zero-based)
*/
public function getIndex()
{
return $this->index;
}
/**
* @api
* @return string Name of the sheet
*/
public function getName()
{
return $this->name;
}
/**
* Sets the name of the sheet. Note that Excel has some restrictions on the name:
* - it should not be blank
* - it should not exceed 31 characters
* - it should not contain these characters: \ / ? * : [ or ]
* - it should be unique
*
* @api
* @param string $name Name of the sheet
* @return Sheet
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
*/
public function setName($name)
{
$this->throwIfNameIsInvalid($name);
$this->name = $name;
self::$SHEETS_NAME_USED[$this->associatedWorkbookId][$this->index] = $name;
return $this;
}
/**
* Throws an exception if the given sheet's name is not valid.
* @see Sheet::setName for validity rules.
*
* @param string $name
* @return void
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
*/
protected function throwIfNameIsInvalid($name)
{
if (!is_string($name)) {
$actualType = gettype($name);
$errorMessage = "The sheet's name is invalid. It must be a string ($actualType given).";
throw new InvalidSheetNameException($errorMessage);
}
$failedRequirements = [];
$nameLength = $this->stringHelper->getStringLength($name);
if (!$this->isNameUnique($name)) {
$failedRequirements[] = 'It should be unique';
} else {
if ($nameLength === 0) {
$failedRequirements[] = 'It should not be blank';
} else {
if ($nameLength > self::MAX_LENGTH_SHEET_NAME) {
$failedRequirements[] = 'It should not exceed 31 characters';
}
if ($this->doesContainInvalidCharacters($name)) {
$failedRequirements[] = 'It should not contain these characters: \\ / ? * : [ or ]';
}
if ($this->doesStartOrEndWithSingleQuote($name)) {
$failedRequirements[] = 'It should not start or end with a single quote';
}
}
}
if (count($failedRequirements) !== 0) {
$errorMessage = "The sheet's name (\"$name\") is invalid. It did not respect these rules:\n - ";
$errorMessage .= implode("\n - ", $failedRequirements);
throw new InvalidSheetNameException($errorMessage);
}
}
/**
* Returns whether the given name contains at least one invalid character.
* @see Sheet::$INVALID_CHARACTERS_IN_SHEET_NAME for the full list.
*
* @param string $name
* @return bool TRUE if the name contains invalid characters, FALSE otherwise.
*/
protected function doesContainInvalidCharacters($name)
{
return (str_replace(self::$INVALID_CHARACTERS_IN_SHEET_NAME, '', $name) !== $name);
}
/**
* Returns whether the given name starts or ends with a single quote
*
* @param string $name
* @return bool TRUE if the name starts or ends with a single quote, FALSE otherwise.
*/
protected function doesStartOrEndWithSingleQuote($name)
{
$startsWithSingleQuote = ($this->stringHelper->getCharFirstOccurrencePosition('\'', $name) === 0);
$endsWithSingleQuote = ($this->stringHelper->getCharLastOccurrencePosition('\'', $name) === ($this->stringHelper->getStringLength($name) - 1));
return ($startsWithSingleQuote || $endsWithSingleQuote);
}
/**
* Returns whether the given name is unique.
*
* @param string $name
* @return bool TRUE if the name is unique, FALSE otherwise.
*/
protected function isNameUnique($name)
{
foreach (self::$SHEETS_NAME_USED[$this->associatedWorkbookId] as $sheetIndex => $sheetName) {
if ($sheetIndex !== $this->index && $sheetName === $name) {
return false;
}
}
return true;
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidNameException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid name identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedNames())));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidStyleException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid style identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedStyles())));
}
}

View File

@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidWidthException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid width identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedWidths())));
}
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class InvalidColorException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class InvalidColorException extends WriterException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class InvalidSheetNameException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class InvalidSheetNameException extends WriterException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class SheetNotFoundException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class SheetNotFoundException extends WriterException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class WriterAlreadyOpenedException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class WriterAlreadyOpenedException extends WriterException
{
}

View File

@@ -0,0 +1,15 @@
<?php
namespace Box\Spout\Writer\Exception;
use Box\Spout\Common\Exception\SpoutException;
/**
* Class WriterException
*
* @package Box\Spout\Writer\Exception
* @abstract
*/
abstract class WriterException extends SpoutException
{
}

View File

@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class WriterNotOpenedException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class WriterNotOpenedException extends WriterException
{
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Style\BorderPart;
use Box\Spout\Writer\Style\Border;
/**
* Class BorderHelper
*
* The fo:border, fo:border-top, fo:border-bottom, fo:border-left and fo:border-right attributes
* specify border properties
* http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1419780_253892949
*
* Example table-cell-properties
*
* <style:table-cell-properties
* fo:border-bottom="0.74pt solid #ffc000" style:diagonal-bl-tr="none"
* style:diagonal-tl-br="none" fo:border-left="none" fo:border-right="none"
* style:rotation-align="none" fo:border-top="none"/>
*/
class BorderHelper
{
/**
* Width mappings
*
* @var array
*/
protected static $widthMap = [
Border::WIDTH_THIN => '0.75pt',
Border::WIDTH_MEDIUM => '1.75pt',
Border::WIDTH_THICK => '2.5pt',
];
/**
* Style mapping
*
* @var array
*/
protected static $styleMap = [
Border::STYLE_SOLID => 'solid',
Border::STYLE_DASHED => 'dashed',
Border::STYLE_DOTTED => 'dotted',
Border::STYLE_DOUBLE => 'double',
];
/**
* @param BorderPart $borderPart
* @return string
*/
public static function serializeBorderPart(BorderPart $borderPart)
{
$definition = 'fo:border-%s="%s"';
if ($borderPart->getStyle() === Border::STYLE_NONE) {
$borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none');
} else {
$attributes = [
self::$widthMap[$borderPart->getWidth()],
self::$styleMap[$borderPart->getStyle()],
'#' . $borderPart->getColor(),
];
$borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
}
return $borderPartDefinition;
}
}

View File

@@ -0,0 +1,279 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\ODS\Internal\Worksheet;
/**
* Class FileSystemHelper
* This class provides helper functions to help with the file system operations
* like files/folders creation & deletion for ODS files
*
* @package Box\Spout\Writer\ODS\Helper
*/
class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
{
const APP_NAME = 'Spout';
const MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet';
const META_INF_FOLDER_NAME = 'META-INF';
const SHEETS_CONTENT_TEMP_FOLDER_NAME = 'worksheets-temp';
const MANIFEST_XML_FILE_NAME = 'manifest.xml';
const CONTENT_XML_FILE_NAME = 'content.xml';
const META_XML_FILE_NAME = 'meta.xml';
const MIMETYPE_FILE_NAME = 'mimetype';
const STYLES_XML_FILE_NAME = 'styles.xml';
/** @var string Path to the root folder inside the temp folder where the files to create the ODS will be stored */
protected $rootFolder;
/** @var string Path to the "META-INF" folder inside the root folder */
protected $metaInfFolder;
/** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */
protected $sheetsContentTempFolder;
/**
* @return string
*/
public function getRootFolder()
{
return $this->rootFolder;
}
/**
* @return string
*/
public function getSheetsContentTempFolder()
{
return $this->sheetsContentTempFolder;
}
/**
* Creates all the folders needed to create a ODS file, as well as the files that won't change.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function createBaseFilesAndFolders()
{
$this
->createRootFolder()
->createMetaInfoFolderAndFile()
->createSheetsContentTempFolder()
->createMetaFile()
->createMimetypeFile();
}
/**
* Creates the folder that will be used as root
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createRootFolder()
{
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
return $this;
}
/**
* Creates the "META-INF" folder under the root folder as well as the "manifest.xml" file in it
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or the "manifest.xml" file
*/
protected function createMetaInfoFolderAndFile()
{
$this->metaInfFolder = $this->createFolder($this->rootFolder, self::META_INF_FOLDER_NAME);
$this->createManifestFile();
return $this;
}
/**
* Creates the "manifest.xml" file under the "META-INF" folder (under root)
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createManifestFile()
{
$manifestXmlFileContents = <<<EOD
<?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:manifest>
EOD;
$this->createFileWithContents($this->metaInfFolder, self::MANIFEST_XML_FILE_NAME, $manifestXmlFileContents);
return $this;
}
/**
* Creates the temp folder where specific sheets content will be written to.
* This folder is not part of the final ODS file and is only used to be able to jump between sheets.
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createSheetsContentTempFolder()
{
$this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, self::SHEETS_CONTENT_TEMP_FOLDER_NAME);
return $this;
}
/**
* Creates the "meta.xml" file under the root folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createMetaFile()
{
$appName = self::APP_NAME;
$createdDate = (new \DateTime())->format(\DateTime::W3C);
$metaXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-meta office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<office:meta>
<dc:creator>$appName</dc:creator>
<meta:creation-date>$createdDate</meta:creation-date>
<dc:date>$createdDate</dc:date>
</office:meta>
</office:document-meta>
EOD;
$this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents);
return $this;
}
/**
* Creates the "mimetype" file under the root folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createMimetypeFile()
{
$this->createFileWithContents($this->rootFolder, self::MIMETYPE_FILE_NAME, self::MIMETYPE);
return $this;
}
/**
* Creates the "content.xml" file under the root folder
*
* @param Worksheet[] $worksheets
* @param StyleHelper $styleHelper
* @return FileSystemHelper
*/
public function createContentFile($worksheets, $styleHelper)
{
$contentXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-content office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
EOD;
$contentXmlFileContents .= $styleHelper->getContentXmlFontFaceSectionContent();
$contentXmlFileContents .= $styleHelper->getContentXmlAutomaticStylesSectionContent(count($worksheets));
$contentXmlFileContents .= '<office:body><office:spreadsheet>';
$this->createFileWithContents($this->rootFolder, self::CONTENT_XML_FILE_NAME, $contentXmlFileContents);
// Append sheets content to "content.xml"
$contentXmlFilePath = $this->rootFolder . '/' . self::CONTENT_XML_FILE_NAME;
$contentXmlHandle = fopen($contentXmlFilePath, 'a');
foreach ($worksheets as $worksheet) {
// write the "<table:table>" node, with the final sheet's name
fwrite($contentXmlHandle, $worksheet->getTableElementStartAsString());
$worksheetFilePath = $worksheet->getWorksheetFilePath();
$this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle);
fwrite($contentXmlHandle, '</table:table>');
}
$contentXmlFileContents = '</office:spreadsheet></office:body></office:document-content>';
fwrite($contentXmlHandle, $contentXmlFileContents);
fclose($contentXmlHandle);
return $this;
}
/**
* Streams the content of the file at the given path into the target resource.
* Depending on which mode the target resource was created with, it will truncate then copy
* or append the content to the target file.
*
* @param string $sourceFilePath Path of the file whose content will be copied
* @param resource $targetResource Target resource that will receive the content
* @return void
*/
protected function copyFileContentsToTarget($sourceFilePath, $targetResource)
{
$sourceHandle = fopen($sourceFilePath, 'r');
stream_copy_to_stream($sourceHandle, $targetResource);
fclose($sourceHandle);
}
/**
* Deletes the temporary folder where sheets content was stored.
*
* @return FileSystemHelper
*/
public function deleteWorksheetTempFolder()
{
$this->deleteFolderRecursively($this->sheetsContentTempFolder);
return $this;
}
/**
* Creates the "styles.xml" file under the root folder
*
* @param StyleHelper $styleHelper
* @param int $numWorksheets Number of created worksheets
* @return FileSystemHelper
*/
public function createStylesFile($styleHelper, $numWorksheets)
{
$stylesXmlFileContents = $styleHelper->getStylesXMLFileContent($numWorksheets);
$this->createFileWithContents($this->rootFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
return $this;
}
/**
* Zips the root folder and streams the contents of the zip into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function zipRootFolderAndCopyToStream($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());
}
}

View File

@@ -0,0 +1,356 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
use Box\Spout\Writer\Style\BorderPart;
/**
* Class StyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\ODS\Helper
*/
class StyleHelper extends AbstractStyleHelper
{
/** @var string[] [FONT_NAME] => [] Map whose keys contain all the fonts used */
protected $usedFontsSet = [];
/**
* Registers the given style as a used style.
* Duplicate styles won't be registered more than once.
*
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
*/
public function registerStyle($style)
{
$this->usedFontsSet[$style->getFontName()] = true;
return parent::registerStyle($style);
}
/**
* @return string[] List of used fonts name
*/
protected function getUsedFonts()
{
return array_keys($this->usedFontsSet);
}
/**
* Returns the content of the "styles.xml" file, given a list of styles.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
public function getStylesXMLFileContent($numWorksheets)
{
$content = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-styles office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
EOD;
$content .= $this->getFontFaceSectionContent();
$content .= $this->getStylesSectionContent();
$content .= $this->getAutomaticStylesSectionContent($numWorksheets);
$content .= $this->getMasterStylesSectionContent($numWorksheets);
$content .= <<<EOD
</office:document-styles>
EOD;
return $content;
}
/**
* Returns the content of the "<office:font-face-decls>" section, inside "styles.xml" file.
*
* @return string
*/
protected function getFontFaceSectionContent()
{
$content = '<office:font-face-decls>';
foreach ($this->getUsedFonts() as $fontName) {
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
}
$content .= '</office:font-face-decls>';
return $content;
}
/**
* Returns the content of the "<office:styles>" section, inside "styles.xml" file.
*
* @return string
*/
protected function getStylesSectionContent()
{
$defaultStyle = $this->getDefaultStyle();
return <<<EOD
<office:styles>
<number:number-style style:name="N0">
<number:number number:min-integer-digits="1"/>
</number:number-style>
<style:style style:data-style-name="N0" style:family="table-cell" style:name="Default">
<style:table-cell-properties fo:background-color="transparent" style:vertical-align="automatic"/>
<style:text-properties fo:color="#{$defaultStyle->getFontColor()}"
fo:font-size="{$defaultStyle->getFontSize()}pt" style:font-size-asian="{$defaultStyle->getFontSize()}pt" style:font-size-complex="{$defaultStyle->getFontSize()}pt"
style:font-name="{$defaultStyle->getFontName()}" style:font-name-asian="{$defaultStyle->getFontName()}" style:font-name-complex="{$defaultStyle->getFontName()}"/>
</style:style>
</office:styles>
EOD;
}
/**
* Returns the content of the "<office:automatic-styles>" section, inside "styles.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
protected function getAutomaticStylesSectionContent($numWorksheets)
{
$content = '<office:automatic-styles>';
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:page-layout style:name="pm$i">
<style:page-layout-properties style:first-page-number="continue" style:print="objects charts drawings" style:table-centering="none"/>
<style:header-style/>
<style:footer-style/>
</style:page-layout>
EOD;
}
$content .= '</office:automatic-styles>';
return $content;
}
/**
* Returns the content of the "<office:master-styles>" section, inside "styles.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
protected function getMasterStylesSectionContent($numWorksheets)
{
$content = '<office:master-styles>';
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:master-page style:name="mp$i" style:page-layout-name="pm$i">
<style:header/>
<style:header-left style:display="false"/>
<style:footer/>
<style:footer-left style:display="false"/>
</style:master-page>
EOD;
}
$content .= '</office:master-styles>';
return $content;
}
/**
* Returns the contents of the "<office:font-face-decls>" section, inside "content.xml" file.
*
* @return string
*/
public function getContentXmlFontFaceSectionContent()
{
$content = '<office:font-face-decls>';
foreach ($this->getUsedFonts() as $fontName) {
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
}
$content .= '</office:font-face-decls>';
return $content;
}
/**
* Returns the contents of the "<office:automatic-styles>" section, inside "content.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
public function getContentXmlAutomaticStylesSectionContent($numWorksheets)
{
$content = '<office:automatic-styles>';
foreach ($this->getRegisteredStyles() as $style) {
$content .= $this->getStyleSectionContent($style);
}
$content .= <<<EOD
<style:style style:family="table-column" style:name="co1">
<style:table-column-properties fo:break-before="auto"/>
</style:style>
<style:style style:family="table-row" style:name="ro1">
<style:table-row-properties fo:break-before="auto" style:row-height="15pt" style:use-optimal-row-height="true"/>
</style:style>
EOD;
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:style style:family="table" style:master-page-name="mp$i" style:name="ta$i">
<style:table-properties style:writing-mode="lr-tb" table:display="true"/>
</style:style>
EOD;
}
$content .= '</office:automatic-styles>';
return $content;
}
/**
* Returns the contents of the "<style:style>" section, inside "<office:automatic-styles>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
protected function getStyleSectionContent($style)
{
$styleIndex = $style->getId() + 1; // 1-based
$content = '<style:style style:data-style-name="N0" style:family="table-cell" style:name="ce' . $styleIndex . '" style:parent-style-name="Default">';
$content .= $this->getTextPropertiesSectionContent($style);
$content .= $this->getTableCellPropertiesSectionContent($style);
$content .= '</style:style>';
return $content;
}
/**
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getTextPropertiesSectionContent($style)
{
$content = '';
if ($style->shouldApplyFont()) {
$content .= $this->getFontSectionContent($style);
}
return $content;
}
/**
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getFontSectionContent($style)
{
$defaultStyle = $this->getDefaultStyle();
$content = '<style:text-properties';
$fontColor = $style->getFontColor();
if ($fontColor !== $defaultStyle->getFontColor()) {
$content .= ' fo:color="#' . $fontColor . '"';
}
$fontName = $style->getFontName();
if ($fontName !== $defaultStyle->getFontName()) {
$content .= ' style:font-name="' . $fontName . '" style:font-name-asian="' . $fontName . '" style:font-name-complex="' . $fontName . '"';
}
$fontSize = $style->getFontSize();
if ($fontSize !== $defaultStyle->getFontSize()) {
$content .= ' fo:font-size="' . $fontSize . 'pt" style:font-size-asian="' . $fontSize . 'pt" style:font-size-complex="' . $fontSize . 'pt"';
}
if ($style->isFontBold()) {
$content .= ' fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"';
}
if ($style->isFontItalic()) {
$content .= ' fo:font-style="italic" style:font-style-asian="italic" style:font-style-complex="italic"';
}
if ($style->isFontUnderline()) {
$content .= ' style:text-underline-style="solid" style:text-underline-type="single"';
}
if ($style->isFontStrikethrough()) {
$content .= ' style:text-line-through-style="solid"';
}
$content .= '/>';
return $content;
}
/**
* Returns the contents of the "<style:table-cell-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getTableCellPropertiesSectionContent($style)
{
$content = '';
if ($style->shouldWrapText()) {
$content .= $this->getWrapTextXMLContent();
}
if ($style->shouldApplyBorder()) {
$content .= $this->getBorderXMLContent($style);
}
if ($style->shouldApplyBackgroundColor()) {
$content .= $this->getBackgroundColorXMLContent($style);
}
return $content;
}
/**
* Returns the contents of the wrap text definition for the "<style:table-cell-properties>" section
*
* @return string
*/
private function getWrapTextXMLContent()
{
return '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
}
/**
* Returns the contents of the borders definition for the "<style:table-cell-properties>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getBorderXMLContent($style)
{
$borderProperty = '<style:table-cell-properties %s />';
$borders = array_map(function (BorderPart $borderPart) {
return BorderHelper::serializeBorderPart($borderPart);
}, $style->getBorder()->getParts());
return sprintf($borderProperty, implode(' ', $borders));
}
/**
* Returns the contents of the background color definition for the "<style:table-cell-properties>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getBackgroundColorXMLContent($style)
{
return sprintf(
'<style:table-cell-properties fo:background-color="#%s"/>',
$style->getBackgroundColor()
);
}
}

View File

@@ -0,0 +1,119 @@
<?php
namespace Box\Spout\Writer\ODS\Internal;
use Box\Spout\Writer\Common\Internal\AbstractWorkbook;
use Box\Spout\Writer\ODS\Helper\FileSystemHelper;
use Box\Spout\Writer\ODS\Helper\StyleHelper;
use Box\Spout\Writer\Common\Sheet;
/**
* Class Workbook
* Represents a workbook within a ODS file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\ODS\Internal
*/
class Workbook extends AbstractWorkbook
{
/**
* Maximum number of rows a ODS sheet can contain
* @see https://ask.libreoffice.org/en/question/8631/upper-limit-to-number-of-rows-in-calc/
*/
protected static $maxRowsPerWorksheet = 1048576;
/** @var \Box\Spout\Writer\ODS\Helper\FileSystemHelper Helper to perform file system operations */
protected $fileSystemHelper;
/** @var \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles */
protected $styleHelper;
/**
* @param string $tempFolder
* @param bool $shouldCreateNewSheetsAutomatically
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function __construct($tempFolder, $shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
parent::__construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle);
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
$this->fileSystemHelper->createBaseFilesAndFolders();
$this->styleHelper = new StyleHelper($defaultRowStyle);
}
/**
* @return \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles to ODS files
*/
protected function getStyleHelper()
{
return $this->styleHelper;
}
/**
* @return int Maximum number of rows/columns a sheet can contain
*/
protected function getMaxRowsPerWorksheet()
{
return self::$maxRowsPerWorksheet;
}
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return Worksheet The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheet()
{
$newSheetIndex = count($this->worksheets);
$sheet = new Sheet($newSheetIndex, $this->internalId);
$sheetsContentTempFolder = $this->fileSystemHelper->getSheetsContentTempFolder();
$worksheet = new Worksheet($sheet, $sheetsContentTempFolder);
$this->worksheets[] = $worksheet;
return $worksheet;
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
public function close($finalFilePointer)
{
/** @var Worksheet[] $worksheets */
$worksheets = $this->worksheets;
$numWorksheets = count($worksheets);
foreach ($worksheets as $worksheet) {
$worksheet->close();
}
// Finish creating all the necessary files before zipping everything together
$this->fileSystemHelper
->createContentFile($worksheets, $this->styleHelper)
->deleteWorksheetTempFolder()
->createStylesFile($this->styleHelper, $numWorksheets)
->zipRootFolderAndCopyToStream($finalFilePointer);
$this->cleanupTempFolder();
}
/**
* Deletes the root folder created in the temp folder and all its contents.
*
* @return void
*/
protected function cleanupTempFolder()
{
$xlsxRootFolder = $this->fileSystemHelper->getRootFolder();
$this->fileSystemHelper->deleteFolderRecursively($xlsxRootFolder);
}
}

View File

@@ -0,0 +1,233 @@
<?php
namespace Box\Spout\Writer\ODS\Internal;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Common\Helper\CellHelper;
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
/**
* Class Worksheet
* Represents a worksheet within a ODS file. The difference with the Sheet object is
* that this class provides an interface to write data
*
* @package Box\Spout\Writer\ODS\Internal
*/
class Worksheet implements WorksheetInterface
{
/** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
protected $externalSheet;
/** @var string Path to the XML file that will contain the sheet data */
protected $worksheetFilePath;
/** @var \Box\Spout\Common\Escaper\ODS Strings escaper */
protected $stringsEscaper;
/** @var \Box\Spout\Common\Helper\StringHelper To help with string manipulation */
protected $stringHelper;
/** @var Resource Pointer to the temporary sheet data file (e.g. worksheets-temp/sheet1.xml) */
protected $sheetFilePointer;
/** @var int Maximum number of columns among all the written rows */
protected $maxNumColumns = 1;
/** @var int Index of the last written row */
protected $lastWrittenRowIndex = 0;
/**
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
* @param string $worksheetFilesFolder Temporary folder where the files to create the ODS will be stored
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
public function __construct($externalSheet, $worksheetFilesFolder)
{
$this->externalSheet = $externalSheet;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
$this->stringHelper = new StringHelper();
$this->startSheet();
}
/**
* Prepares the worksheet to accept data
* The XML file does not contain the "<table:table>" node as it contains the sheet's name
* which may change during the execution of the program. It will be added at the end.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function startSheet()
{
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
$this->throwIfSheetFilePointerIsNotAvailable();
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function throwIfSheetFilePointerIsNotAvailable()
{
if (!$this->sheetFilePointer) {
throw new IOException('Unable to open sheet for writing.');
}
}
/**
* @return string Path to the temporary sheet content XML file
*/
public function getWorksheetFilePath()
{
return $this->worksheetFilePath;
}
/**
* Returns the table XML root node as string.
*
* @return string <table> node as string
*/
public function getTableElementStartAsString()
{
$escapedSheetName = $this->stringsEscaper->escape($this->externalSheet->getName());
$tableStyleName = 'ta' . ($this->externalSheet->getIndex() + 1);
$tableElement = '<table:table table:style-name="' . $tableStyleName . '" table:name="' . $escapedSheetName . '">';
$tableElement .= '<table:table-column table:default-cell-style-name="ce1" table:style-name="co1" table:number-columns-repeated="' . $this->maxNumColumns . '"/>';
return $tableElement;
}
/**
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
*/
public function getExternalSheet()
{
return $this->externalSheet;
}
/**
* @return int The index of the last written row
*/
public function getLastWrittenRowIndex()
{
return $this->lastWrittenRowIndex;
}
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @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
*/
public function addRow($dataRow, $style)
{
// $dataRow can be an associative array. We need to transform
// it into a regular array, as we'll use the numeric indexes.
$dataRowWithNumericIndexes = array_values($dataRow);
$styleIndex = ($style->getId() + 1); // 1-based
$cellsCount = count($dataRow);
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
$data = '<table:table-row table:style-name="ro1">';
$currentCellIndex = 0;
$nextCellIndex = 1;
for ($i = 0; $i < $cellsCount; $i++) {
$currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
// Using isset here because it is way faster than array_key_exists...
if (!isset($dataRowWithNumericIndexes[$nextCellIndex]) ||
$currentCellValue !== $dataRowWithNumericIndexes[$nextCellIndex]) {
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
$data .= $this->getCellXML($currentCellValue, $styleIndex, $numTimesValueRepeated);
$currentCellIndex = $nextCellIndex;
}
$nextCellIndex++;
}
$data .= '</table:table-row>';
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $data);
if ($wasWriteSuccessful === false) {
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
}
// only update the count if the write worked
$this->lastWrittenRowIndex++;
}
/**
* Returns the cell XML content, given its value.
*
* @param mixed $cellValue The value to be written
* @param int $styleIndex Index of the used style
* @param int $numTimesValueRepeated Number of times the value is consecutively repeated
* @return string The cell XML content
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
*/
protected function getCellXML($cellValue, $styleIndex, $numTimesValueRepeated)
{
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
if ($numTimesValueRepeated !== 1) {
$data .= ' table:number-columns-repeated="' . $numTimesValueRepeated . '"';
}
if (CellHelper::isNonEmptyString($cellValue)) {
$data .= ' office:value-type="string" calcext:value-type="string">';
$cellValueLines = explode("\n", $cellValue);
foreach ($cellValueLines as $cellValueLine) {
$data .= '<text:p>' . $this->stringsEscaper->escape($cellValueLine) . '</text:p>';
}
$data .= '</table:table-cell>';
} else if (CellHelper::isBoolean($cellValue)) {
$data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="' . $cellValue . '">';
$data .= '<text:p>' . $cellValue . '</text:p>';
$data .= '</table:table-cell>';
} else if (CellHelper::isNumeric($cellValue)) {
$data .= ' office:value-type="float" calcext:value-type="float" office:value="' . $cellValue . '">';
$data .= '<text:p>' . $cellValue . '</text:p>';
$data .= '</table:table-cell>';
} else if (empty($cellValue)) {
$data .= '/>';
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
return $data;
}
/**
* Closes the worksheet
*
* @return void
*/
public function close()
{
if (!is_resource($this->sheetFilePointer)) {
return;
}
fclose($this->sheetFilePointer);
}
}

View File

@@ -0,0 +1,93 @@
<?php
namespace Box\Spout\Writer\ODS;
use Box\Spout\Writer\AbstractMultiSheetsWriter;
use Box\Spout\Writer\Common;
use Box\Spout\Writer\ODS\Internal\Workbook;
/**
* Class Writer
* This class provides base support to write data to ODS files
*
* @package Box\Spout\Writer\ODS
*/
class Writer extends AbstractMultiSheetsWriter
{
/** @var string Content-Type value for the header */
protected static $headerContentType = 'application/vnd.oasis.opendocument.spreadsheet';
/** @var string Temporary folder where the files to create the ODS will be stored */
protected $tempFolder;
/** @var Internal\Workbook The workbook for the ODS file */
protected $book;
/**
* Sets a custom temporary folder for creating intermediate files/folders.
* This must be set before opening the writer.
*
* @api
* @param string $tempFolder Temporary folder where the files to create the ODS will be stored
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setTempFolder($tempFolder)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->tempFolder = $tempFolder;
return $this;
}
/**
* Configures the write and sets the current sheet pointer to a new sheet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to open the file for writing
*/
protected function openWriter()
{
$tempFolder = ($this->tempFolder) ? : sys_get_temp_dir();
$this->book = new Workbook($tempFolder, $this->shouldCreateNewSheetsAutomatically, $this->defaultRowStyle);
$this->book->addNewSheetAndMakeItCurrent();
}
/**
* @return Internal\Workbook The workbook representing the file to be written
*/
protected function getWorkbook()
{
return $this->book;
}
/**
* Adds data to the currently opened writer.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
protected function addRowToWriter(array $dataRow, $style)
{
$this->throwIfBookIsNotAvailable();
$this->book->addRowToCurrentWorksheet($dataRow, $style);
}
/**
* Closes the writer, preventing any additional writing.
*
* @return void
*/
protected function closeWriter()
{
if ($this->book) {
$this->book->close($this->filePointer);
}
}
}

View File

@@ -0,0 +1,85 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class Border
*/
class Border
{
const LEFT = 'left';
const RIGHT = 'right';
const TOP = 'top';
const BOTTOM = 'bottom';
const STYLE_NONE = 'none';
const STYLE_SOLID = 'solid';
const STYLE_DASHED = 'dashed';
const STYLE_DOTTED = 'dotted';
const STYLE_DOUBLE = 'double';
const WIDTH_THIN = 'thin';
const WIDTH_MEDIUM = 'medium';
const WIDTH_THICK = 'thick';
/**
* @var array A list of BorderPart objects for this border.
*/
protected $parts = [];
/**
* @param array|void $borderParts
*/
public function __construct(array $borderParts = [])
{
$this->setParts($borderParts);
}
/**
* @param $name The name of the border part
* @return null|BorderPart
*/
public function getPart($name)
{
return $this->hasPart($name) ? $this->parts[$name] : null;
}
/**
* @param $name The name of the border part
* @return bool
*/
public function hasPart($name)
{
return isset($this->parts[$name]);
}
/**
* @return array
*/
public function getParts()
{
return $this->parts;
}
/**
* Set BorderParts
* @param array $parts
*/
public function setParts($parts)
{
unset($this->parts);
foreach ($parts as $part) {
$this->addPart($part);
}
}
/**
* @param BorderPart $borderPart
* @return self
*/
public function addPart(BorderPart $borderPart)
{
$this->parts[$borderPart->getName()] = $borderPart;
return $this;
}
}

View File

@@ -0,0 +1,75 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class BorderBuilder
*/
class BorderBuilder
{
/**
* @var Border
*/
protected $border;
public function __construct()
{
$this->border = new Border();
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style));
return $this;
}
/**
* @return Border
*/
public function build()
{
return $this->border;
}
}

View File

@@ -0,0 +1,184 @@
<?php
namespace Box\Spout\Writer\Style;
use Box\Spout\Writer\Exception\Border\InvalidNameException;
use Box\Spout\Writer\Exception\Border\InvalidStyleException;
use Box\Spout\Writer\Exception\Border\InvalidWidthException;
/**
* Class BorderPart
*/
class BorderPart
{
/**
* @var string The style of this border part.
*/
protected $style;
/**
* @var string The name of this border part.
*/
protected $name;
/**
* @var string The color of this border part.
*/
protected $color;
/**
* @var string The width of this border part.
*/
protected $width;
/**
* @var array Allowed style constants for parts.
*/
protected static $allowedStyles = [
'none',
'solid',
'dashed',
'dotted',
'double'
];
/**
* @var array Allowed names constants for border parts.
*/
protected static $allowedNames = [
'left',
'right',
'top',
'bottom',
];
/**
* @var array Allowed width constants for border parts.
*/
protected static $allowedWidths = [
'thin',
'medium',
'thick',
];
/**
* @param string $name @see BorderPart::$allowedNames
* @param string $color A RGB color code
* @param string $width @see BorderPart::$allowedWidths
* @param string $style @see BorderPart::$allowedStyles
* @throws InvalidNameException
* @throws InvalidStyleException
* @throws InvalidWidthException
*/
public function __construct($name, $color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->setName($name);
$this->setColor($color);
$this->setWidth($width);
$this->setStyle($style);
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $name The name of the border part @see BorderPart::$allowedNames
* @throws InvalidNameException
* @return void
*/
public function setName($name)
{
if (!in_array($name, self::$allowedNames)) {
throw new InvalidNameException($name);
}
$this->name = $name;
}
/**
* @return string
*/
public function getStyle()
{
return $this->style;
}
/**
* @param string $style The style of the border part @see BorderPart::$allowedStyles
* @throws InvalidStyleException
* @return void
*/
public function setStyle($style)
{
if (!in_array($style, self::$allowedStyles)) {
throw new InvalidStyleException($style);
}
$this->style = $style;
}
/**
* @return string
*/
public function getColor()
{
return $this->color;
}
/**
* @param string $color The color of the border part @see Color::rgb()
* @return void
*/
public function setColor($color)
{
$this->color = $color;
}
/**
* @return string
*/
public function getWidth()
{
return $this->width;
}
/**
* @param string $width The width of the border part @see BorderPart::$allowedWidths
* @throws InvalidWidthException
* @return void
*/
public function setWidth($width)
{
if (!in_array($width, self::$allowedWidths)) {
throw new InvalidWidthException($width);
}
$this->width = $width;
}
/**
* @return array
*/
public static function getAllowedStyles()
{
return self::$allowedStyles;
}
/**
* @return array
*/
public static function getAllowedNames()
{
return self::$allowedNames;
}
/**
* @return array
*/
public static function getAllowedWidths()
{
return self::$allowedWidths;
}
}

View File

@@ -0,0 +1,87 @@
<?php
namespace Box\Spout\Writer\Style;
use Box\Spout\Writer\Exception\InvalidColorException;
/**
* Class Color
* This class provides constants and functions to work with colors
*
* @package Box\Spout\Writer\Style
*/
class Color
{
/** Standard colors - based on Office Online */
const BLACK = '000000';
const WHITE = 'FFFFFF';
const RED = 'FF0000';
const DARK_RED = 'C00000';
const ORANGE = 'FFC000';
const YELLOW = 'FFFF00';
const LIGHT_GREEN = '92D040';
const GREEN = '00B050';
const LIGHT_BLUE = '00B0E0';
const BLUE = '0070C0';
const DARK_BLUE = '002060';
const PURPLE = '7030A0';
/**
* Returns an RGB color from R, G and B values
*
* @api
* @param int $red Red component, 0 - 255
* @param int $green Green component, 0 - 255
* @param int $blue Blue component, 0 - 255
* @return string RGB color
*/
public static function rgb($red, $green, $blue)
{
self::throwIfInvalidColorComponentValue($red);
self::throwIfInvalidColorComponentValue($green);
self::throwIfInvalidColorComponentValue($blue);
return strtoupper(
self::convertColorComponentToHex($red) .
self::convertColorComponentToHex($green) .
self::convertColorComponentToHex($blue)
);
}
/**
* Throws an exception is the color component value is outside of bounds (0 - 255)
*
* @param int $colorComponent
* @return void
* @throws \Box\Spout\Writer\Exception\InvalidColorException
*/
protected static function throwIfInvalidColorComponentValue($colorComponent)
{
if (!is_int($colorComponent) || $colorComponent < 0 || $colorComponent > 255) {
throw new InvalidColorException("The RGB components must be between 0 and 255. Received: $colorComponent");
}
}
/**
* Converts the color component to its corresponding hexadecimal value
*
* @param int $colorComponent Color component, 0 - 255
* @return string Corresponding hexadecimal value, with a leading 0 if needed. E.g "0f", "2d"
*/
protected static function convertColorComponentToHex($colorComponent)
{
return str_pad(dechex($colorComponent), 2, '0', STR_PAD_LEFT);
}
/**
* Returns the ARGB color of the given RGB color,
* assuming that alpha value is always 1.
*
* @param string $rgbColor RGB color like "FF08B2"
* @return string ARGB color
*/
public static function toARGB($rgbColor)
{
return 'FF' . $rgbColor;
}
}

View File

@@ -0,0 +1,426 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class Style
* Represents a style to be applied to a cell
*
* @package Box\Spout\Writer\Style
*/
class Style
{
/** Default font values */
const DEFAULT_FONT_SIZE = 11;
const DEFAULT_FONT_COLOR = Color::BLACK;
const DEFAULT_FONT_NAME = 'Arial';
/** @var int|null Style ID */
protected $id = null;
/** @var bool Whether the font should be bold */
protected $fontBold = false;
/** @var bool Whether the bold property was set */
protected $hasSetFontBold = false;
/** @var bool Whether the font should be italic */
protected $fontItalic = false;
/** @var bool Whether the italic property was set */
protected $hasSetFontItalic = false;
/** @var bool Whether the font should be underlined */
protected $fontUnderline = false;
/** @var bool Whether the underline property was set */
protected $hasSetFontUnderline = false;
/** @var bool Whether the font should be struck through */
protected $fontStrikethrough = false;
/** @var bool Whether the strikethrough property was set */
protected $hasSetFontStrikethrough = false;
/** @var int Font size */
protected $fontSize = self::DEFAULT_FONT_SIZE;
/** @var bool Whether the font size property was set */
protected $hasSetFontSize = false;
/** @var string Font color */
protected $fontColor = self::DEFAULT_FONT_COLOR;
/** @var bool Whether the font color property was set */
protected $hasSetFontColor = false;
/** @var string Font name */
protected $fontName = self::DEFAULT_FONT_NAME;
/** @var bool Whether the font name property was set */
protected $hasSetFontName = false;
/** @var bool Whether specific font properties should be applied */
protected $shouldApplyFont = false;
/** @var bool Whether the text should wrap in the cell (useful for long or multi-lines text) */
protected $shouldWrapText = false;
/** @var bool Whether the wrap text property was set */
protected $hasSetWrapText = false;
/**
* @var Border
*/
protected $border = null;
/**
* @var bool Whether border properties should be applied
*/
protected $shouldApplyBorder = false;
/** @var string Background color */
protected $backgroundColor = null;
/** @var bool */
protected $hasSetBackgroundColor = false;
/**
* @return int|null
*/
public function getId()
{
return $this->id;
}
/**
* @param int $id
* @return Style
*/
public function setId($id)
{
$this->id = $id;
return $this;
}
/**
* @return Border
*/
public function getBorder()
{
return $this->border;
}
/**
* @param Border $border
* @return Style
*/
public function setBorder(Border $border)
{
$this->shouldApplyBorder = true;
$this->border = $border;
return $this;
}
/**
* @return bool
*/
public function shouldApplyBorder()
{
return $this->shouldApplyBorder;
}
/**
* @return bool
*/
public function isFontBold()
{
return $this->fontBold;
}
/**
* @return Style
*/
public function setFontBold()
{
$this->fontBold = true;
$this->hasSetFontBold = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return bool
*/
public function isFontItalic()
{
return $this->fontItalic;
}
/**
* @return Style
*/
public function setFontItalic()
{
$this->fontItalic = true;
$this->hasSetFontItalic = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return bool
*/
public function isFontUnderline()
{
return $this->fontUnderline;
}
/**
* @return Style
*/
public function setFontUnderline()
{
$this->fontUnderline = true;
$this->hasSetFontUnderline = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return bool
*/
public function isFontStrikethrough()
{
return $this->fontStrikethrough;
}
/**
* @return Style
*/
public function setFontStrikethrough()
{
$this->fontStrikethrough = true;
$this->hasSetFontStrikethrough = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return int
*/
public function getFontSize()
{
return $this->fontSize;
}
/**
* @param int $fontSize Font size, in pixels
* @return Style
*/
public function setFontSize($fontSize)
{
$this->fontSize = $fontSize;
$this->hasSetFontSize = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return string
*/
public function getFontColor()
{
return $this->fontColor;
}
/**
* Sets the font color.
*
* @param string $fontColor ARGB color (@see Color)
* @return Style
*/
public function setFontColor($fontColor)
{
$this->fontColor = $fontColor;
$this->hasSetFontColor = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return string
*/
public function getFontName()
{
return $this->fontName;
}
/**
* @param string $fontName Name of the font to use
* @return Style
*/
public function setFontName($fontName)
{
$this->fontName = $fontName;
$this->hasSetFontName = true;
$this->shouldApplyFont = true;
return $this;
}
/**
* @return bool
*/
public function shouldWrapText()
{
return $this->shouldWrapText;
}
/**
* @param bool|void $shouldWrap Should the text be wrapped
* @return Style
*/
public function setShouldWrapText($shouldWrap = true)
{
$this->shouldWrapText = $shouldWrap;
$this->hasSetWrapText = true;
return $this;
}
/**
* @return bool
*/
public function hasSetWrapText()
{
return $this->hasSetWrapText;
}
/**
* @return bool Whether specific font properties should be applied
*/
public function shouldApplyFont()
{
return $this->shouldApplyFont;
}
/**
* Sets the background color
* @param string $color ARGB color (@see Color)
* @return Style
*/
public function setBackgroundColor($color)
{
$this->hasSetBackgroundColor = true;
$this->backgroundColor = $color;
return $this;
}
/**
* @return string
*/
public function getBackgroundColor()
{
return $this->backgroundColor;
}
/**
*
* @return bool Whether the background color should be applied
*/
public function shouldApplyBackgroundColor()
{
return $this->hasSetBackgroundColor;
}
/**
* Serializes the style for future comparison with other styles.
* The ID is excluded from the comparison, as we only care about
* actual style properties.
*
* @return string The serialized style
*/
public function serialize()
{
// In order to be able to properly compare style, set static ID value
$currentId = $this->id;
$this->setId(0);
$serializedStyle = serialize($this);
$this->setId($currentId);
return $serializedStyle;
}
/**
* Merges the current style with the given style, using the given style as a base. This means that:
* - if current style and base style both have property A set, use current style property's value
* - if current style has property A set but base style does not, use current style property's value
* - if base style has property A set but current style does not, use base style property's value
*
* @NOTE: This function returns a new style.
*
* @param Style $baseStyle
* @return Style New style corresponding to the merge of the 2 styles
*/
public function mergeWith($baseStyle)
{
$mergedStyle = clone $this;
$this->mergeFontStyles($mergedStyle, $baseStyle);
$this->mergeOtherFontProperties($mergedStyle, $baseStyle);
$this->mergeCellProperties($mergedStyle, $baseStyle);
return $mergedStyle;
}
/**
* @param Style $styleToUpdate (passed as reference)
* @param Style $baseStyle
* @return void
*/
private function mergeFontStyles($styleToUpdate, $baseStyle)
{
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
$styleToUpdate->setFontBold();
}
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
$styleToUpdate->setFontItalic();
}
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
$styleToUpdate->setFontUnderline();
}
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
$styleToUpdate->setFontStrikethrough();
}
}
/**
* @param Style $styleToUpdate Style to update (passed as reference)
* @param Style $baseStyle
* @return void
*/
private function mergeOtherFontProperties($styleToUpdate, $baseStyle)
{
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
$styleToUpdate->setFontSize($baseStyle->getFontSize());
}
if (!$this->hasSetFontColor && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
$styleToUpdate->setFontColor($baseStyle->getFontColor());
}
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
$styleToUpdate->setFontName($baseStyle->getFontName());
}
}
/**
* @param Style $styleToUpdate Style to update (passed as reference)
* @param Style $baseStyle
* @return void
*/
private function mergeCellProperties($styleToUpdate, $baseStyle)
{
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
$styleToUpdate->setShouldWrapText();
}
if (!$this->getBorder() && $baseStyle->shouldApplyBorder()) {
$styleToUpdate->setBorder($baseStyle->getBorder());
}
if (!$this->hasSetBackgroundColor && $baseStyle->shouldApplyBackgroundColor()) {
$styleToUpdate->setBackgroundColor($baseStyle->getBackgroundColor());
}
}
}

View File

@@ -0,0 +1,159 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class StyleBuilder
* Builder to create new styles
*
* @package Box\Spout\Writer\Style
*/
class StyleBuilder
{
/** @var Style Style to be created */
protected $style;
/**
*
*/
public function __construct()
{
$this->style = new Style();
}
/**
* Makes the font bold.
*
* @api
* @return StyleBuilder
*/
public function setFontBold()
{
$this->style->setFontBold();
return $this;
}
/**
* Makes the font italic.
*
* @api
* @return StyleBuilder
*/
public function setFontItalic()
{
$this->style->setFontItalic();
return $this;
}
/**
* Makes the font underlined.
*
* @api
* @return StyleBuilder
*/
public function setFontUnderline()
{
$this->style->setFontUnderline();
return $this;
}
/**
* Makes the font struck through.
*
* @api
* @return StyleBuilder
*/
public function setFontStrikethrough()
{
$this->style->setFontStrikethrough();
return $this;
}
/**
* Sets the font size.
*
* @api
* @param int $fontSize Font size, in pixels
* @return StyleBuilder
*/
public function setFontSize($fontSize)
{
$this->style->setFontSize($fontSize);
return $this;
}
/**
* Sets the font color.
*
* @api
* @param string $fontColor ARGB color (@see Color)
* @return StyleBuilder
*/
public function setFontColor($fontColor)
{
$this->style->setFontColor($fontColor);
return $this;
}
/**
* Sets the font name.
*
* @api
* @param string $fontName Name of the font to use
* @return StyleBuilder
*/
public function setFontName($fontName)
{
$this->style->setFontName($fontName);
return $this;
}
/**
* Makes the text wrap in the cell if requested
*
* @api
* @param bool $shouldWrap Should the text be wrapped
* @return StyleBuilder
*/
public function setShouldWrapText($shouldWrap = true)
{
$this->style->setShouldWrapText($shouldWrap);
return $this;
}
/**
* Set a border
*
* @param Border $border
* @return $this
*/
public function setBorder(Border $border)
{
$this->style->setBorder($border);
return $this;
}
/**
* Sets a background color
*
* @api
* @param string $color ARGB color (@see Color)
* @return StyleBuilder
*/
public function setBackgroundColor($color)
{
$this->style->setBackgroundColor($color);
return $this;
}
/**
* Returns the configured style. The style is cached and can be reused.
*
* @api
* @return Style
*/
public function build()
{
return $this->style;
}
}

View File

@@ -0,0 +1,48 @@
<?php
namespace Box\Spout\Writer;
use Box\Spout\Common\Exception\UnsupportedTypeException;
use Box\Spout\Common\Helper\GlobalFunctionsHelper;
use Box\Spout\Common\Type;
/**
* Class WriterFactory
* This factory is used to create writers, based on the type of the file to be read.
* It supports CSV, XLSX and ODS formats.
*
* @package Box\Spout\Writer
*/
class WriterFactory
{
/**
* This creates an instance of the appropriate writer, given the type of the file to be read
*
* @api
* @param string $writerType Type of the writer to instantiate
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\UnsupportedTypeException
*/
public static function create($writerType)
{
$writer = null;
switch ($writerType) {
case Type::CSV:
$writer = new CSV\Writer();
break;
case Type::XLSX:
$writer = new XLSX\Writer();
break;
case Type::ODS:
$writer = new ODS\Writer();
break;
default:
throw new UnsupportedTypeException('No writers supporting the given type: ' . $writerType);
}
$writer->setGlobalFunctionsHelper(new GlobalFunctionsHelper());
return $writer;
}
}

View File

@@ -0,0 +1,91 @@
<?php
namespace Box\Spout\Writer;
/**
* Interface WriterInterface
*
* @package Box\Spout\Writer
*/
interface WriterInterface
{
/**
* Inits the writer and opens it to accept data.
* By using this method, the data will be written to a file.
*
* @param string $outputFilePath Path of the output file that will contain the data
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened or if the given path is not writable
*/
public function openToFile($outputFilePath);
/**
* Inits the writer and opens it to accept data.
* By using this method, the data will be outputted directly to the browser.
*
* @param string $outputFileName Name of the output file that will contain the data. If a path is passed in, only the file name will be kept
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened
*/
public function openToBrowser($outputFileName);
/**
* Write given data to the output. New data will be appended to end of stream.
*
* @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 yet
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRow(array $dataRow);
/**
* Write given data to the output and apply the given style.
* @see addRow
*
* @param array $dataRow Array of array containing data to be streamed.
* @param Style\Style $style Style to be applied to the row.
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRowWithStyle(array $dataRow, $style);
/**
* Write given data to the output. New data will be appended to end of stream.
*
* @param array $dataRows Array of array containing data to be streamed.
* Example $dataRow = [
* ['data11', 12, , '', 'data13'],
* ['data21', 'data22', null],
* ];
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @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 addRows(array $dataRows);
/**
* Write given data to the output and apply the given style.
* @see addRows
*
* @param array $dataRows Array of array containing data to be streamed.
* @param Style\Style $style Style to be applied to the rows.
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
public function addRowsWithStyle(array $dataRows, $style);
/**
* Closes the writer. This will close the streamer as well, preventing new data
* to be written to the file.
*
* @return void
*/
public function close();
}

View File

@@ -0,0 +1,68 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderPart;
class BorderHelper
{
public static $xlsxStyleMap = [
Border::STYLE_SOLID => [
Border::WIDTH_THIN => 'thin',
Border::WIDTH_MEDIUM => 'medium',
Border::WIDTH_THICK => 'thick'
],
Border::STYLE_DOTTED => [
Border::WIDTH_THIN => 'dotted',
Border::WIDTH_MEDIUM => 'dotted',
Border::WIDTH_THICK => 'dotted',
],
Border::STYLE_DASHED => [
Border::WIDTH_THIN => 'dashed',
Border::WIDTH_MEDIUM => 'mediumDashed',
Border::WIDTH_THICK => 'mediumDashed',
],
Border::STYLE_DOUBLE => [
Border::WIDTH_THIN => 'double',
Border::WIDTH_MEDIUM => 'double',
Border::WIDTH_THICK => 'double',
],
Border::STYLE_NONE => [
Border::WIDTH_THIN => 'none',
Border::WIDTH_MEDIUM => 'none',
Border::WIDTH_THICK => 'none',
],
];
/**
* @param BorderPart $borderPart
* @return string
*/
public static function serializeBorderPart(BorderPart $borderPart)
{
$borderStyle = self::getBorderStyle($borderPart);
$colorEl = $borderPart->getColor() ? sprintf('<color rgb="%s"/>', $borderPart->getColor()) : '';
$partEl = sprintf(
'<%s style="%s">%s</%s>',
$borderPart->getName(),
$borderStyle,
$colorEl,
$borderPart->getName()
);
return $partEl . PHP_EOL;
}
/**
* Get the style definition from the style map
*
* @param BorderPart $borderPart
* @return string
*/
protected static function getBorderStyle(BorderPart $borderPart)
{
return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()];
}
}

View File

@@ -0,0 +1,371 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\XLSX\Internal\Worksheet;
/**
* Class FileSystemHelper
* This class provides helper functions to help with the file system operations
* like files/folders creation & deletion for XLSX files
*
* @package Box\Spout\Writer\XLSX\Helper
*/
class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
{
const APP_NAME = 'Spout';
const RELS_FOLDER_NAME = '_rels';
const DOC_PROPS_FOLDER_NAME = 'docProps';
const XL_FOLDER_NAME = 'xl';
const WORKSHEETS_FOLDER_NAME = 'worksheets';
const RELS_FILE_NAME = '.rels';
const APP_XML_FILE_NAME = 'app.xml';
const CORE_XML_FILE_NAME = 'core.xml';
const CONTENT_TYPES_XML_FILE_NAME = '[Content_Types].xml';
const WORKBOOK_XML_FILE_NAME = 'workbook.xml';
const WORKBOOK_RELS_XML_FILE_NAME = 'workbook.xml.rels';
const STYLES_XML_FILE_NAME = 'styles.xml';
/** @var string Path to the root folder inside the temp folder where the files to create the XLSX will be stored */
protected $rootFolder;
/** @var string Path to the "_rels" folder inside the root folder */
protected $relsFolder;
/** @var string Path to the "docProps" folder inside the root folder */
protected $docPropsFolder;
/** @var string Path to the "xl" folder inside the root folder */
protected $xlFolder;
/** @var string Path to the "_rels" folder inside the "xl" folder */
protected $xlRelsFolder;
/** @var string Path to the "worksheets" folder inside the "xl" folder */
protected $xlWorksheetsFolder;
/**
* @return string
*/
public function getRootFolder()
{
return $this->rootFolder;
}
/**
* @return string
*/
public function getXlFolder()
{
return $this->xlFolder;
}
/**
* @return string
*/
public function getXlWorksheetsFolder()
{
return $this->xlWorksheetsFolder;
}
/**
* Creates all the folders needed to create a XLSX file, as well as the files that won't change.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function createBaseFilesAndFolders()
{
$this
->createRootFolder()
->createRelsFolderAndFile()
->createDocPropsFolderAndFiles()
->createXlFolderAndSubFolders();
}
/**
* Creates the folder that will be used as root
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createRootFolder()
{
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
return $this;
}
/**
* Creates the "_rels" folder under the root folder as well as the ".rels" file in it
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or the ".rels" file
*/
protected function createRelsFolderAndFile()
{
$this->relsFolder = $this->createFolder($this->rootFolder, self::RELS_FOLDER_NAME);
$this->createRelsFile();
return $this;
}
/**
* Creates the ".rels" file under the "_rels" folder (under root)
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createRelsFile()
{
$relsFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdWorkbook" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
<Relationship Id="rIdCore" Type="http://schemas.openxmlformats.org/officedocument/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>
<Relationship Id="rIdApp" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>
</Relationships>
EOD;
$this->createFileWithContents($this->relsFolder, self::RELS_FILE_NAME, $relsFileContents);
return $this;
}
/**
* Creates the "docProps" folder under the root folder as well as the "app.xml" and "core.xml" files in it
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or one of the files
*/
protected function createDocPropsFolderAndFiles()
{
$this->docPropsFolder = $this->createFolder($this->rootFolder, self::DOC_PROPS_FOLDER_NAME);
$this->createAppXmlFile();
$this->createCoreXmlFile();
return $this;
}
/**
* Creates the "app.xml" file under the "docProps" folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createAppXmlFile()
{
$appName = self::APP_NAME;
$appXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties">
<Application>$appName</Application>
<TotalTime>0</TotalTime>
</Properties>
EOD;
$this->createFileWithContents($this->docPropsFolder, self::APP_XML_FILE_NAME, $appXmlFileContents);
return $this;
}
/**
* Creates the "core.xml" file under the "docProps" folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createCoreXmlFile()
{
$createdDate = (new \DateTime())->format(\DateTime::W3C);
$coreXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcmitype="http://purl.org/dc/dcmitype/" xmlns:dcterms="http://purl.org/dc/terms/" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<dcterms:created xsi:type="dcterms:W3CDTF">$createdDate</dcterms:created>
<dcterms:modified xsi:type="dcterms:W3CDTF">$createdDate</dcterms:modified>
<cp:revision>0</cp:revision>
</cp:coreProperties>
EOD;
$this->createFileWithContents($this->docPropsFolder, self::CORE_XML_FILE_NAME, $coreXmlFileContents);
return $this;
}
/**
* Creates the "xl" folder under the root folder as well as its subfolders
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the folders
*/
protected function createXlFolderAndSubFolders()
{
$this->xlFolder = $this->createFolder($this->rootFolder, self::XL_FOLDER_NAME);
$this->createXlRelsFolder();
$this->createXlWorksheetsFolder();
return $this;
}
/**
* Creates the "_rels" folder under the "xl" folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createXlRelsFolder()
{
$this->xlRelsFolder = $this->createFolder($this->xlFolder, self::RELS_FOLDER_NAME);
return $this;
}
/**
* Creates the "worksheets" folder under the "xl" folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createXlWorksheetsFolder()
{
$this->xlWorksheetsFolder = $this->createFolder($this->xlFolder, self::WORKSHEETS_FOLDER_NAME);
return $this;
}
/**
* Creates the "[Content_Types].xml" file under the root folder
*
* @param Worksheet[] $worksheets
* @return FileSystemHelper
*/
public function createContentTypesFile($worksheets)
{
$contentTypesXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default ContentType="application/xml" Extension="xml"/>
<Default ContentType="application/vnd.openxmlformats-package.relationships+xml" Extension="rels"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" PartName="/xl/workbook.xml"/>
EOD;
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$contentTypesXmlFileContents .= '<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" PartName="/xl/worksheets/sheet' . $worksheet->getId() . '.xml"/>';
}
$contentTypesXmlFileContents .= <<<EOD
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml" PartName="/xl/styles.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml" PartName="/xl/sharedStrings.xml"/>
<Override ContentType="application/vnd.openxmlformats-package.core-properties+xml" PartName="/docProps/core.xml"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml" PartName="/docProps/app.xml"/>
</Types>
EOD;
$this->createFileWithContents($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME, $contentTypesXmlFileContents);
return $this;
}
/**
* Creates the "workbook.xml" file under the "xl" folder
*
* @param Worksheet[] $worksheets
* @return FileSystemHelper
*/
public function createWorkbookFile($worksheets)
{
$workbookXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
EOD;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$worksheetName = $worksheet->getExternalSheet()->getName();
$worksheetId = $worksheet->getId();
$workbookXmlFileContents .= '<sheet name="' . $escaper->escape($worksheetName) . '" sheetId="' . $worksheetId . '" r:id="rIdSheet' . $worksheetId . '"/>';
}
$workbookXmlFileContents .= <<<EOD
</sheets>
</workbook>
EOD;
$this->createFileWithContents($this->xlFolder, self::WORKBOOK_XML_FILE_NAME, $workbookXmlFileContents);
return $this;
}
/**
* Creates the "workbook.xml.res" file under the "xl/_res" folder
*
* @param Worksheet[] $worksheets
* @return FileSystemHelper
*/
public function createWorkbookRelsFile($worksheets)
{
$workbookRelsXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdStyles" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
<Relationship Id="rIdSharedStrings" Target="sharedStrings.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"/>
EOD;
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$worksheetId = $worksheet->getId();
$workbookRelsXmlFileContents .= '<Relationship Id="rIdSheet' . $worksheetId . '" Target="worksheets/sheet' . $worksheetId . '.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"/>';
}
$workbookRelsXmlFileContents .= '</Relationships>';
$this->createFileWithContents($this->xlRelsFolder, self::WORKBOOK_RELS_XML_FILE_NAME, $workbookRelsXmlFileContents);
return $this;
}
/**
* Creates the "styles.xml" file under the "xl" folder
*
* @param StyleHelper $styleHelper
* @return FileSystemHelper
*/
public function createStylesFile($styleHelper)
{
$stylesXmlFileContents = $styleHelper->getStylesXMLFileContent();
$this->createFileWithContents($this->xlFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
return $this;
}
/**
* Zips the root folder and streams the contents of the zip into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function zipRootFolderAndCopyToStream($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());
}
}

View File

@@ -0,0 +1,107 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Common\Exception\IOException;
/**
* Class SharedStringsHelper
* This class provides helper functions to write shared strings
*
* @package Box\Spout\Writer\XLSX\Helper
*/
class SharedStringsHelper
{
const SHARED_STRINGS_FILE_NAME = 'sharedStrings.xml';
const SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main"
EOD;
/**
* This number must be really big so that the no generated file will have more strings than that.
* If the strings number goes above, characters will be overwritten in an unwanted way and will corrupt the file.
*/
const DEFAULT_STRINGS_COUNT_PART = 'count="9999999999999" uniqueCount="9999999999999"';
/** @var resource Pointer to the sharedStrings.xml file */
protected $sharedStringsFilePointer;
/** @var int Number of shared strings already written */
protected $numSharedStrings = 0;
/** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */
protected $stringsEscaper;
/**
* @param string $xlFolder Path to the "xl" folder
*/
public function __construct($xlFolder)
{
$sharedStringsFilePath = $xlFolder . '/' . self::SHARED_STRINGS_FILE_NAME;
$this->sharedStringsFilePointer = fopen($sharedStringsFilePath, 'w');
$this->throwIfSharedStringsFilePointerIsNotAvailable();
// the headers is split into different parts so that we can fseek and put in the correct count and uniqueCount later
$header = self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER . ' ' . self::DEFAULT_STRINGS_COUNT_PART . '>';
fwrite($this->sharedStringsFilePointer, $header);
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function throwIfSharedStringsFilePointerIsNotAvailable()
{
if (!$this->sharedStringsFilePointer) {
throw new IOException('Unable to open shared strings file for writing.');
}
}
/**
* Writes the given string into the sharedStrings.xml file.
* Starting and ending whitespaces are preserved.
*
* @param string $string
* @return int ID of the written shared string
*/
public function writeString($string)
{
fwrite($this->sharedStringsFilePointer, '<si><t xml:space="preserve">' . $this->stringsEscaper->escape($string) . '</t></si>');
$this->numSharedStrings++;
// Shared string ID is zero-based
return ($this->numSharedStrings - 1);
}
/**
* Finishes writing the data in the sharedStrings.xml file and closes the file.
*
* @return void
*/
public function close()
{
if (!is_resource($this->sharedStringsFilePointer)) {
return;
}
fwrite($this->sharedStringsFilePointer, '</sst>');
// Replace the default strings count with the actual number of shared strings in the file header
$firstPartHeaderLength = strlen(self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER);
$defaultStringsCountPartLength = strlen(self::DEFAULT_STRINGS_COUNT_PART);
// Adding 1 to take into account the space between the last xml attribute and "count"
fseek($this->sharedStringsFilePointer, $firstPartHeaderLength + 1);
fwrite($this->sharedStringsFilePointer, sprintf("%-{$defaultStringsCountPartLength}s", 'count="' . $this->numSharedStrings . '" uniqueCount="' . $this->numSharedStrings . '"'));
fclose($this->sharedStringsFilePointer);
}
}

View File

@@ -0,0 +1,344 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\Style;
/**
* Class StyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\XLSX\Helper
*/
class StyleHelper extends AbstractStyleHelper
{
/**
* @var array
*/
protected $registeredFills = [];
/**
* @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration
*/
protected $styleIdToFillMappingTable = [];
/**
* Excel preserves two default fills with index 0 and 1
* Since Excel is the dominant vendor - we play along here
*
* @var int The fill index counter for custom fills.
*/
protected $fillIndex = 2;
/**
* @var array
*/
protected $registeredBorders = [];
/**
* @var array [STYLE_ID] => [BORDER_ID] maps a style to a border declaration
*/
protected $styleIdToBorderMappingTable = [];
/**
* XLSX specific operations on the registered styles
*
* @param \Box\Spout\Writer\Style\Style $style
* @return \Box\Spout\Writer\Style\Style
*/
public function registerStyle($style)
{
$registeredStyle = parent::registerStyle($style);
$this->registerFill($registeredStyle);
$this->registerBorder($registeredStyle);
return $registeredStyle;
}
/**
* Register a fill definition
*
* @param \Box\Spout\Writer\Style\Style $style
*/
protected function registerFill($style)
{
$styleId = $style->getId();
// Currently - only solid backgrounds are supported
// so $backgroundColor is a scalar value (RGB Color)
$backgroundColor = $style->getBackgroundColor();
if ($backgroundColor) {
$isBackgroundColorRegistered = isset($this->registeredFills[$backgroundColor]);
// We need to track the already registered background definitions
if ($isBackgroundColorRegistered) {
$registeredStyleId = $this->registeredFills[$backgroundColor];
$registeredFillId = $this->styleIdToFillMappingTable[$registeredStyleId];
$this->styleIdToFillMappingTable[$styleId] = $registeredFillId;
} else {
$this->registeredFills[$backgroundColor] = $styleId;
$this->styleIdToFillMappingTable[$styleId] = $this->fillIndex++;
}
} else {
// The fillId maps a style to a fill declaration
// When there is no background color definition - we default to 0
$this->styleIdToFillMappingTable[$styleId] = 0;
}
}
/**
* Register a border definition
*
* @param \Box\Spout\Writer\Style\Style $style
*/
protected function registerBorder($style)
{
$styleId = $style->getId();
if ($style->shouldApplyBorder()) {
$border = $style->getBorder();
$serializedBorder = serialize($border);
$isBorderAlreadyRegistered = isset($this->registeredBorders[$serializedBorder]);
if ($isBorderAlreadyRegistered) {
$registeredStyleId = $this->registeredBorders[$serializedBorder];
$registeredBorderId = $this->styleIdToBorderMappingTable[$registeredStyleId];
$this->styleIdToBorderMappingTable[$styleId] = $registeredBorderId;
} else {
$this->registeredBorders[$serializedBorder] = $styleId;
$this->styleIdToBorderMappingTable[$styleId] = count($this->registeredBorders);
}
} else {
// If no border should be applied - the mapping is the default border: 0
$this->styleIdToBorderMappingTable[$styleId] = 0;
}
}
/**
* For empty cells, we can specify a style or not. If no style are specified,
* then the software default will be applied. But sometimes, it may be useful
* to override this default style, for instance if the cell should have a
* background color different than the default one or some borders
* (fonts property don't really matter here).
*
* @param int $styleId
* @return bool Whether the cell should define a custom style
*/
public function shouldApplyStyleOnEmptyCell($styleId)
{
$hasStyleCustomFill = (isset($this->styleIdToFillMappingTable[$styleId]) && $this->styleIdToFillMappingTable[$styleId] !== 0);
$hasStyleCustomBorders = (isset($this->styleIdToBorderMappingTable[$styleId]) && $this->styleIdToBorderMappingTable[$styleId] !== 0);
return ($hasStyleCustomFill || $hasStyleCustomBorders);
}
/**
* Returns the content of the "styles.xml" file, given a list of styles.
*
* @return string
*/
public function getStylesXMLFileContent()
{
$content = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
EOD;
$content .= $this->getFontsSectionContent();
$content .= $this->getFillsSectionContent();
$content .= $this->getBordersSectionContent();
$content .= $this->getCellStyleXfsSectionContent();
$content .= $this->getCellXfsSectionContent();
$content .= $this->getCellStylesSectionContent();
$content .= <<<EOD
</styleSheet>
EOD;
return $content;
}
/**
* Returns the content of the "<fonts>" section.
*
* @return string
*/
protected function getFontsSectionContent()
{
$content = '<fonts count="' . count($this->styleIdToStyleMappingTable) . '">';
/** @var \Box\Spout\Writer\Style\Style $style */
foreach ($this->getRegisteredStyles() as $style) {
$content .= '<font>';
$content .= '<sz val="' . $style->getFontSize() . '"/>';
$content .= '<color rgb="' . Color::toARGB($style->getFontColor()) . '"/>';
$content .= '<name val="' . $style->getFontName() . '"/>';
if ($style->isFontBold()) {
$content .= '<b/>';
}
if ($style->isFontItalic()) {
$content .= '<i/>';
}
if ($style->isFontUnderline()) {
$content .= '<u/>';
}
if ($style->isFontStrikethrough()) {
$content .= '<strike/>';
}
$content .= '</font>';
}
$content .= '</fonts>';
return $content;
}
/**
* Returns the content of the "<fills>" section.
*
* @return string
*/
protected function getFillsSectionContent()
{
// Excel reserves two default fills
$fillsCount = count($this->registeredFills) + 2;
$content = sprintf('<fills count="%d">', $fillsCount);
$content .= '<fill><patternFill patternType="none"/></fill>';
$content .= '<fill><patternFill patternType="gray125"/></fill>';
// The other fills are actually registered by setting a background color
foreach ($this->registeredFills as $styleId) {
/** @var Style $style */
$style = $this->styleIdToStyleMappingTable[$styleId];
$backgroundColor = $style->getBackgroundColor();
$content .= sprintf(
'<fill><patternFill patternType="solid"><fgColor rgb="%s"/></patternFill></fill>',
$backgroundColor
);
}
$content .= '</fills>';
return $content;
}
/**
* Returns the content of the "<borders>" section.
*
* @return string
*/
protected function getBordersSectionContent()
{
// There is one default border with index 0
$borderCount = count($this->registeredBorders) + 1;
$content = '<borders count="' . $borderCount . '">';
// Default border starting at index 0
$content .= '<border><left/><right/><top/><bottom/></border>';
foreach ($this->registeredBorders as $styleId) {
/** @var \Box\Spout\Writer\Style\Style $style */
$style = $this->styleIdToStyleMappingTable[$styleId];
$border = $style->getBorder();
$content .= '<border>';
// @link https://github.com/box/spout/issues/271
$sortOrder = ['left', 'right', 'top', 'bottom'];
foreach ($sortOrder as $partName) {
if ($border->hasPart($partName)) {
/** @var $part \Box\Spout\Writer\Style\BorderPart */
$part = $border->getPart($partName);
$content .= BorderHelper::serializeBorderPart($part);
}
}
$content .= '</border>';
}
$content .= '</borders>';
return $content;
}
/**
* Returns the content of the "<cellStyleXfs>" section.
*
* @return string
*/
protected function getCellStyleXfsSectionContent()
{
return <<<EOD
<cellStyleXfs count="1">
<xf borderId="0" fillId="0" fontId="0" numFmtId="0"/>
</cellStyleXfs>
EOD;
}
/**
* Returns the content of the "<cellXfs>" section.
*
* @return string
*/
protected function getCellXfsSectionContent()
{
$registeredStyles = $this->getRegisteredStyles();
$content = '<cellXfs count="' . count($registeredStyles) . '">';
foreach ($registeredStyles as $style) {
$styleId = $style->getId();
$fillId = $this->styleIdToFillMappingTable[$styleId];
$borderId = $this->styleIdToBorderMappingTable[$styleId];
$content .= '<xf numFmtId="0" fontId="' . $styleId . '" fillId="' . $fillId . '" borderId="' . $borderId . '" xfId="0"';
if ($style->shouldApplyFont()) {
$content .= ' applyFont="1"';
}
$content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0);
if ($style->shouldWrapText()) {
$content .= ' applyAlignment="1">';
$content .= '<alignment wrapText="1"/>';
$content .= '</xf>';
} else {
$content .= '/>';
}
}
$content .= '</cellXfs>';
return $content;
}
/**
* Returns the content of the "<cellStyles>" section.
*
* @return string
*/
protected function getCellStylesSectionContent()
{
return <<<EOD
<cellStyles count="1">
<cellStyle builtinId="0" name="Normal" xfId="0"/>
</cellStyles>
EOD;
}
}

View File

@@ -0,0 +1,135 @@
<?php
namespace Box\Spout\Writer\XLSX\Internal;
use Box\Spout\Writer\Common\Internal\AbstractWorkbook;
use Box\Spout\Writer\XLSX\Helper\FileSystemHelper;
use Box\Spout\Writer\XLSX\Helper\SharedStringsHelper;
use Box\Spout\Writer\XLSX\Helper\StyleHelper;
use Box\Spout\Writer\Common\Sheet;
/**
* Class Workbook
* Represents a workbook within a XLSX file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\XLSX\Internal
*/
class Workbook extends AbstractWorkbook
{
/**
* Maximum number of rows a XLSX sheet can contain
* @see http://office.microsoft.com/en-us/excel-help/excel-specifications-and-limits-HP010073849.aspx
*/
protected static $maxRowsPerWorksheet = 1048576;
/** @var bool Whether inline or shared strings should be used */
protected $shouldUseInlineStrings;
/** @var \Box\Spout\Writer\XLSX\Helper\FileSystemHelper Helper to perform file system operations */
protected $fileSystemHelper;
/** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
protected $sharedStringsHelper;
/** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to apply styles */
protected $styleHelper;
/**
* @param string $tempFolder
* @param bool $shouldUseInlineStrings
* @param bool $shouldCreateNewSheetsAutomatically
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function __construct($tempFolder, $shouldUseInlineStrings, $shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
parent::__construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle);
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
$this->fileSystemHelper->createBaseFilesAndFolders();
$this->styleHelper = new StyleHelper($defaultRowStyle);
// This helper will be shared by all sheets
$xlFolder = $this->fileSystemHelper->getXlFolder();
$this->sharedStringsHelper = new SharedStringsHelper($xlFolder);
}
/**
* @return \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to apply styles to XLSX files
*/
protected function getStyleHelper()
{
return $this->styleHelper;
}
/**
* @return int Maximum number of rows/columns a sheet can contain
*/
protected function getMaxRowsPerWorksheet()
{
return self::$maxRowsPerWorksheet;
}
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return Worksheet The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheet()
{
$newSheetIndex = count($this->worksheets);
$sheet = new Sheet($newSheetIndex, $this->internalId);
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->styleHelper, $this->shouldUseInlineStrings);
$this->worksheets[] = $worksheet;
return $worksheet;
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the XLSX file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the XLSX that will be created
* @return void
*/
public function close($finalFilePointer)
{
/** @var Worksheet[] $worksheets */
$worksheets = $this->worksheets;
foreach ($worksheets as $worksheet) {
$worksheet->close();
}
$this->sharedStringsHelper->close();
// Finish creating all the necessary files before zipping everything together
$this->fileSystemHelper
->createContentTypesFile($worksheets)
->createWorkbookFile($worksheets)
->createWorkbookRelsFile($worksheets)
->createStylesFile($this->styleHelper)
->zipRootFolderAndCopyToStream($finalFilePointer);
$this->cleanupTempFolder();
}
/**
* Deletes the root folder created in the temp folder and all its contents.
*
* @return void
*/
protected function cleanupTempFolder()
{
$xlsxRootFolder = $this->fileSystemHelper->getRootFolder();
$this->fileSystemHelper->deleteFolderRecursively($xlsxRootFolder);
}
}

View File

@@ -0,0 +1,275 @@
<?php
namespace Box\Spout\Writer\XLSX\Internal;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Common\Helper\CellHelper;
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
/**
* Class Worksheet
* Represents a worksheet within a XLSX file. The difference with the Sheet object is
* that this class provides an interface to write data
*
* @package Box\Spout\Writer\XLSX\Internal
*/
class Worksheet implements WorksheetInterface
{
/**
* Maximum number of characters a cell can contain
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-16c69c74-3d6a-4aaf-ba35-e6eb276e8eaa [Excel 2007]
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3 [Excel 2010]
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-ca36e2dc-1f09-4620-b726-67c00b05040f [Excel 2013/2016]
*/
const MAX_CHARACTERS_PER_CELL = 32767;
const SHEET_XML_FILE_HEADER = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
EOD;
/** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
protected $externalSheet;
/** @var string Path to the XML file that will contain the sheet data */
protected $worksheetFilePath;
/** @var \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper Helper to write shared strings */
protected $sharedStringsHelper;
/** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles */
protected $styleHelper;
/** @var bool Whether inline or shared strings should be used */
protected $shouldUseInlineStrings;
/** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */
protected $stringsEscaper;
/** @var \Box\Spout\Common\Helper\StringHelper String helper */
protected $stringHelper;
/** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */
protected $sheetFilePointer;
/** @var int Index of the last written row */
protected $lastWrittenRowIndex = 0;
/**
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
* @param string $worksheetFilesFolder Temporary folder where the files to create the XLSX will be stored
* @param \Box\Spout\Writer\XLSX\Helper\SharedStringsHelper $sharedStringsHelper Helper for shared strings
* @param \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to work with styles
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $styleHelper, $shouldUseInlineStrings)
{
$this->externalSheet = $externalSheet;
$this->sharedStringsHelper = $sharedStringsHelper;
$this->styleHelper = $styleHelper;
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$this->stringHelper = new StringHelper();
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
$this->startSheet();
}
/**
* Prepares the worksheet to accept data
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function startSheet()
{
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
$this->throwIfSheetFilePointerIsNotAvailable();
fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER);
fwrite($this->sheetFilePointer, '<sheetData>');
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function throwIfSheetFilePointerIsNotAvailable()
{
if (!$this->sheetFilePointer) {
throw new IOException('Unable to open sheet for writing.');
}
}
/**
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
*/
public function getExternalSheet()
{
return $this->externalSheet;
}
/**
* @return int The index of the last written row
*/
public function getLastWrittenRowIndex()
{
return $this->lastWrittenRowIndex;
}
/**
* @return int The ID of the worksheet
*/
public function getId()
{
// sheet index is zero-based, while ID is 1-based
return $this->externalSheet->getIndex() + 1;
}
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @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
*/
public function addRow($dataRow, $style)
{
if (!$this->isEmptyRow($dataRow)) {
$this->addNonEmptyRow($dataRow, $style);
}
$this->lastWrittenRowIndex++;
}
/**
* Returns whether the given row is empty
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @return bool Whether the given row is empty
*/
protected function isEmptyRow($dataRow)
{
$numCells = count($dataRow);
// using "reset()" instead of "$dataRow[0]" because $dataRow can be an associative array
return ($numCells === 1 && CellHelper::isEmpty(reset($dataRow)));
}
/**
* Adds non empty row to the worksheet.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @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
*/
protected function addNonEmptyRow($dataRow, $style)
{
$cellNumber = 0;
$rowIndex = $this->lastWrittenRowIndex + 1;
$numCells = count($dataRow);
$rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
foreach($dataRow as $cellValue) {
$rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style->getId());
$cellNumber++;
}
$rowXML .= '</row>';
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $rowXML);
if ($wasWriteSuccessful === false) {
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
}
}
/**
* Build and return xml for a single cell.
*
* @param int $rowIndex
* @param int $cellNumber
* @param mixed $cellValue
* @param int $styleId
* @return string
* @throws InvalidArgumentException If the given value cannot be processed
*/
protected function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
{
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
$cellXML .= ' s="' . $styleId . '"';
if (CellHelper::isNonEmptyString($cellValue)) {
$cellXML .= $this->getCellXMLFragmentForNonEmptyString($cellValue);
} else if (CellHelper::isBoolean($cellValue)) {
$cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
} else if (CellHelper::isNumeric($cellValue)) {
$cellXML .= '><v>' . $cellValue . '</v></c>';
} else if (empty($cellValue)) {
if ($this->styleHelper->shouldApplyStyleOnEmptyCell($styleId)) {
$cellXML .= '/>';
} else {
// don't write empty cells that do no need styling
// NOTE: not appending to $cellXML is the right behavior!!
$cellXML = '';
}
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
return $cellXML;
}
/**
* Returns the XML fragment for a cell containing a non empty string
*
* @param string $cellValue The cell value
* @return string The XML fragment representing the cell
* @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
*/
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)');
}
if ($this->shouldUseInlineStrings) {
$cellXMLFragment = ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
} else {
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
$cellXMLFragment = ' t="s"><v>' . $sharedStringId . '</v></c>';
}
return $cellXMLFragment;
}
/**
* Closes the worksheet
*
* @return void
*/
public function close()
{
if (!is_resource($this->sheetFilePointer)) {
return;
}
fwrite($this->sheetFilePointer, '</sheetData>');
fwrite($this->sheetFilePointer, '</worksheet>');
fclose($this->sheetFilePointer);
}
}

View File

@@ -0,0 +1,132 @@
<?php
namespace Box\Spout\Writer\XLSX;
use Box\Spout\Writer\AbstractMultiSheetsWriter;
use Box\Spout\Writer\Style\StyleBuilder;
use Box\Spout\Writer\XLSX\Internal\Workbook;
/**
* Class Writer
* This class provides base support to write data to XLSX files
*
* @package Box\Spout\Writer\XLSX
*/
class Writer extends AbstractMultiSheetsWriter
{
/** Default style font values */
const DEFAULT_FONT_SIZE = 12;
const DEFAULT_FONT_NAME = 'Calibri';
/** @var string Content-Type value for the header */
protected static $headerContentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
/** @var string Temporary folder where the files to create the XLSX will be stored */
protected $tempFolder;
/** @var bool Whether inline or shared strings should be used - inline string is more memory efficient */
protected $shouldUseInlineStrings = true;
/** @var Internal\Workbook The workbook for the XLSX file */
protected $book;
/**
* Sets a custom temporary folder for creating intermediate files/folders.
* This must be set before opening the writer.
*
* @api
* @param string $tempFolder Temporary folder where the files to create the XLSX will be stored
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setTempFolder($tempFolder)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->tempFolder = $tempFolder;
return $this;
}
/**
* Use inline string to be more memory efficient. If set to false, it will use shared strings.
* This must be set before opening the writer.
*
* @api
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setShouldUseInlineStrings($shouldUseInlineStrings)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
return $this;
}
/**
* Configures the write and sets the current sheet pointer to a new sheet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to open the file for writing
*/
protected function openWriter()
{
if (!$this->book) {
$tempFolder = ($this->tempFolder) ? : sys_get_temp_dir();
$this->book = new Workbook($tempFolder, $this->shouldUseInlineStrings, $this->shouldCreateNewSheetsAutomatically, $this->defaultRowStyle);
$this->book->addNewSheetAndMakeItCurrent();
}
}
/**
* @return Internal\Workbook The workbook representing the file to be written
*/
protected function getWorkbook()
{
return $this->book;
}
/**
* Adds data to the currently opened writer.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
protected function addRowToWriter(array $dataRow, $style)
{
$this->throwIfBookIsNotAvailable();
$this->book->addRowToCurrentWorksheet($dataRow, $style);
}
/**
* Returns the default style to be applied to rows.
*
* @return \Box\Spout\Writer\Style\Style
*/
protected function getDefaultRowStyle()
{
return (new StyleBuilder())
->setFontSize(self::DEFAULT_FONT_SIZE)
->setFontName(self::DEFAULT_FONT_NAME)
->build();
}
/**
* Closes the writer, preventing any additional writing.
*
* @return void
*/
protected function closeWriter()
{
if ($this->book) {
$this->book->close($this->filePointer);
}
}
}