Autoloader/Psr4Autoloader.php000064400000010760150732270610012233 0ustar00prefixes[$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; } } Autoloader/autoload.php000064400000000533150732270610011170 0ustar00register(); $loader->addNamespace('Box\Spout', $srcBaseDirectory); Reader/SheetInterface.php000064400000000430150732270610011350 0ustar00filePath = $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 "" 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 "" 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 "" 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 "" 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 "" 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 $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 "" 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 $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(); } } Reader/XLSX/Helper/DateFormatHelper.php000064400000013404150732270610013667 0ustar00 [ // 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); } } Reader/XLSX/Helper/CellValueFormatter.php000064400000027105150732270610014244 0ustar00sharedStringsHelper = $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: // [INLINE_STRING] $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: // [SHARED_STRING_INDEX] $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; } } } Reader/XLSX/Helper/SharedStringsCaching/InMemoryStrategy.php000064400000005036150732270610020022 0ustar00inMemoryCache = 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; } } Reader/XLSX/Helper/SharedStringsCaching/CachingStrategyInterface.php000064400000002472150732270610021441 0ustar00fileSystemHelper = 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); } } } Reader/XLSX/Helper/SharedStringsCaching/CachingStrategyFactory.php000064400000013321150732270610021143 0ustar00 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'); } } Reader/XLSX/Helper/SheetHelper.php000064400000015013150732270610012707 0ustar00filePath = $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; } } Reader/XLSX/Helper/CellHelper.php000064400000010174150732270610012521 0ustar00 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); } } Reader/XLSX/Helper/StyleHelper.php000064400000030037150732270610012742 0ustar00 '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 = '((?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; } } Reader/XLSX/Helper/SharedStringsHelper.php000064400000021356150732270610014426 0ustar00filePath = $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 '' 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 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 "" 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 "" or "". * * @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 () 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(); } } } Reader/XLSX/Reader.php000064400000006163150732270610010470 0ustar00options)) { $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(); } } } Reader/XLSX/Sheet.php000064400000004172150732270610010334 0ustar00rowIterator = 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; } } Reader/XLSX/ReaderOptions.php000064400000001450150732270610012036 0ustar00tempFolder; } /** * @param string|null $tempFolder Temporary folder where the temporary files will be created * @return ReaderOptions */ public function setTempFolder($tempFolder) { $this->tempFolder = $tempFolder; return $this; } } Reader/XLSX/SheetIterator.php000064400000006212150732270610012043 0ustar00sheets = $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(); } } } Reader/AbstractReader.php000064400000016462150732270610011361 0ustar00globalFunctionsHelper = $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; } } } Reader/Exception/XMLProcessingException.php000064400000000271150732270610014774 0ustar00initialUseInternalErrorsValue = 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); } } Reader/Wrapper/XMLReader.php000064400000013224150732270610011667 0ustar00getRealPathURIForFileInZip($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, "" can also be ""). // 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; } } Reader/Common/XMLProcessor.php000064400000013557150732270610012265 0ustar00xmlReader = $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); } } Reader/Common/ReaderOptions.php000064400000002752150732270610012476 0ustar00shouldFormatDates; } /** * 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; } } Reader/CSV/RowIterator.php000064400000020737150732270610011407 0ustar00filePointer = $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 } } Reader/CSV/Reader.php000064400000007625150732270610010331 0ustar00options)) { $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); } } Reader/CSV/Sheet.php000064400000002451150732270610010167 0ustar00rowIterator = 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; } } Reader/CSV/ReaderOptions.php000064400000005215150732270610011676 0ustar00fieldDelimiter; } /** * 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; } } Reader/CSV/SheetIterator.php000064400000004046150732270610011703 0ustar00sheet = 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 } } Reader/ODS/RowIterator.php000064400000032663150732270610011402 0ustar00" 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 "" 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 "" 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 "" // 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 "" 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 "" 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 "" 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(); } } Reader/ODS/Helper/CellValueFormatter.php000064400000020414150732270610014067 0ustar00shouldFormatDates = $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: // // 05/19/16 04:39 PM // 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: // // 01:24:00 PM // 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); } } Reader/ODS/Helper/SettingsHelper.php000064400000003030150732270610013262 0ustar00openFileInZip($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; } } Reader/ODS/Reader.php000064400000004014150732270610010310 0ustar00options)) { $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(); } } } Reader/ODS/Sheet.php000064400000003744150732270610010167 0ustar00" 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; } } Reader/ODS/ReaderOptions.php000064400000000403150732270610011662 0ustar00filePath = $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(); } } Reader/ReaderFactory.php000064400000002447150732270610011223 0ustar00setGlobalFunctionsHelper(new GlobalFunctionsHelper()); return $reader; } } Writer/XLSX/Helper/FileSystemHelper.php000064400000032653150732270610014006 0ustar00rootFolder; } /** * @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; $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 = << $appName 0 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 = << $createdDate $createdDate 0 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; /** @var Worksheet $worksheet */ foreach ($worksheets as $worksheet) { $contentTypesXmlFileContents .= ''; } $contentTypesXmlFileContents .= << 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; /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ $escaper = \Box\Spout\Common\Escaper\XLSX::getInstance(); /** @var Worksheet $worksheet */ foreach ($worksheets as $worksheet) { $worksheetName = $worksheet->getExternalSheet()->getName(); $worksheetId = $worksheet->getId(); $workbookXmlFileContents .= ''; } $workbookXmlFileContents .= << 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; /** @var Worksheet $worksheet */ foreach ($worksheets as $worksheet) { $worksheetId = $worksheet->getId(); $workbookRelsXmlFileContents .= ''; } $workbookRelsXmlFileContents .= ''; $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()); } } Writer/XLSX/Helper/StyleHelper.php000064400000024704150732270610013020 0ustar00 [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; $content .= $this->getFontsSectionContent(); $content .= $this->getFillsSectionContent(); $content .= $this->getBordersSectionContent(); $content .= $this->getCellStyleXfsSectionContent(); $content .= $this->getCellXfsSectionContent(); $content .= $this->getCellStylesSectionContent(); $content .= << EOD; return $content; } /** * Returns the content of the "" section. * * @return string */ protected function getFontsSectionContent() { $content = ''; /** @var \Box\Spout\Writer\Style\Style $style */ foreach ($this->getRegisteredStyles() as $style) { $content .= ''; $content .= ''; $content .= ''; $content .= ''; if ($style->isFontBold()) { $content .= ''; } if ($style->isFontItalic()) { $content .= ''; } if ($style->isFontUnderline()) { $content .= ''; } if ($style->isFontStrikethrough()) { $content .= ''; } $content .= ''; } $content .= ''; return $content; } /** * Returns the content of the "" section. * * @return string */ protected function getFillsSectionContent() { // Excel reserves two default fills $fillsCount = count($this->registeredFills) + 2; $content = sprintf('', $fillsCount); $content .= ''; $content .= ''; // 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( '', $backgroundColor ); } $content .= ''; return $content; } /** * Returns the content of the "" section. * * @return string */ protected function getBordersSectionContent() { // There is one default border with index 0 $borderCount = count($this->registeredBorders) + 1; $content = ''; // Default border starting at index 0 $content .= ''; foreach ($this->registeredBorders as $styleId) { /** @var \Box\Spout\Writer\Style\Style $style */ $style = $this->styleIdToStyleMappingTable[$styleId]; $border = $style->getBorder(); $content .= ''; // @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 .= ''; } $content .= ''; return $content; } /** * Returns the content of the "" section. * * @return string */ protected function getCellStyleXfsSectionContent() { return << EOD; } /** * Returns the content of the "" section. * * @return string */ protected function getCellXfsSectionContent() { $registeredStyles = $this->getRegisteredStyles(); $content = ''; foreach ($registeredStyles as $style) { $styleId = $style->getId(); $fillId = $this->styleIdToFillMappingTable[$styleId]; $borderId = $this->styleIdToBorderMappingTable[$styleId]; $content .= 'shouldApplyFont()) { $content .= ' applyFont="1"'; } $content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0); if ($style->shouldWrapText()) { $content .= ' applyAlignment="1">'; $content .= ''; $content .= ''; } else { $content .= '/>'; } } $content .= ''; return $content; } /** * Returns the content of the "" section. * * @return string */ protected function getCellStylesSectionContent() { return << EOD; } } Writer/XLSX/Helper/BorderHelper.php000064400000003626150732270610013135 0ustar00 [ 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('', $borderPart->getColor()) : ''; $partEl = sprintf( '<%s style="%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()]; } } Writer/XLSX/Helper/SharedStringsHelper.php000064400000007561150732270610014502 0ustar00 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, '' . $this->stringsEscaper->escape($string) . ''); $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, ''); // 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); } } Writer/XLSX/Internal/Worksheet.php000064400000024403150732270610013064 0ustar00 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, ''); } /** * 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 = ''; foreach($dataRow as $cellValue) { $rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style->getId()); $cellNumber++; } $rowXML .= ''; $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 = 'getCellXMLFragmentForNonEmptyString($cellValue); } else if (CellHelper::isBoolean($cellValue)) { $cellXML .= ' t="b">' . intval($cellValue) . ''; } else if (CellHelper::isNumeric($cellValue)) { $cellXML .= '>' . $cellValue . ''; } 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">' . $this->stringsEscaper->escape($cellValue) . ''; } else { $sharedStringId = $this->sharedStringsHelper->writeString($cellValue); $cellXMLFragment = ' t="s">' . $sharedStringId . ''; } return $cellXMLFragment; } /** * Closes the worksheet * * @return void */ public function close() { if (!is_resource($this->sheetFilePointer)) { return; } fwrite($this->sheetFilePointer, ''); fwrite($this->sheetFilePointer, ''); fclose($this->sheetFilePointer); } } Writer/XLSX/Internal/Workbook.php000064400000011052150732270610012702 0ustar00shouldUseInlineStrings = $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); } } Writer/XLSX/Writer.php000064400000010567150732270610010617 0ustar00throwIfWriterAlreadyOpened('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); } } } Writer/AbstractWriter.php000064400000031704150732270610011501 0ustar00defaultRowStyle = $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); } } } Writer/Style/Color.php000064400000005045150732270610010716 0ustar00 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; } } Writer/Style/Border.php000064400000003205150732270610011051 0ustar00setParts($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; } } Writer/Style/BorderPart.php000064400000007510150732270610011703 0ustar00setName($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; } } Writer/Style/Style.php000064400000024663150732270610010747 0ustar00id; } /** * @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()); } } } Writer/Style/StyleBuilder.php000064400000005665150732270610012257 0ustar00style = 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; } } Writer/Style/BorderBuilder.php000064400000004435150732270610012366 0ustar00border = 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; } } Writer/Exception/InvalidColorException.php000064400000000277150732270610014744 0ustar00tmpFolderPath = $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); } } Writer/Common/Helper/CellHelper.php000064400000005331150732270610013224 0ustar00 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'; } } Writer/Common/Helper/AbstractStyleHelper.php000064400000011171150732270610015130 0ustar00 [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; } } Writer/Common/Sheet.php000064400000014066150732270610011043 0ustar00 [[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; } } Writer/Common/Internal/WorksheetInterface.php000064400000002071150732270610015334 0ustar00shouldCreateNewSheetsAutomatically = $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); } Writer/WriterInterface.php000064400000007220150732270610011632 0ustar00fieldDelimiter = $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; } } Writer/WriterFactory.php000064400000002454150732270610011345 0ustar00setGlobalFunctionsHelper(new GlobalFunctionsHelper()); return $writer; } } Writer/ODS/Helper/FileSystemHelper.php000064400000024742150732270610013635 0ustar00rootFolder; } /** * @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; $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 = << $appName $createdDate $createdDate 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; $contentXmlFileContents .= $styleHelper->getContentXmlFontFaceSectionContent(); $contentXmlFileContents .= $styleHelper->getContentXmlAutomaticStylesSectionContent(count($worksheets)); $contentXmlFileContents .= ''; $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 "" node, with the final sheet's name fwrite($contentXmlHandle, $worksheet->getTableElementStartAsString()); $worksheetFilePath = $worksheet->getWorksheetFilePath(); $this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle); fwrite($contentXmlHandle, ''); } $contentXmlFileContents = ''; 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()); } } Writer/ODS/Helper/StyleHelper.php000064400000027475150732270610012657 0ustar00 [] 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; $content .= $this->getFontFaceSectionContent(); $content .= $this->getStylesSectionContent(); $content .= $this->getAutomaticStylesSectionContent($numWorksheets); $content .= $this->getMasterStylesSectionContent($numWorksheets); $content .= << EOD; return $content; } /** * Returns the content of the "" section, inside "styles.xml" file. * * @return string */ protected function getFontFaceSectionContent() { $content = ''; foreach ($this->getUsedFonts() as $fontName) { $content .= ''; } $content .= ''; return $content; } /** * Returns the content of the "" section, inside "styles.xml" file. * * @return string */ protected function getStylesSectionContent() { $defaultStyle = $this->getDefaultStyle(); return << EOD; } /** * Returns the content of the "" section, inside "styles.xml" file. * * @param int $numWorksheets Number of worksheets created * @return string */ protected function getAutomaticStylesSectionContent($numWorksheets) { $content = ''; for ($i = 1; $i <= $numWorksheets; $i++) { $content .= << EOD; } $content .= ''; return $content; } /** * Returns the content of the "" section, inside "styles.xml" file. * * @param int $numWorksheets Number of worksheets created * @return string */ protected function getMasterStylesSectionContent($numWorksheets) { $content = ''; for ($i = 1; $i <= $numWorksheets; $i++) { $content .= << EOD; } $content .= ''; return $content; } /** * Returns the contents of the "" section, inside "content.xml" file. * * @return string */ public function getContentXmlFontFaceSectionContent() { $content = ''; foreach ($this->getUsedFonts() as $fontName) { $content .= ''; } $content .= ''; return $content; } /** * Returns the contents of the "" section, inside "content.xml" file. * * @param int $numWorksheets Number of worksheets created * @return string */ public function getContentXmlAutomaticStylesSectionContent($numWorksheets) { $content = ''; foreach ($this->getRegisteredStyles() as $style) { $content .= $this->getStyleSectionContent($style); } $content .= << EOD; for ($i = 1; $i <= $numWorksheets; $i++) { $content .= << EOD; } $content .= ''; return $content; } /** * Returns the contents of the "" section, inside "" section * * @param \Box\Spout\Writer\Style\Style $style * @return string */ protected function getStyleSectionContent($style) { $styleIndex = $style->getId() + 1; // 1-based $content = ''; $content .= $this->getTextPropertiesSectionContent($style); $content .= $this->getTableCellPropertiesSectionContent($style); $content .= ''; return $content; } /** * Returns the contents of the "" section, inside "" 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 "" section, inside "" section * * @param \Box\Spout\Writer\Style\Style $style * @return string */ private function getFontSectionContent($style) { $defaultStyle = $this->getDefaultStyle(); $content = '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 "" section, inside "" 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 "" section * * @return string */ private function getWrapTextXMLContent() { return ''; } /** * Returns the contents of the borders definition for the "" section * * @param \Box\Spout\Writer\Style\Style $style * @return string */ private function getBorderXMLContent($style) { $borderProperty = ''; $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 "" section * * @param \Box\Spout\Writer\Style\Style $style * @return string */ private function getBackgroundColorXMLContent($style) { return sprintf( '', $style->getBackgroundColor() ); } } Writer/ODS/Helper/BorderHelper.php000064400000003577150732270610012771 0ustar00 */ 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; } } Writer/ODS/Internal/Worksheet.php000064400000020566150732270610012721 0ustar00externalSheet = $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 "" 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 node as string */ public function getTableElementStartAsString() { $escapedSheetName = $this->stringsEscaper->escape($this->externalSheet->getName()); $tableStyleName = 'ta' . ($this->externalSheet->getIndex() + 1); $tableElement = ''; $tableElement .= ''; 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 = ''; $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 .= ''; $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 = 'stringsEscaper->escape($cellValueLine) . ''; } $data .= ''; } else if (CellHelper::isBoolean($cellValue)) { $data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="' . $cellValue . '">'; $data .= '' . $cellValue . ''; $data .= ''; } else if (CellHelper::isNumeric($cellValue)) { $data .= ' office:value-type="float" calcext:value-type="float" office:value="' . $cellValue . '">'; $data .= '' . $cellValue . ''; $data .= ''; } 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); } } Writer/ODS/Internal/Workbook.php000064400000007517150732270610012544 0ustar00fileSystemHelper = 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); } } Writer/ODS/Writer.php000064400000006002150732270610010433 0ustar00throwIfWriterAlreadyOpened('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); } } } Writer/AbstractMultiSheetsWriter.php000064400000007723150732270610013674 0ustar00throwIfWriterAlreadyOpened('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.'); } } } Common/Singleton.php000064400000001302150732270610010446 0ustar00init(); } /** * Initializes the singleton * @return void */ protected function init() {} final private function __wakeup() {} final private function __clone() {} } Common/Helper/FileSystemHelper.php000064400000011323150732270610013153 0ustar00baseFolderRealPath = 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}"); } } } Common/Helper/GlobalFunctionsHelper.php000064400000016575150732270610014176 0ustar00convertToUseRealPath($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); } } Common/Helper/EncodingHelper.php000064400000014676150732270610012633 0ustar00globalFunctionsHelper = $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'); } } Common/Helper/StringHelper.php000064400000004141150732270610012335 0ustar00hasMbstringSupport = 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; } } Common/Exception/IOException.php000064400000000252150732270610012633 0ustar00', '&') 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/", '�', $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; } } Common/Escaper/XLSX.php000064400000015207150732270610010675 0ustar00escapableControlCharactersPattern = $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("/(?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); } } Common/Escaper/EscaperInterface.php000064400000001116150732270610013274 0ustar00