From 104cd9b8114659751d29f34376851bc8c36f1bdc Mon Sep 17 00:00:00 2001 From: Adrien Loison Date: Fri, 20 May 2016 16:08:35 -0700 Subject: [PATCH] Option to return formatted dates instead of PHP objects (#226) When reading spreadsheets, Spout should be able to return formatted dates, as shown when opened with Excel for instance. It currently only returns DateTime/DateInterval objects, making it impossible to read + write, as the Writer does not accept objects. --- README.md | 17 ++- src/Spout/Reader/AbstractReader.php | 17 ++- .../Reader/ODS/Helper/CellValueFormatter.php | 58 +++++++-- src/Spout/Reader/ODS/Reader.php | 2 +- src/Spout/Reader/ODS/RowIterator.php | 7 +- src/Spout/Reader/ODS/Sheet.php | 5 +- src/Spout/Reader/ODS/SheetIterator.php | 9 +- .../Reader/XLSX/Helper/CellValueFormatter.php | 42 ++++-- .../Reader/XLSX/Helper/DateFormatHelper.php | 122 ++++++++++++++++++ src/Spout/Reader/XLSX/Helper/SheetHelper.php | 9 +- src/Spout/Reader/XLSX/Helper/StyleHelper.php | 44 ++++++- src/Spout/Reader/XLSX/Reader.php | 2 +- src/Spout/Reader/XLSX/RowIterator.php | 5 +- src/Spout/Reader/XLSX/Sheet.php | 5 +- src/Spout/Reader/XLSX/SheetIterator.php | 5 +- tests/Spout/Reader/ODS/ReaderTest.php | 19 ++- .../XLSX/Helper/CellValueFormatterTest.php | 6 +- .../XLSX/Helper/DateFormatHelperTest.php | 47 +++++++ tests/Spout/Reader/XLSX/ReaderTest.php | 19 ++- .../ods/sheet_with_dates_and_times.ods | Bin 0 -> 9988 bytes .../xlsx/sheet_with_dates_and_times.xlsx | Bin 0 -> 22453 bytes 21 files changed, 389 insertions(+), 51 deletions(-) create mode 100644 src/Spout/Reader/XLSX/Helper/DateFormatHelper.php create mode 100644 tests/Spout/Reader/XLSX/Helper/DateFormatHelperTest.php create mode 100644 tests/resources/ods/sheet_with_dates_and_times.ods create mode 100644 tests/resources/xlsx/sheet_with_dates_and_times.xlsx diff --git a/README.md b/README.md index 6bb73aa..a34c365 100644 --- a/README.md +++ b/README.md @@ -121,7 +121,7 @@ $reader->setEncoding('UTF-16LE'); The writer always generate CSV files encoded in UTF-8, with a BOM. -### Configuring the XLSX and ODS writers +### Configuring the XLSX and ODS readers and writers #### Row styling @@ -163,7 +163,6 @@ Font | Bold | `StyleBuilder::setFontBold()` | Font color | `StyleBuilder::setFontColor(Color::BLUE)`
`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))` Alignment | Wrap text | `StyleBuilder::setShouldWrapText()` - #### New sheet creation It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet: @@ -208,6 +207,20 @@ $writer->setShouldUseInlineStrings(false); // will use shared strings > Apple's products (Numbers and the iOS previewer) don't support inline strings and display empty cells instead. Therefore, if these platforms need to be supported, make sure to use shared strings! +#### Date/Time formatting + +When reading a spreadsheet containing dates or times, Spout returns the values by default as DateTime objects. +It is possible to change this behavior and have a formatted date returned instead (e.g. "2016-11-29 1:22 AM"). The format of the date corresponds to what is specified in the spreadsheet. + +```php +use Box\Spout\Reader\ReaderFactory; +use Box\Spout\Common\Type; + +$reader = ReaderFactory::create(Type::XLSX); +$reader->setShouldFormatDates(false); // default value +$reader->setShouldFormatDates(true); // will return formatted dates +``` + ### Playing with sheets When creating a XLSX or ODS file, it is possible to control which sheet the data will be written into. At any time, you can retrieve or set the current sheet: diff --git a/src/Spout/Reader/AbstractReader.php b/src/Spout/Reader/AbstractReader.php index d6d38e2..cb476ab 100644 --- a/src/Spout/Reader/AbstractReader.php +++ b/src/Spout/Reader/AbstractReader.php @@ -19,6 +19,9 @@ abstract class AbstractReader implements ReaderInterface /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ protected $globalFunctionsHelper; + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + protected $shouldFormatDates = false; + /** * Returns whether stream wrappers are supported * @@ -49,7 +52,7 @@ abstract class AbstractReader implements ReaderInterface abstract protected function closeReader(); /** - * @param $globalFunctionsHelper + * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper * @return AbstractReader */ public function setGlobalFunctionsHelper($globalFunctionsHelper) @@ -58,6 +61,18 @@ abstract class AbstractReader implements ReaderInterface return $this; } + /** + * Sets whether date/time values should be returned as PHP objects or be formatted as strings. + * + * @param bool $shouldFormatDates + * @return AbstractReader + */ + public function setShouldFormatDates($shouldFormatDates) + { + $this->shouldFormatDates = $shouldFormatDates; + return $this; + } + /** * Prepares the reader to read the given file. It also makes sure * that the file exists and is readable. diff --git a/src/Spout/Reader/ODS/Helper/CellValueFormatter.php b/src/Spout/Reader/ODS/Helper/CellValueFormatter.php index 3eb1918..b39af21 100644 --- a/src/Spout/Reader/ODS/Helper/CellValueFormatter.php +++ b/src/Spout/Reader/ODS/Helper/CellValueFormatter.php @@ -34,14 +34,19 @@ class CellValueFormatter const XML_ATTRIBUTE_CURRENCY = 'office:currency'; const XML_ATTRIBUTE_C = 'text:c'; + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + protected $shouldFormatDates; + /** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */ protected $escaper; /** - * + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings */ - public function __construct() + public function __construct($shouldFormatDates) { + $this->shouldFormatDates = $shouldFormatDates; + /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ $this->escaper = new \Box\Spout\Common\Escaper\ODS(); } @@ -122,6 +127,7 @@ class CellValueFormatter { $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; } @@ -144,15 +150,27 @@ class CellValueFormatter * Returns the cell Date value from the given node. * * @param \DOMNode $node - * @return \DateTime|null The value associated with the cell or NULL if invalid date value + * @return \DateTime|string|null The value associated with the cell or NULL if invalid date value */ protected function formatDateCellValue($node) { - try { - $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE); - return new \DateTime($nodeValue); - } catch (\Exception $e) { - return null; + // 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; + } } } @@ -160,15 +178,27 @@ class CellValueFormatter * Returns the cell Time value from the given node. * * @param \DOMNode $node - * @return \DateInterval|null The value associated with the cell or NULL if invalid time value + * @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value */ protected function formatTimeCellValue($node) { - try { - $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE); - return new \DateInterval($nodeValue); - } catch (\Exception $e) { - return null; + // 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; + } } } diff --git a/src/Spout/Reader/ODS/Reader.php b/src/Spout/Reader/ODS/Reader.php index b4093ae..a52bafa 100644 --- a/src/Spout/Reader/ODS/Reader.php +++ b/src/Spout/Reader/ODS/Reader.php @@ -42,7 +42,7 @@ class Reader extends AbstractReader $this->zip = new \ZipArchive(); if ($this->zip->open($filePath) === true) { - $this->sheetIterator = new SheetIterator($filePath); + $this->sheetIterator = new SheetIterator($filePath, $this->shouldFormatDates); } else { throw new IOException("Could not open $filePath for reading."); } diff --git a/src/Spout/Reader/ODS/RowIterator.php b/src/Spout/Reader/ODS/RowIterator.php index aa7a496..e91ad90 100644 --- a/src/Spout/Reader/ODS/RowIterator.php +++ b/src/Spout/Reader/ODS/RowIterator.php @@ -45,11 +45,12 @@ class RowIterator implements IteratorInterface /** * @param XMLReader $xmlReader XML Reader, positioned on the "" element + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings */ - public function __construct($xmlReader) + public function __construct($xmlReader, $shouldFormatDates) { $this->xmlReader = $xmlReader; - $this->cellValueFormatter = new CellValueFormatter(); + $this->cellValueFormatter = new CellValueFormatter($shouldFormatDates); } /** @@ -186,7 +187,7 @@ class RowIterator implements IteratorInterface /** * empty() replacement that honours 0 as a valid value * - * @param $value The cell value + * @param string|int|float|bool|\DateTime|\DateInterval|null $value The cell value * @return bool */ protected function isEmptyCellValue($value) diff --git a/src/Spout/Reader/ODS/Sheet.php b/src/Spout/Reader/ODS/Sheet.php index c78e4aa..98d00b1 100644 --- a/src/Spout/Reader/ODS/Sheet.php +++ b/src/Spout/Reader/ODS/Sheet.php @@ -27,12 +27,13 @@ class Sheet implements SheetInterface /** * @param XMLReader $xmlReader XML Reader, positioned on the "" element + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param string $sheetName Name of the sheet */ - public function __construct($xmlReader, $sheetIndex, $sheetName) + public function __construct($xmlReader, $shouldFormatDates, $sheetIndex, $sheetName) { - $this->rowIterator = new RowIterator($xmlReader); + $this->rowIterator = new RowIterator($xmlReader, $shouldFormatDates); $this->index = $sheetIndex; $this->name = $sheetName; } diff --git a/src/Spout/Reader/ODS/SheetIterator.php b/src/Spout/Reader/ODS/SheetIterator.php index f8683f0..d0010bd 100644 --- a/src/Spout/Reader/ODS/SheetIterator.php +++ b/src/Spout/Reader/ODS/SheetIterator.php @@ -22,6 +22,9 @@ class SheetIterator implements IteratorInterface /** @var string $filePath Path of the file to be read */ protected $filePath; + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + protected $shouldFormatDates; + /** @var XMLReader The XMLReader object that will help read sheet's XML data */ protected $xmlReader; @@ -36,11 +39,13 @@ class SheetIterator implements IteratorInterface /** * @param string $filePath Path of the file to be read + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file */ - public function __construct($filePath) + public function __construct($filePath, $shouldFormatDates) { $this->filePath = $filePath; + $this->shouldFormatDates = $shouldFormatDates; $this->xmlReader = new XMLReader(); /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ @@ -109,7 +114,7 @@ class SheetIterator implements IteratorInterface $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME); $sheetName = $this->escaper->unescape($escapedSheetName); - return new Sheet($this->xmlReader, $sheetName, $this->currentSheetIndex); + return new Sheet($this->xmlReader, $this->shouldFormatDates, $sheetName, $this->currentSheetIndex); } /** diff --git a/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php b/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php index 046336a..286d348 100644 --- a/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php +++ b/src/Spout/Reader/XLSX/Helper/CellValueFormatter.php @@ -44,17 +44,22 @@ class CellValueFormatter /** @var StyleHelper Helper to work with styles */ protected $styleHelper; + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + protected $shouldFormatDates; + /** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */ protected $escaper; /** * @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings * @param StyleHelper $styleHelper Helper to work with styles + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings */ - public function __construct($sharedStringsHelper, $styleHelper) + public function __construct($sharedStringsHelper, $styleHelper, $shouldFormatDates) { $this->sharedStringsHelper = $sharedStringsHelper; $this->styleHelper = $styleHelper; + $this->shouldFormatDates = $shouldFormatDates; /** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */ $this->escaper = new \Box\Spout\Common\Escaper\XLSX(); @@ -168,7 +173,7 @@ class CellValueFormatter $shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId); if ($shouldFormatAsDate) { - return $this->formatExcelTimestampValue(floatval($nodeValue)); + return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId); } else { $nodeIntValue = intval($nodeValue); return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue); @@ -181,9 +186,10 @@ class CellValueFormatter * 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) + protected function formatExcelTimestampValue($nodeValue, $cellStyleId) { // Fix for the erroneous leap year in Excel if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) { @@ -192,10 +198,10 @@ class CellValueFormatter if ($nodeValue >= 1) { // Values greater than 1 represent "dates". The value 1.0 representing the "base" date: 1900-01-01. - return $this->formatExcelTimestampValueAsDateValue($nodeValue); + return $this->formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId); } else if ($nodeValue >= 0) { // Values between 0 and 1 represent "times". - return $this->formatExcelTimestampValueAsTimeValue($nodeValue); + return $this->formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId); } else { // invalid date return null; @@ -207,9 +213,10 @@ class CellValueFormatter * Only the time value matters. The date part is set to Jan 1st, 1900 (base Excel date). * * @param float $nodeValue - * @return \DateTime The value associated with the cell + * @param int $cellStyleId 0 being the default style + * @return \DateTime|string The value associated with the cell */ - protected function formatExcelTimestampValueAsTimeValue($nodeValue) + protected function formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId) { $time = round($nodeValue * self::NUM_SECONDS_IN_ONE_DAY); $hours = floor($time / self::NUM_SECONDS_IN_ONE_HOUR); @@ -220,7 +227,13 @@ class CellValueFormatter $dateObj = new \DateTime('1900-01-01'); $dateObj->setTime($hours, $minutes, $seconds); - return $dateObj; + if ($this->shouldFormatDates) { + $styleNumberFormat = $this->styleHelper->getNumberFormat($cellStyleId); + $phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormat); + return $dateObj->format($phpDateFormat); + } else { + return $dateObj; + } } /** @@ -228,9 +241,10 @@ class CellValueFormatter * NOTE: The timestamp is a float representing the number of days since January 1st, 1900. * * @param float $nodeValue - * @return \DateTime|null The value associated with the cell or NULL if invalid date value + * @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) + protected function formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId) { // Do not use any unix timestamps for calculation to prevent // issues with numbers exceeding 2^31. @@ -242,7 +256,13 @@ class CellValueFormatter $dateObj->modify('+' . intval($nodeValue) . 'days'); $dateObj->modify('+' . $secondsRemainder . 'seconds'); - return $dateObj; + if ($this->shouldFormatDates) { + $styleNumberFormat = $this->styleHelper->getNumberFormat($cellStyleId); + $phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormat); + return $dateObj->format($phpDateFormat); + } else { + return $dateObj; + } } catch (\Exception $e) { return null; } diff --git a/src/Spout/Reader/XLSX/Helper/DateFormatHelper.php b/src/Spout/Reader/XLSX/Helper/DateFormatHelper.php new file mode 100644 index 0000000..4acbef7 --- /dev/null +++ b/src/Spout/Reader/XLSX/Helper/DateFormatHelper.php @@ -0,0 +1,122 @@ + [ + // 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 + $dateFormat = preg_replace('/^(\[\$[^\]]+?\])/i', '', $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); + } +} diff --git a/src/Spout/Reader/XLSX/Helper/SheetHelper.php b/src/Spout/Reader/XLSX/Helper/SheetHelper.php index 23a2b08..5f74f44 100644 --- a/src/Spout/Reader/XLSX/Helper/SheetHelper.php +++ b/src/Spout/Reader/XLSX/Helper/SheetHelper.php @@ -30,6 +30,9 @@ class SheetHelper /** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */ protected $globalFunctionsHelper; + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + protected $shouldFormatDates; + /** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml.rels file */ protected $workbookXMLRelsAsXMLElement; @@ -40,12 +43,14 @@ class SheetHelper * @param string $filePath Path of the XLSX file being read * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings */ - public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper) + public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates) { $this->filePath = $filePath; $this->sharedStringsHelper = $sharedStringsHelper; $this->globalFunctionsHelper = $globalFunctionsHelper; + $this->shouldFormatDates = $shouldFormatDates; } /** @@ -103,7 +108,7 @@ class SheetHelper // In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml" $sheetDataXMLFilePath = '/xl/' . $relationshipNode->getAttribute('Target'); - return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $sheetIndexZeroBased, $sheetName); + return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $this->shouldFormatDates, $sheetIndexZeroBased, $sheetName); } /** diff --git a/src/Spout/Reader/XLSX/Helper/StyleHelper.php b/src/Spout/Reader/XLSX/Helper/StyleHelper.php index 19014b5..462433c 100644 --- a/src/Spout/Reader/XLSX/Helper/StyleHelper.php +++ b/src/Spout/Reader/XLSX/Helper/StyleHelper.php @@ -30,6 +30,25 @@ class StyleHelper /** By convention, default style ID is 0 */ const DEFAULT_STYLE_ID = 0; + /** + * @see https://msdn.microsoft.com/en-us/library/ff529597(v=office.12).aspx + * @var array Mapping between built-in numFmtId and the associated format - for dates only + */ + protected static $builtinNumFmtIdToNumFormatMapping = [ + 14 => 'm/d/yyyy', // @NOTE: ECMA spec is 'mm-dd-yy' + 15 => 'd-mmm-yy', + 16 => 'd-mmm', + 17 => 'mmm-yy', + 18 => 'h:mm AM/PM', + 19 => 'h:mm:ss AM/PM', + 20 => 'h:mm', + 21 => 'h:mm:ss', + 22 => 'm/d/yyyy h:mm', // @NOTE: ECMA spec is 'm/d/yy h:mm', + 45 => 'mm:ss', + 46 => '[h]:mm:ss', + 47 => 'mm:ss.0', // @NOTE: ECMA spec is 'mmss.0', + ]; + /** @var string Path of the XLSX file being read */ protected $filePath; @@ -194,7 +213,7 @@ class StyleHelper */ protected function isNumFmtIdBuiltInDateFormat($numFmtId) { - $builtInDateFormatIds = [14, 15, 16, 17, 18, 19, 20, 21, 22, 45, 46, 47]; + $builtInDateFormatIds = array_keys(self::$builtinNumFmtIdToNumFormatMapping); return in_array($numFmtId, $builtInDateFormatIds); } @@ -235,4 +254,27 @@ class StyleHelper return $hasFoundDateFormatCharacter; } + + /** + * Returns the format as defined in "styles.xml" of the given style. + * NOTE: It is assumed that the style DOES have a number format associated to it. + * + * @param int $styleId Zero-based style ID + * @return string The number format associated with the given style + */ + public function getNumberFormat($styleId) + { + $stylesAttributes = $this->getStylesAttributes(); + $styleAttributes = $stylesAttributes[$styleId]; + $numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID]; + + if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) { + $numberFormat = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId]; + } else { + $customNumberFormats = $this->getCustomNumberFormats(); + $numberFormat = $customNumberFormats[$numFmtId]; + } + + return $numberFormat; + } } diff --git a/src/Spout/Reader/XLSX/Reader.php b/src/Spout/Reader/XLSX/Reader.php index 42c6f02..bcf02cc 100644 --- a/src/Spout/Reader/XLSX/Reader.php +++ b/src/Spout/Reader/XLSX/Reader.php @@ -69,7 +69,7 @@ class Reader extends AbstractReader $this->sharedStringsHelper->extractSharedStrings(); } - $this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper); + $this->sheetIterator = new SheetIterator($filePath, $this->sharedStringsHelper, $this->globalFunctionsHelper, $this->shouldFormatDates); } else { throw new IOException("Could not open $filePath for reading."); } diff --git a/src/Spout/Reader/XLSX/RowIterator.php b/src/Spout/Reader/XLSX/RowIterator.php index d1913bd..c7491ac 100644 --- a/src/Spout/Reader/XLSX/RowIterator.php +++ b/src/Spout/Reader/XLSX/RowIterator.php @@ -59,8 +59,9 @@ class RowIterator implements IteratorInterface * @param string $filePath Path of the XLSX file being read * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings */ - public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper) + public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates) { $this->filePath = $filePath; $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath); @@ -68,7 +69,7 @@ class RowIterator implements IteratorInterface $this->xmlReader = new XMLReader(); $this->styleHelper = new StyleHelper($filePath); - $this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper); + $this->cellValueFormatter = new CellValueFormatter($sharedStringsHelper, $this->styleHelper, $shouldFormatDates); } /** diff --git a/src/Spout/Reader/XLSX/Sheet.php b/src/Spout/Reader/XLSX/Sheet.php index 85a4dc9..a1c7d95 100644 --- a/src/Spout/Reader/XLSX/Sheet.php +++ b/src/Spout/Reader/XLSX/Sheet.php @@ -25,12 +25,13 @@ class Sheet implements SheetInterface * @param string $filePath Path of the XLSX file being read * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml * @param Helper\SharedStringsHelper Helper to work with shared strings + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based) * @param string $sheetName Name of the sheet */ - public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $sheetIndex, $sheetName) + public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates, $sheetIndex, $sheetName) { - $this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper); + $this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $shouldFormatDates); $this->index = $sheetIndex; $this->name = $sheetName; } diff --git a/src/Spout/Reader/XLSX/SheetIterator.php b/src/Spout/Reader/XLSX/SheetIterator.php index 7b3d3dd..f7a3f59 100644 --- a/src/Spout/Reader/XLSX/SheetIterator.php +++ b/src/Spout/Reader/XLSX/SheetIterator.php @@ -24,12 +24,13 @@ class SheetIterator implements IteratorInterface * @param string $filePath Path of the file to be read * @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper * @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings * @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file */ - public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper) + public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates) { // Fetch all available sheets - $sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper); + $sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper, $shouldFormatDates); $this->sheets = $sheetHelper->getSheets(); if (count($this->sheets) === 0) { diff --git a/tests/Spout/Reader/ODS/ReaderTest.php b/tests/Spout/Reader/ODS/ReaderTest.php index 4c95fd9..759d842 100644 --- a/tests/Spout/Reader/ODS/ReaderTest.php +++ b/tests/Spout/Reader/ODS/ReaderTest.php @@ -164,6 +164,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expectedRows, $allRows); } + /** + * @return void + */ + public function testReadShouldSupportFormatDatesAndTimesIfSpecified() + { + $shouldFormatDates = true; + $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.ods', $shouldFormatDates); + + $expectedRows = [ + ['05/19/2016', '5/19/16', '05/19/2016 16:39:00', '05/19/16 04:39 PM', '5/19/2016'], + ['11:29', '13:23:45', '01:23:45', '01:23:45 AM', '01:23:45 PM'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + /** * @return void */ @@ -455,14 +470,16 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * @param string $fileName + * @param bool|void $shouldFormatDates * @return array All the read rows the given file */ - private function getAllRowsForFile($fileName) + private function getAllRowsForFile($fileName, $shouldFormatDates = false) { $allRows = []; $resourcePath = $this->getResourcePath($fileName); $reader = ReaderFactory::create(Type::ODS); + $reader->setShouldFormatDates($shouldFormatDates); $reader->open($resourcePath); foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { diff --git a/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php b/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php index 6ea5b92..92831ab 100644 --- a/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php +++ b/tests/Spout/Reader/XLSX/Helper/CellValueFormatterTest.php @@ -71,7 +71,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->with(123) ->will($this->returnValue(true)); - $formatter = new CellValueFormatter(null, $styleHelperMock); + $formatter = new CellValueFormatter(null, $styleHelperMock, false); $result = $formatter->extractAndFormatNodeValue($nodeMock); if ($expectedDateAsString === null) { @@ -120,7 +120,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->method('shouldFormatNumericValueAsDate') ->will($this->returnValue(false)); - $formatter = new CellValueFormatter(null, $styleHelperMock); + $formatter = new CellValueFormatter(null, $styleHelperMock, false); $formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatNumericCellValue', $value, 0); $this->assertEquals($expectedFormattedValue, $formattedValue); @@ -163,7 +163,7 @@ class CellValueFormatterTest extends \PHPUnit_Framework_TestCase ->with(CellValueFormatter::XML_NODE_INLINE_STRING_VALUE) ->will($this->returnValue($nodeListMock)); - $formatter = new CellValueFormatter(null, null); + $formatter = new CellValueFormatter(null, null, false); $formattedValue = \ReflectionHelper::callMethodOnObject($formatter, 'formatInlineStringCellValue', $nodeMock); $this->assertEquals($expectedFormattedValue, $formattedValue); diff --git a/tests/Spout/Reader/XLSX/Helper/DateFormatHelperTest.php b/tests/Spout/Reader/XLSX/Helper/DateFormatHelperTest.php new file mode 100644 index 0000000..b6d852c --- /dev/null +++ b/tests/Spout/Reader/XLSX/Helper/DateFormatHelperTest.php @@ -0,0 +1,47 @@ +assertEquals($expectedPHPDateFormat, $phpDateFormat); + } +} diff --git a/tests/Spout/Reader/XLSX/ReaderTest.php b/tests/Spout/Reader/XLSX/ReaderTest.php index ee36266..8620ed5 100644 --- a/tests/Spout/Reader/XLSX/ReaderTest.php +++ b/tests/Spout/Reader/XLSX/ReaderTest.php @@ -203,6 +203,21 @@ class ReaderTest extends \PHPUnit_Framework_TestCase $this->assertEquals($expectedRows, $allRows); } + /** + * @return void + */ + public function testReadShouldSupportFormatDatesAndTimesIfSpecified() + { + $shouldFormatDates = true; + $allRows = $this->getAllRowsForFile('sheet_with_dates_and_times.xlsx', $shouldFormatDates); + + $expectedRows = [ + ['1/13/2016', '01/13/2016', '13-Jan-16', 'Wednesday January 13, 16', 'Today is 1/13/2016'], + ['4:43:25', '04:43', '4:43', '4:43:25 AM', '4:43:25 PM'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + /** * @return void */ @@ -503,14 +518,16 @@ class ReaderTest extends \PHPUnit_Framework_TestCase /** * @param string $fileName + * @param bool|void $shouldFormatDates * @return array All the read rows the given file */ - private function getAllRowsForFile($fileName) + private function getAllRowsForFile($fileName, $shouldFormatDates = false) { $allRows = []; $resourcePath = $this->getResourcePath($fileName); $reader = ReaderFactory::create(Type::XLSX); + $reader->setShouldFormatDates($shouldFormatDates); $reader->open($resourcePath); foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) { diff --git a/tests/resources/ods/sheet_with_dates_and_times.ods b/tests/resources/ods/sheet_with_dates_and_times.ods new file mode 100644 index 0000000000000000000000000000000000000000..0e0fb5f89e268891784ccde64becbecf218c4d44 GIT binary patch literal 9988 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 M_&)x4Q$2k8AH1^?od5s; literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/sheet_with_dates_and_times.xlsx b/tests/resources/xlsx/sheet_with_dates_and_times.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..769e03b81942f43b0bce8317d312783abeb55f24 GIT binary patch literal 22453 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