From bfc76c31002334cb3564bf1b9bae68ea8bef66fe Mon Sep 17 00:00:00 2001 From: Adrien Loison Date: Thu, 19 May 2016 18:56:49 -0700 Subject: [PATCH] Option to return formatted dates instead of PHP objects 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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