Compare commits

...
170 Commits
Author SHA1 Message Date
Adrien Loison 4a65466b61 Prevent error when close() called while writer already closed (#402) 2017-03-28 15:07:48 +02:00
Adrien Loison 742780613a Do not add space between text nodes (#401) 2017-03-28 14:36:15 +02:00
Adrien Loison 3128f86769 Remove max line length when reading CSV files (#399) 2017-03-28 13:55:06 +02:00
Adrien Loison d898f91917 Update README.md (#398) 2017-03-27 18:28:12 +02:00
Adrien Loison 33c9d2f2ed Enforce sheet name uniqueness per workbook (#397)
Instead of across all workbooks (in case of multiple spreadsheets being created at the same time).
2017-03-27 17:58:06 +02:00
someson 36d3596f83 Fixing the Bug reading the ODS Sheetnames (#389)
incorrect sequence of arguments creating a Sheet in
Reader/ODS/SheetIterator::current()
Tests added.
2017-02-28 10:25:25 +11:00
Stian Liknes 1ce931a424 Handle empty rows without E_WARNING when filling missing array indexes (#385)
In some cases, reading an XLSX file produce E_WARNING from the max()
call in the fillMissingArray() method. This commit fix the problem
by handling empty rows.
2017-02-19 23:30:35 +13:00
Lito 6f4ddb1569 Fixed processDimensionStartingNode regular expression (#372)
* Fixed processDimensionStartingNode regular expression
* Improved processDimensionStartingNode regular expression
* Removed strict control on processDimensionStartingNode regular expression
2017-01-04 18:50:12 +01:00
Adrien Loison 521f799366 [XLSX] A cell should not contain more than 32,767 characters (#365) 2016-11-29 15:43:38 -08:00
Adrien Loison 984c9c1f67 Improve error message when invalid sheet name set (#364)
Instead of listing all the requirements, only list the requirements that actually failed to be met.
2016-11-29 14:36:57 -08:00
Adrien Loison e255895cff Refactor ODS escaper (#363) 2016-11-28 17:03:13 -08:00
Adrien Loison e276b4378e Fix crash when using associative array with empty row (#354) 2016-11-03 13:45:18 -07:00
Adrien Loison 9ce77405e0 Refactor Style::mergeWith() and StyleHelper::getStyleSectionContent() (#346) 2016-10-19 17:01:19 -07:00
madflow ef4a32eb5e Fix issue 329 (#333)
* Used ENT_DISALLOWED when escaping a string.
* Added workaround for environments where ENT_DISALLOWED is not defined
2016-10-19 09:46:37 -07:00
Adrien Loison 4cb30bc36d Add Scrutinizer Code Quality badge (#345) 2016-10-18 20:29:33 -07:00
Adrien Loison bf616dee90 Bump dev-master version (#344) 2016-10-18 20:03:15 -07:00
Adrien Loison 3a330debb3 Move ReaderCommonOptions class to Common folder (#343) 2016-10-18 16:55:05 -07:00
Adrien Loison a19231fb68 Introduce XMLProcessor to reduce ODS,XLSX readers' complexity (#342) 2016-10-18 16:28:26 -07:00
Adrien Loison 73d5d0ea17 Remove text suffix in XLSX date formats (#341)
Some date formats have a text suffix, e.g. "mm/dd/yy;@". We should remove the ";...@" part.
2016-10-18 11:55:36 -07:00
Adrien Loison 687c321363 Refactor SharedStringsHelper::extractSharedStrings (#340) 2016-10-18 00:03:15 -07:00
Adrien Loison 2fa01cd838 Remove unused SimpleXMLElement::children() method (#339) 2016-10-17 22:49:37 -07:00
Adrien Loison 752f4bf64e Add ReaderOptions for all readers (#338)
Instead of passing every single option down the chain
2016-10-17 22:41:36 -07:00
Adrien Loison b61323d7d2 Remove XLSX references in ODS Writer (#337) 2016-10-17 11:54:49 -07:00
Adrien Loison 179ab483d6 Empty rows do not need to be written for XLSX files (#336) 2016-10-17 11:40:52 -07:00
Adrien Loison 2fafb63115 ODS Reader should support num-rows-repeated for non empty rows (#335) 2016-10-17 10:51:12 -07:00
Adrien Loison 5ef5647558 Make getConcreteSheetIterator() protected (#334) 2016-10-17 10:26:37 -07:00
Adrien Loison 0978d340f0 Option to keep empty rows (#331)
* Add option to preserve empty rows when reading an XLSX file
* Add option to preserve empty rows when reading a CSV file
* Add option to preserve empty rows when reading an ODS file
2016-10-17 10:20:02 -07:00
Adrien Loison 77178122c3 Fix output file deletion after exception thrown on write (#328)
For relative paths, it would not work as the FileSystemHelper would not allow deleting a file that's not part of its base folder.
2016-10-12 11:15:07 -07:00
Adrien Loison 23f8cc4f05 Temp files should be deleted when an exception is thrown (#327)
If an exception is thrown while writing data, instead of letting the developer handle this situation gracefully, Spout can attempt to delete all the temporary files that were created so far, as well as the output file as it won't be completed and therefore corrupted.
2016-10-11 15:08:37 -07:00
Adrien Loison 442a9837f1 Removing license badge (#320)
As Github now provides this information in the project's header
2016-09-30 10:11:05 -07:00
Adrien Loison cc07072cbb Better support for Date custom format (#316)
- To determine if a style should apply a date format, the presence of "applyNumberFormat" attribute on the "cellXfs" section of styles.xml is now optional. We only look at the "numFmtId" attribute (but early return if "applyNumberFormat" is set to "0").
- The format code can contain lowercase AND now uppercase characters as its pattern.
- "General" format code used as a custom format is now supported. It seems to be used by a bunch of programs...
2016-09-24 10:46:42 -07:00
Hastegan 30aa1b87e2 Fix boolean notation in PHPDoc (#314) 2016-09-16 15:12:58 -07:00
Hastegan ddb7365a79 Add a way to disable text wrapping through the api (Fix #247) (#311)
* Add a way to disable text wrapping (Fix #247)
* Fix PHPDoc boolean notation
* Fix PHPDoc param notation
2016-09-16 11:24:36 -07:00
Adrien Loison a07a96f523 Bump dev-master version (#309)
Bump to `2.7.x-dev` as 2.6.0 just got released
2016-09-08 09:34:43 -07:00
Adrien Loison 3e0afd858f Apply custom style to empty cells if needed (#307)
Fixes #295

If a row should be written with a custom style, the handling of empty cells should change.
Instead of being skipped entirely, empty cells will be applied the custom style, if this style has custom background color or borders.
If not, then the cell definition can still be skipped.
2016-09-07 17:04:31 -07:00
Adrien Loison d4e57b1f0d Add consistency using getCellXML() (#306) 2016-09-06 19:47:18 -07:00
Adrien Loison 435a9a016e Improve XLSX Escaper performance (#305) 2016-09-03 11:04:25 -07:00
Stefan 5e7a1745ac Initial extraction of method getCellXml from addRow (#302) 2016-09-02 19:48:10 -07:00
madflow 277c353984 Fix #297 (#299) 2016-08-30 10:56:52 -07:00
madflow ff2d54cc8d Fix #276, some general refinement (#289)
* Fix #276, some general refinement

* Failing test #267

* Fixed shared border definitions across different styles #267

* Fix finding the correct borderId
2016-08-23 19:57:57 -07:00
Adrien Loison c94694cb60 API to set default row style (#290) 2016-08-16 21:18:44 -07:00
madflow 584121d478 Add background-color to styles (#211)
Removed default background color, cosmetics
Remove default background color
reuse bg colors
Cosmetics
Moved reusing fills to XLSX StyleHelper
Tests and inline doc
2016-08-10 13:11:47 -07:00
Adrien Loison b2dc0c3fa9 Fix tests on Windows (#288) 2016-08-10 12:09:37 -07:00
Adrien Loison 7f65993c87 Spout should be able to read prefixed styles.xml (#287) 2016-08-09 20:53:40 -07:00
Adrien Loison b75a3e34fc XLSX cells containing date values should respect shouldFormatDate option (#282)
Return the ISO 8601 date string directly if option is set
2016-07-20 20:12:00 -07:00
Adrien Loison 82605ab57b Do not return anything from constructor (#275) 2016-07-14 10:53:44 -07:00
madflow 7a613eed8c Border ordering (#273)
* Fix #271

* Minor fix for quick copy+paste

* Added link to issue #271
2016-07-13 11:45:37 -07:00
Adrien Loison 54a1a09e29 Bump dev-master version (#270) 2016-07-11 20:07:56 +02:00
Adrien Loison a8eb7ad39c Shared strings table without uniqueCount and count should work (#269)
Use file based strategy in this case
2016-07-11 19:03:37 +02:00
Adrien Loison ffea8871a6 Add support for missing cell reference (#268)
When describing a cell, the cell reference (r="A1") is optional.
When not present, we should just increment the index of the last processed row.
2016-07-11 18:15:55 +02:00
Marie b02d13cd40 Set BOM as optional on CSV writer (#265) 2016-07-07 15:21:41 +02:00
rlukasz aa25678a83 Update RowIterator.php (#263) 2016-07-04 11:31:03 +02:00
Adrien Loison 192659cb24 Update README.md (#256) 2016-06-19 20:20:49 -07:00
madflow e30bc37448 First stab at #135 - Add borders (#200)
Fixes #135

Added border support for ODS and XLSX files.
Updated README.
2016-06-18 23:36:10 -07:00
Adrien Loison 1891c0b053 Fix XLSX reading when shared strings is missing the uniqueCount attribute (#255)
Use "count" attribute as a fallback
2016-06-16 10:06:11 -07:00
madflow a43c13a36f Fix #252 (#253) 2016-06-14 10:02:01 -07:00
Adrien Loison dc31d6e8c2 Update .scrutinizer.yml (#248) 2016-06-09 10:52:01 -07:00
Adrien Loison 8edd8e2401 Clear previous output when openToBrowser() called (#243)
If any character has been outputted before, the generated file will be corrupted.
2016-06-08 13:53:41 -07:00
madflow cd38ba093e Fix #245 (#246) 2016-06-08 09:50:00 -07:00
Pavel Zyukin 70c81e809f Add the ability to pass an array with various keys to addRows() (#240) 2016-06-03 08:09:37 -07:00
Adrien Loison 1d3a9f939c Convert escapers to singletons (#239) 2016-05-30 13:55:21 -07:00
Ingmar Runge efebfb2bc2 CellValueFormatterTest: fix expectations for 32bit PHP (#234) 2016-05-30 10:25:30 -07:00
Adrien Loison 251c0bebc1 Adding open_file_in_zip() helper function to XMLReader (#238) 2016-05-29 23:22:57 -07:00
Adrien Loison 03866a6604 Support XLSX with prefixed XML files (#237)
While the standard is not to have prefixes, some XLSX files have XML files containing a prefix.
Microsoft has a tool that generates such files: https://msdn.microsoft.com/en-us/library/office/gg278316.aspx
2016-05-29 22:16:59 -07:00
Adrien Loison 2ed30321b4 ODS Writer should accept associative arrays (#232) 2016-05-25 19:59:18 -07:00
Adrien Loison 2c80b1f23a XLSX Reader should add a space between text nodes (#229)
When a cell contains multiple text nodes, the cell value is currently obtained by concatenating the value of each text node.
Instead, values should still be concatenated but a space should be added in between.
2016-05-23 14:15:48 -07:00
Adrien Loison a24e794177 Update README.md (#228)
Fixed the amount of memory used by Spout, as it used to take into account the memory used by PHPUnit.
2016-05-23 13:36:07 -07:00
Adrien Loison 104cd9b811 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.
2016-05-20 16:08:35 -07:00
Adrien Loison a6a6b158de Pin PHPUnit version to keep support for PHP5.4 (#227) 2016-05-20 16:00:22 -07:00
madflow 2d923c7e46 Fix issue #218 (#222) 2016-05-20 09:32:47 -07:00
Adrien Loison b4724906c4 Add support for cells formatted as time (#224)
Cells formatted as "time" have values between 0 and 1. These values used to be considered as invalid.
Note: this uses what was started in #202
2016-05-19 13:10:47 -07:00
Adrien Loison bb20d2e6bb Update Scrutinizer dependency and code coverage (#223) 2016-05-19 11:25:00 -07:00
Adrien Loison b8fd789ac0 Retrieve XLSX sheets in order of appearance (#220)
Instead of relying on the ID, sheets should be retrieved in the order they appear in the file.
Workbook.xml describes the correct order.
This allows the reader to read data in the correct order when sheets have been manually moved after creation.
2016-05-19 10:37:48 -07:00
Adrien Loison 5a7c2c1262 Handle General number format as non date (#221)
If the number format is set to General (id = 0), do no try to format the value as a date
2016-05-19 09:40:12 -07:00
Adrien Loison e9cd7a397e Merge pull request #199 from box/check_valid_resource_on_close
Check file handle is valid before manipulating it
2016-04-12 11:03:15 -07:00
Adrien Loison 8bb42ebc23 Check file handle is valid before manipulating it
This fixes issues when something went wrong on reader/writer init and the developer wants to close the reader/writer.
The file handle may not be defined so we need to add a check for it, before actually using it.
2016-04-12 10:36:59 -07:00
Adrien Loison 71a6f6a937 Merge pull request #196 from madflow/trim-inline-strings
Fix #195
2016-04-09 13:20:48 -07:00
madflow 616925148e Renamed xlsx file, #195 2016-04-07 08:58:54 +02:00
madflow 6f0f7c9690 Fix #195 2016-04-06 22:00:47 +02:00
Adrien Loison b69e28050b Merge pull request #187 from skeleton/issue-183
Fix line breaks on CSV reader
2016-03-23 17:02:42 -07:00
skeleton d6e8fe4b54 Fix line breaks on CSV reader 2016-03-23 23:26:49 +01:00
Adrien Loison e321f30c3b Merge pull request #191 from box/close_file_pointer_when_done_writing
Writers did not close written file pointer
2016-03-23 10:45:42 -07:00
Adrien Loison c31373fb1a Writers did not close written file pointer 2016-03-23 10:21:27 -07:00
Adrien Loison 6c57125c0c Merge pull request #189 from madflow/ods-missing-values
Fixes for #184
2016-03-21 10:24:38 -07:00
madflow 30837f869d Coding style and typos 2016-03-20 08:46:30 +01:00
madflow e60054f3c4 More explicit rule for ignoring empty placeholder cells in Excel ODS #184 2016-03-19 11:34:32 +01:00
madflow 3ee7099c95 Fix zeros treated as missing values #184 2016-03-19 11:34:32 +01:00
madflow 2b1160bb33 Tests for #184 2016-03-19 11:34:31 +01:00
Adrien Loison 049fd990b9 Merge pull request #188 from box/stream_wrapper_support
Custom stream wrapper support
2016-03-18 18:09:22 -07:00
Adrien Loison d2ac54c578 Custom stream wrapper support
Added support for custom stream wrappers, such as "fly" or "s3".
Support is determined per reader.
2016-03-18 17:09:13 -07:00
Adrien Loison 0c90d102ef Merge pull request #179 from lichunqiang/gitattributes
Add .gitattributes
2016-02-21 18:11:03 -08:00
lichunqiang e39dcb3847 Add .gitattributes 2016-02-22 09:48:28 +08:00
Adrien Loison 10d1140a95 Merge pull request #175 from IlyaBakhlin/master
Fixing the boolean bug.
2016-02-15 16:43:30 -08:00
Ilya Bakhlin 48debbcbca Simplifying the fix. 2016-02-15 22:11:04 +01:00
Ilya Bakhlin f4d6fb87ee Fixing the boolean bug. 2016-02-15 15:55:46 +01:00
Adrien Loison 771afcb5f1 Merge pull request #173 from sfichera/master
Support for variable EOL for CSV
2016-02-14 00:20:48 -08:00
Sebastian Fichera 86e26632f6 Added test case for custom EOL characters... 2016-02-12 16:30:18 -06:00
Sebastian Fichera 8614f79da3 Minor fixes in order to be ok with naming conventions and code documentation... 2016-02-11 17:51:24 -06:00
Sebastian Fichera 4827e56cac Added new public function usage to docs... 2016-02-11 17:15:48 -06:00
Sebastian Fichera 03e85ffc21 Added EOL configuration support while reading CSV files...
Enhancement for #172 issue…
2016-02-11 17:12:54 -06:00
Adrien Loison e4cc8b4eaa Merge pull request #169 from welcoMattic/patch-1
Update README.md
2016-01-27 13:26:18 -08:00
Mathieu Santo Stefano--Féron 73341d06a9 Update README.md
Fix typo
2016-01-27 18:21:06 +01:00
Adrien Loison 209372462b Merge pull request #167 from box/fix_int_float_value_formatters
Fix CellValueFormatter for numeric values
2016-01-14 11:29:01 -08:00
Adrien Loison 4a5da2ad74 Fix CellValueFormatter for numeric values
The value passed into the format() function is coming from an XML file and has never been coerced.
Therefore, when checking is_int($value), the check always returns false - because it's a string.
Changing the check fixes the issue and Spout now correctly parses large numbers.
2016-01-14 11:11:31 -08:00
Adrien Loison c48c07db99 Merge pull request #165 from box/support_xlsx_sheets_random_order
Support XLSX that are defined in random order
2016-01-08 08:50:49 -08:00
Adrien Loison a804be4844 Support XLSX that are defined in random order
Some software generate [Content_Types].xml file with sheets definition in random order.
Instead of having the first sheet (id = 1) defined first, it may be defined in 3rd position.
Therefore, to read the file in the correct order, sheets order need to be fixed.
2016-01-08 08:42:29 -08:00
Adrien Loison 4bfbb41c95 Merge pull request #162 from box/update_branch_alias
Update dev-master branch alias
2015-12-22 12:22:15 +01:00
Adrien Loison 61b71bf379 Update dev-master branch alias 2015-12-22 12:17:15 +01:00
Adrien Loison 3c3294061a Merge pull request #161 from garak/patch-1
support branch alias
2015-12-21 12:05:16 +01:00
Massimiliano Arione 1fde6d836a support branch alias 2015-12-21 10:54:15 +01:00
Adrien Loison 05489cda88 Merge pull request #160 from box/reference-wiki
Update README.md
2015-12-21 10:52:46 +01:00
Adrien Loison 128db45f22 Update README.md 2015-12-21 10:45:02 +01:00
Adrien Loison 9a85d84a2e Merge pull request #155 from KiNgMaR/Fix-Dates-Beyond-2037
XLSX Date Support / Fix for years beyond 2037
2015-12-17 10:59:45 -08:00
Ingmar Runge 4407cffeff XLSX Date Support / Test + Fix for years beyond 2037
This also fixes years < 1902 on 32-bit PHP systems.
2015-12-17 08:52:15 +01:00
Adrien Loison 8d27b3097d Merge pull request #154 from box/fix_font_color_check
Fix hasSetFontColor check
2015-12-08 10:35:49 -08:00
Adrien Loison 2c6cb1ffe5 Fix hasSetFontColor check 2015-12-08 10:31:16 -08:00
Adrien Loison 22daea5f9a Merge pull request #153 from box/improve_zip_for_mime_detection
Improve ZIP interface for better mime detection
2015-12-05 19:13:47 -08:00
Adrien Loison ed0e8f79cc Improve ZIP interface for better mime detection
The ZipHelper interface is now more generic and allow single files to be added.
It supports adding uncompressed files (for PHP7+), which is required to have the mime detection magic work with ODS files.
Also fixed a few issues with the created ODS file (thanks to https://odf-validator.rhcloud.com/)
2015-12-05 18:06:13 -08:00
Adrien Loison 44d72d8245 Merge pull request #152 from box/proper_mime_type_detection
Proper mime type detection for XLSX files
2015-12-05 00:30:28 -08:00
Adrien Loison 728dd3b399 Proper mime type detection for XLSX files
Heuristics to detect proper mime type for XLSX files expect to see
certain files at the beginning of the XLSX archive. The order in which
the XML files are added therefore matters.
Specifically, "[Content_Types].xml" should be added first, followed by the
files located in the "xl" folder (at least 1 file).
2015-12-05 00:20:40 -08:00
Adrien Loison 05a9a1b60a Merge pull request #146 from box/update_perf_tests_doc
Update README.md
2015-11-13 10:36:40 -08:00
Adrien Loison a76624a721 Update README.md 2015-11-13 10:14:10 -08:00
Adrien Loison 90cbb7b5a6 Merge pull request #145 from box/speed_up
Various speed improvements
2015-11-12 14:09:52 -08:00
Adrien Loison f55520661e Various speed improvements 2015-11-12 13:55:25 -08:00
Adrien Loison cb5dae22e4 Merge pull request #143 from box/xlsx_skip_empty_cells_on_write
[XLSX] Skip empty cells on write
2015-11-10 17:26:26 -08:00
Adrien Loison 2f6193ce20 [XLSX] Skip empty cells on write
Since cells are referenced by their coordinates (A2, B4...), it is not necessary to write empty cells.
This will reduce the final size of the generated XML and therefore XLSX file.
2015-11-10 17:17:54 -08:00
Adrien Loison 582da8403d Merge pull request #139 from box/fix_phpdoc
Fix PHPDoc to work with Augmented Types
2015-11-05 15:52:56 -08:00
Adrien Loison 8b666fc6cd Fix PHPDoc to work with Augmented Types 2015-11-05 15:48:26 -08:00
Adrien Loison f8aab6eefd Merge pull request #138 from box/increase_entropy_uniqid
Increase entropy of uniqid
2015-11-05 10:44:47 -08:00
Adrien Loison c4c6dddb20 Increase entropy of uniqid
This is to avoid conflicts if two folders are being created at the exact same time.
2015-11-05 10:40:19 -08:00
Adrien Loison 6c97141679 Merge pull request #133 from philipbrown/patch-1
Update README.md
2015-10-27 11:44:01 -07:00
Philip Brown 9c8cc05364 Update README.md 2015-10-27 18:34:58 +00:00
Adrien Loison 2a9400dfca Merge pull request #131 from box/better_date_support_xlsx
Better date support
2015-10-23 16:26:00 -07:00
Adrien Loison 8ef6bdac62 Better date support
Although Excel has a Date type, older Excel versions use numeric values to store dates.
The value represents the number of days since Jan 1st, 1900.
The only way to tell if the value is a number or a date is to look at the styles.xml and check if the cell has date formatting.
2015-10-23 16:04:38 -07:00
Adrien Loison 46c246e6a4 Merge pull request #130 from box/highlight_xlsx_over_csv
Highlight XLSX over CSV in documentation
2015-10-23 11:00:04 -07:00
Adrien Loison 8fd606ae4f Highlight XLSX over CSV in documentation 2015-10-23 10:52:12 -07:00
Adrien Loison 6767386daf Merge pull request #128 from box/increase_max_read_bytes_for_csv
Increase max read bytes per line for CSV
2015-10-22 13:39:18 -07:00
Adrien Loison 3395d3abb3 Increase max read bytes per line for CSV
Specify a bigger value than the default one to support long lines.
2015-10-22 10:54:12 -07:00
Adrien Loison d1c4d563c1 Merge pull request #126 from box/fix_xmlreader_open_issue_windows
Fix "Cannot open file" issue with XMLReader::open on Windows
2015-10-15 10:41:12 -07:00
Adrien Loison 01cc8b3da0 Fix "Cannot open file" issue with XMLReader::open on Windows
This occurred when using relative paths. Using realpath() solves this issue.
2015-10-15 09:19:47 -07:00
Adrien Loison 45980195cd Merge pull request #121 from box/add_test_for_empty_csv
Fix infinite loop for CSV with all lines empty
2015-10-05 21:15:11 +02:00
Adrien Loison a1a1077677 Fix infinite loop for CSV with all lines empty
Only occured with multiline CSV files
2015-10-05 21:10:41 +02:00
Adrien Loison 16d0290a17 Merge pull request #115 from box/add_api_phpdoc
Added @api tag for documentation
2015-09-04 12:52:32 -07:00
Adrien Loison f8c39287ad Added @api tag for documentation 2015-09-04 11:43:01 -07:00
Adrien Loison 118810de22 Merge pull request #114 from box/remove_duplicate_method
Remove duplicated method
2015-09-04 11:42:52 -07:00
Adrien Loison 3720c9ea1c Remove duplicated method 2015-09-04 11:37:44 -07:00
Adrien Loison 6124528161 Merge pull request #113 from box/all_ods_cell_types
Support all ODS cell types
2015-09-02 14:30:19 -07:00
Adrien Loison 818ec2488c Support all ODS cell types
Including:
- date / time
- currency
- percentage
- void

And improved support for boolean
2015-09-02 14:03:38 -07:00
Adrien Loison aa7978146f Merge pull request #112 from box/cell_formatters
Moved cell value formatting logic into formatters
2015-09-02 00:21:25 -07:00
Adrien Loison d6e707c5fe Moved cell value formatting logic into formatters 2015-09-02 00:12:59 -07:00
Adrien Loison 03ae367fb3 Update composer.json 2015-09-02 00:11:36 -07:00
Adrien Loison ac48dce334 Merge pull request #110 from box/ods_documentation
ODS documentation
2015-09-01 23:23:10 -07:00
Adrien Loison e147c580ca ODS documentation 2015-09-01 23:11:07 -07:00
Adrien Loison 7615acf398 Random fixes 2015-09-01 23:04:57 -07:00
Adrien Loison 1d38bb00c5 Merge pull request #108 from box/remove_xml_indentation
Remove XML indentation
2015-09-01 12:52:34 -07:00
Adrien Loison 79d947a6b3 Merge pull request #107 from box/remove_is_inside_row
Remove unused isInsideRowTag
2015-09-01 11:30:48 -07:00
Adrien Loison dd3cc5bf47 Remove XML indentation 2015-09-01 11:24:13 -07:00
Adrien Loison 0a5be41c53 Remove unused isInsideRowTag 2015-09-01 10:59:33 -07:00
Adrien Loison d2ba6d884c Merge pull request #106 from box/ods_reader
ODS Reader
2015-09-01 10:57:59 -07:00
Adrien Loison e4154dfdc3 ODS Reader
Spout can now read ODS files.
It's on par with the XLSX reader. The only difference is that the row iterator cannot be rewound.
It supports the different output formats from LibreOffice and Excel, skipping extra rows/cells if needed.
2015-09-01 10:53:49 -07:00
Adrien Loison 3f0016f753 Merge pull request #103 from box/use_number_columns_repeated
Use number-columns-repeated in ODS writer
2015-08-31 12:55:37 -07:00
Adrien Loison bc009a3241 Use number-columns-repeated in ODS writer
The number-columns-repeated usage may reduce the size of the outputted XML file by merging repeated values together.
2015-08-31 12:03:28 -07:00
Adrien Loison 0f8e7a8f58 Merge pull request #102 from box/improve_ods_writer
Improve ODS Writer
2015-08-31 10:02:06 -07:00
Adrien Loison 156fd29a44 Improve ODS Writer
Remove num-columns-repeated and num-rows-repeated as it does not seem to be required (LibreOffice does not add them).
This greatly simplifies the writer and the XML output.
Added some optional attributes to help LibreOffice with cell values caching ("calcext")
2015-08-31 09:55:17 -07:00
Adrien Loison ef171910b9 Merge pull request #99 from box/ods_writer
ODS writer
2015-08-28 20:28:08 -07:00
Adrien Loison 5949cb2442 ODS writer
Added ODS writer
Refactored XLSX writer to abstract some pieces into an abstract multi-sheets writer
Created an abstract style helper
Moved shared components around
2015-08-28 20:19:45 -07:00
Adrien Loison 108a92e259 Merge pull request #97 from box/add_bool_type_xlsx_writer
Add boolean type when writing XLSX cell
2015-08-25 23:53:46 -07:00
Adrien Loison 9fb1427944 Add boolean type when writing XLSX cell 2015-08-25 23:36:24 -07:00
Adrien Loison a8f1fba854 Merge pull request #96 from box/throw_if_xlsx_setters_called_after_open
Throw if XLSX Writer configured after being opened
2015-08-24 10:55:59 -07:00
Adrien Loison 1812b4f996 Throw if XLSX Writer configured after being opened 2015-08-24 10:52:12 -07:00
179 changed files with 10514 additions and 2219 deletions

No files matched your search

+11
View File
@@ -0,0 +1,11 @@
# Ignore all test and documentation for archive
/tests export-ignore
/.editorconfig export-ignore
/.gitattributes export-ignore
/.gitignore export-ignore
/.scrutinizer.yml export-ignore
/.travis.yml export-ignore
/composer.lock export-ignore
/CONTRIBUTING.md export-ignore
/logo.png export-ignore
/phpunit.xml export-ignore
+6 -3
View File
@@ -15,10 +15,13 @@ tools:
php_pdepend: true
php_loc:
enabled: true
excluded_dirs: [vendor, tests]
php_cpd:
filter:
paths: ['src']
php_cpd: false # Must be disabled to use php_sim instead
php_sim:
enabled: true
excluded_dirs: [vendor, tests]
filter:
paths: ['src']
build_failure_conditions:
- 'project.metric("scrutinizer.quality", < 9)' # Code Quality Rating drops below 9
+3 -2
View File
@@ -16,7 +16,8 @@ install:
script:
- mkdir -p build/logs
- php vendor/bin/phpunit --coverage-clover build/logs/clover.xml
- php vendor/bin/phpunit --coverage-clover=build/logs/coverage.clover
after_script:
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php vendor/bin/ocular code-coverage:upload --format=php-clover build/logs/clover.xml; fi
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then wget https://scrutinizer-ci.com/ocular.phar; fi
- if [[ $TRAVIS_PHP_VERSION != 'hhvm' && $TRAVIS_PHP_VERSION != '7.0' ]]; then php ocular.phar code-coverage:upload --format=php-clover build/logs/coverage.clover; fi
+138 -97
View File
@@ -2,13 +2,13 @@
[![Latest Stable Version](https://poser.pugx.org/box/spout/v/stable)](https://packagist.org/packages/box/spout)
[![Project Status](http://opensource.box.com/badges/active.svg)](http://opensource.box.com/badges)
[![Build Status](https://travis-ci.org/box/spout.svg?branch=master)](http://travis-ci.org/box/spout)
[![Build Status](https://travis-ci.org/box/spout.svg?branch=master)](https://travis-ci.org/box/spout)
[![Scrutinizer Code Quality](https://scrutinizer-ci.com/g/box/spout/badges/quality-score.png?b=master)](https://scrutinizer-ci.com/g/box/spout/?branch=master)
[![Code Coverage](https://scrutinizer-ci.com/g/box/spout/badges/coverage.png?b=master)](https://scrutinizer-ci.com/g/box/spout/?branch=master)
[![Total Downloads](https://poser.pugx.org/box/spout/downloads)](https://packagist.org/packages/box/spout)
[![License](https://poser.pugx.org/box/spout/license)](https://packagist.org/packages/box/spout)
Spout is a PHP library to read and write CSV and XLSX files, in a fast and scalable way.
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 10MB).
Spout is a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
Contrary to other file readers or writers, it is capable of processing very large files while keeping the memory usage really low (less than 3MB).
Join the community and come discuss about Spout: [![Gitter](https://badges.gitter.im/Join%20Chat.svg)](https://gitter.im/box/spout?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge)
@@ -18,16 +18,9 @@ Join the community and come discuss about Spout: [![Gitter](https://badges.gitte
Spout can be installed directly from [Composer](https://getcomposer.org/).
Add "box/spout" as a dependency in your project's composer.json file:
```json
"require": {
"box/spout": "~2.0"
}
Run the following command:
```
Then run the install command from Composer:
```
php composer.phar install
$ composer require box/spout
```
### Manual installation
@@ -56,75 +49,57 @@ require_once '[PATH/TO]/src/Spout/Autoloader/autoload.php'; // don't forget to c
### Reader
#### How to read a CSV file?
Regardless of the file type, the interface to read a file is always the same:
```php
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\Common\Type;
$reader = ReaderFactory::create(Type::CSV);
$reader = ReaderFactory::create(Type::XLSX); // for XLSX files
//$reader = ReaderFactory::create(Type::CSV); // for CSV files
//$reader = ReaderFactory::create(Type::ODS); // for ODS files
$reader->open($filePath);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
// do stuff
// do stuff with the row
}
}
$reader->close();
```
#### How to read a XLSX file?
```php
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\Common\Type;
$reader = ReaderFactory::create(Type::XLSX);
$reader->open($filePath);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
// do stuff
}
}
$reader->close();
```
If there are multiple sheets in the file, the reader will read through all of them sequentially.
If there are multiple sheets in the file, the reader will read all of them sequentially.
### Writer
### How to create a CSV file?
As with the reader, there is one common interface to write data to a file:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::CSV);
$writer = WriterFactory::create(Type::XLSX); // for XLSX files
//$writer = WriterFactory::create(Type::CSV); // for CSV files
//$writer = WriterFactory::create(Type::ODS); // for ODS files
$writer->openToFile($filePath); // write data to a file or to a PHP stream
//$writer->openToBrowser($fileName); // stream data directly to the browser
$writer->addRow($singleRow); // add a row at a time
$writer->close();
```
### How to create a XLSX file?
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::XLSX);
$writer->openToBrowser($fileName); // stream data directly to the browser
$writer->addRows($multipleRows); // add multiple rows at a time
$writer->close();
```
For XLSX files, the number of rows per sheet is limited to 1,048,576 (see [Office OpenXML specs](http://office.microsoft.com/en-us/excel-help/excel-specifications-and-limits-HP010073849.aspx)). By default, once this limit is reached, the writer will automatically create a new sheet and continue writing data into it.
For XLSX and ODS files, the number of rows per sheet is limited to 1,048,576. By default, once this limit is reached, the writer will automatically create a new sheet and continue writing data into it.
## Advanced usage
If you are looking for how to perform some common, more advanced tasks with Spout, please take a look at the [Wiki](https://github.com/box/spout/wiki). It contains code snippets, ready to be used.
### Configuring the CSV reader and writer
It is possible to configure both the CSV reader and writer to specify the field separator as well as the field enclosure:
@@ -135,6 +110,7 @@ use Box\Spout\Common\Type;
$reader = ReaderFactory::create(Type::CSV);
$reader->setFieldDelimiter('|');
$reader->setFieldEnclosure('@');
$reader->setEndOfLineCharacter("\r");
```
Additionally, if you need to read non UTF-8 files, you can specify the encoding of your file this way:
@@ -142,13 +118,22 @@ Additionally, if you need to read non UTF-8 files, you can specify the encoding
$reader->setEncoding('UTF-16LE');
```
The writer always generate CSV files encoded in UTF-8, with a BOM.
By default, the writer generates CSV files encoded in UTF-8, with a BOM.
It is however possible to not include the BOM:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
### Configuring the XLSX writer
$writer = WriterFactory::create(Type::CSV);
$writer->setShouldAddBOM(false);
```
### Configuring the XLSX and ODS readers and writers
#### Row styling
It is possible to apply some formatting options to a row. Spout supports fonts as well as alignment styles.
It is possible to apply some formatting options to a row. Spout supports fonts, background, borders as well as alignment styles.
```php
use Box\Spout\Common\Type;
@@ -161,6 +146,7 @@ $style = (new StyleBuilder())
->setFontSize(15)
->setFontColor(Color::BLUE)
->setShouldWrapText()
->setBackgroundColor(Color::YELLOW)
->build();
$writer = WriterFactory::create(Type::XLSX);
@@ -173,21 +159,82 @@ $writer->addRowsWithStyle($multipleRows, $style); // style will be applied to al
$writer->close();
```
Adding borders to a row requires a ```Border``` object.
```php
use Box\Spout\Common\Type;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderBuilder;
use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\StyleBuilder;
use Box\Spout\Writer\WriterFactory;
$border = (new BorderBuilder())
->setBorderBottom(Color::GREEN, Border::WIDTH_THIN, Border::STYLE_DASHED)
->build();
$style = (new StyleBuilder())
->setBorder($border)
->build();
$writer = WriterFactory::create(Type::XLSX);
$writer->openToFile($filePath);
$writer->addRowWithStyle(['Border Bottom Green Thin Dashed'], $style);
$writer->close();
```
Spout will use a default style for all created rows. This style can be overridden this way:
```php
$defaultStyle = (new StyleBuilder())
->setFontName('Arial')
->setFontSize(11)
->build();
$writer = WriterFactory::create(Type::XLSX);
$writer->setDefaultRowStyle($defaultStyle)
->openToFile($filePath);
```
Unfortunately, Spout does not support all the possible formatting options yet. But you can find the most important ones:
Category | Property | API
----------|---------------|---------------------------------------
Font | Bold | `StyleBuilder::setFontBold()`
| Italic | `StyleBuilder::setFontItalic()`
| Underline | `StyleBuilder::setFontUnderline()`
| Strikethrough | `StyleBuilder::setFontStrikethrough()`
| Font name | `StyleBuilder::setFontName('Arial')`
| Font size | `StyleBuilder::setFontSize(14)`
| Font color | `StyleBuilder::setFontSize(Color::BLUE)`<br>`StyleBuilder::setFontSize(Color::rgb(0, 128, 255))`
Alignment | Wrap text | `StyleBuilder::setShouldWrapText()`
| Category | Property | API
|-----------|---------------|---------------------------------------
| Font | Bold | `StyleBuilder::setFontBold()`
| | Italic | `StyleBuilder::setFontItalic()`
| | Underline | `StyleBuilder::setFontUnderline()`
| | Strikethrough | `StyleBuilder::setFontStrikethrough()`
| | Font name | `StyleBuilder::setFontName('Arial')`
| | Font size | `StyleBuilder::setFontSize(14)`
| | Font color | `StyleBuilder::setFontColor(Color::BLUE)`<br>`StyleBuilder::setFontColor(Color::rgb(0, 128, 255))`
| Alignment | Wrap text | `StyleBuilder::setShouldWrapText(true|false)`
#### New sheet creation
#### Strings storage
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:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::ODS);
$writer->setShouldCreateNewSheetsAutomatically(true); // default value
$writer->setShouldCreateNewSheetsAutomatically(false); // will stop writing new data when limit is reached
```
#### Using custom temporary folder
Processing XLSX and ODS files require temporary files to be created. By default, Spout will use the system default temporary folder (as returned by `sys_get_temp_dir()`). It is possible to override this by explicitly setting it on the reader or writer:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::XLSX);
$writer->setTempFolder($customTempFolderPath);
```
#### Strings storage (XLSX writer)
XLSX files support different ways to store the string values:
* Shared strings are meant to optimize file size by separating strings from the sheet representation and ignoring strings duplicates (if a string is used three times, only one string will be stored)
@@ -208,33 +255,23 @@ $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!
#### New sheet creation
#### 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.
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:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::XLSX);
$writer->setShouldCreateNewSheetsAutomatically(true); // default value
$writer->setShouldCreateNewSheetsAutomatically(false); // will stop writing new data when limit is reached
$reader = ReaderFactory::create(Type::XLSX);
$reader->setShouldFormatDates(false); // default value
$reader->setShouldFormatDates(true); // will return formatted dates
```
#### Using custom temporary folder
### Playing with sheets
Processing XLSX files require temporary files to be created. By default, Spout will use the system default temporary folder (as returned by sys_get_temp_dir()). It is possible to override this by explicitly setting it on the reader or writer:
```php
use Box\Spout\Writer\WriterFactory;
use Box\Spout\Common\Type;
$writer = WriterFactory::create(Type::XLSX);
$writer->setTempFolder($customTempFolderPath);
```
### Playing with XLSX sheets
When creating a XLSX file, it is possible to control in which sheet the data will be written to.
At any point, you can retrieve the current sheet and set the current sheet:
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:
```php
$firstSheet = $writer->getCurrentSheet();
$writer->addRow($rowForSheet1); // writes the row to the first sheet
@@ -264,7 +301,7 @@ $sheetName = $sheet->getName();
// Customizing the sheet name when writing
$sheet = $writer->getCurrentSheet();
$sheetName = $sheet->setName('My custom name');
$sheet->setName('My custom name');
```
> Please note that Excel has some restrictions on the sheet's name:
@@ -296,21 +333,23 @@ $writer->setTempFolder($customTempFolderPath)
## Running tests
On the `master` branch, only unit and functional tests are included. The performance requires very large files and have been excluded.
If you just want to check that everything is working as expected, executing the tests of the master branch is enough.
On the `master` branch, only unit and functional tests are included. The performance tests require very large files and have been excluded.
If you just want to check that everything is working as expected, executing the tests of the `master` branch is enough.
If you want to run performance tests, you will need to checkout the `perf-tests` branch. Multiple test suites can then be run, depending on the expected output:
* `phpunit` - runs the whole test suite (unit + functional + performance tests)
* `phpunit --testuite no-perf-tests` - only runs the unit and functional tests
* `phpunit --testuite perf-tests` - only runs the performance tests
* `phpunit --exclude-group perf-tests` - only runs the unit and functional tests
* `phpunit --group perf-tests` - only runs the performance tests
For information, the performance tests take about one hour to run (processing 2 million rows files is not a quick thing).
For information, the performance tests take about 30 minutes to run (processing 1 million rows files is not a quick thing).
> Performance tests status: [![Build Status](https://travis-ci.org/box/spout.svg?branch=perf-tests)](https://travis-ci.org/box/spout)
## Frequently Asked Questions
#### How can Spout handle such large data sets and still use less than 10MB of memory?
#### How can Spout handle such large data sets and still use less than 3MB of memory?
When writing data, Spout is streaming the data to files, one or few lines at a time. That means that it only keeps in memory the few rows that it needs to write. Once written, the memory is freed.
@@ -320,13 +359,15 @@ Same goes with reading. Only one row at a time is stored in memory. A special te
Here are a few numbers regarding the performance of Spout:
| | 2,000 rows (6,000 cells) | 200,000 rows (600,000 cells) | 2,000,000 rows (6,000,000 cells) |
| :------------------------------- | :----------------------: | :--------------------------: | :------------------------------: |
| Read CSV | < 1 second | 4 seconds | 2-3 minutes |
| Write CSV | < 1 second | 2 seconds | 2-3 minutes |
| Read XLSX (using inline strings) | < 1 second | 35-40 seconds | 18-20 minutes |
| Read XLSX (using shared strings) | 1 second | 1-2 minutes | 35-40 minutes |
| Write XLSX | 1 second | 20-25 seconds | 8-10 minutes |
| Type | Action | 2,000 rows (6,000 cells) | 200,000 rows (600,000 cells) | 2,000,000 rows (6,000,000 cells) |
|------|-------------------------------|--------------------------|------------------------------|----------------------------------|
| CSV | Read | < 1 second | 4 seconds | 2-3 minutes |
| | Write | < 1 second | 2 seconds | 2-3 minutes |
| XLSX | Read<br>*inline&nbsp;strings* | < 1 second | 35-40 seconds | 18-20 minutes |
| | Read<br>*shared&nbsp;strings* | 1 second | 1-2 minutes | 35-40 minutes |
| | Write | 1 second | 20-25 seconds | 8-10 minutes |
| ODS | Read | 1 second | 1-2 minutes | 5-6 minutes |
| | Write | < 1 second | 35-40 seconds | 5-6 minutes |
#### Does Spout support charts or formulas?
+9 -4
View File
@@ -1,8 +1,8 @@
{
"name": "box/spout",
"description": "PHP Library to read and write CSV and XLSX files, in a fast and scalable way",
"description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
"type": "library",
"keywords": ["php","read","write","csv","xlsx","excel","spreadsheet","scale","memory","stream","ooxml"],
"keywords": ["php","read","write","csv","xlsx","ods","odf","open","office","excel","spreadsheet","scale","memory","stream","ooxml"],
"license": "Apache-2.0",
"homepage": "https://www.github.com/box/spout",
"authors": [
@@ -18,8 +18,7 @@
"ext-simplexml": "*"
},
"require-dev": {
"phpunit/phpunit": ">=3.7",
"scrutinizer/ocular": "~1.1"
"phpunit/phpunit": "^4.8.0"
},
"suggest": {
"ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
@@ -29,5 +28,11 @@
"psr-4": {
"Box\\Spout\\": "src/Spout"
}
},
"extra": {
"branch-alias": {
"dev-master": "2.8.x-dev"
}
}
}
Generated
+74 -741
View File
File diff suppressed because it is too large. Load diff
+57
View File
@@ -0,0 +1,57 @@
<?php
namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/**
* Class ODS
* Provides functions to escape and unescape data for ODS files
*
* @package Box\Spout\Common\Escaper
*/
class ODS implements EscaperInterface
{
use Singleton;
/**
* Escapes the given string to make it compatible with XLSX
*
* @param string $string The string to escape
* @return string The escaped string
*/
public function escape($string)
{
if (defined('ENT_DISALLOWED')) {
// 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
// Otherwise control characters like a vertical tab "\v" will make the XML document unreadable by the XML processor
// @link https://github.com/box/spout/issues/329
$replacedString = htmlspecialchars($string, ENT_QUOTES | ENT_DISALLOWED);
} else {
// We are on hhvm or any other engine that does not support ENT_DISALLOWED
$escapedString = htmlspecialchars($string, ENT_QUOTES);
// 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)
{
return htmlspecialchars_decode($string, ENT_QUOTES);
}
}
+43 -8
View File
@@ -2,6 +2,8 @@
namespace Box\Spout\Common\Escaper;
use Box\Spout\Common\Singleton;
/**
* Class XLSX
* Provides functions to escape and unescape data for XLSX files
@@ -10,15 +12,25 @@ namespace Box\Spout\Common\Escaper;
*/
class XLSX implements EscaperInterface
{
/** @var string[] Control characters to be escaped */
use Singleton;
/** @var string Regex pattern to detect control characters that need to be escaped */
protected $escapableControlCharactersPattern;
/** @var string[] Map containing control characters to be escaped (key) and their escaped value (value) */
protected $controlCharactersEscapingMap;
/** @var string[] Map containing control characters to be escaped (value) and their escaped value (key) */
protected $controlCharactersEscapingReverseMap;
/**
*
* Initializes the singleton instance
*/
public function __construct()
protected function init()
{
$this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
$this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
$this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
}
/**
@@ -49,6 +61,20 @@ class XLSX implements EscaperInterface
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.
@@ -62,14 +88,14 @@ class XLSX implements EscaperInterface
protected function getControlCharactersEscapingMap()
{
$controlCharactersEscapingMap = [];
$whitelistedControlCharacters = ["\t", "\r", "\n"];
// control characters values are from 0 to 1F (hex values) in the ASCII table
for ($charValue = 0x0; $charValue <= 0x1F; $charValue++) {
if (!in_array(chr($charValue), $whitelistedControlCharacters)) {
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] = chr($charValue);
$controlCharactersEscapingMap[$escapedChar] = $character;
}
}
@@ -92,7 +118,15 @@ class XLSX implements EscaperInterface
protected function escapeControlCharacters($string)
{
$escapedString = $this->escapeEscapeCharacter($string);
return str_replace(array_values($this->controlCharactersEscapingMap), array_keys($this->controlCharactersEscapingMap), $escapedString);
// 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);
}
/**
@@ -122,6 +156,7 @@ class XLSX implements EscaperInterface
protected function unescapeControlCharacters($string)
{
$unescapedString = $string;
foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) {
// only unescape characters that don't contain the escaped escape character for now
$unescapedString = preg_replace("/(?<!_x005F)($escapedCharValue)/", $charValue, $unescapedString);
@@ -1,12 +0,0 @@
<?php
namespace Box\Spout\Common\Exception;
/**
* Class BadUsageException
*
* @package Box\Spout\Common\Exception
*/
class BadUsageException extends SpoutException
{
}
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
/**
* Class EncodingConversionException
*
* @api
* @package Box\Spout\Common\Exception
*/
class EncodingConversionException extends SpoutException
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
/**
* Class IOException
*
* @api
* @package Box\Spout\Common\Exception
*/
class IOException extends SpoutException
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
/**
* Class InvalidArgumentException
*
* @api
* @package Box\Spout\Common\Exception
*/
class InvalidArgumentException extends SpoutException
@@ -6,6 +6,7 @@ namespace Box\Spout\Common\Exception;
* Class SpoutException
*
* @package Box\Spout\Common\Exception
* @abstract
*/
abstract class SpoutException extends \Exception
{
@@ -5,6 +5,7 @@ namespace Box\Spout\Common\Exception;
/**
* Class UnsupportedTypeException
*
* @api
* @package Box\Spout\Common\Exception
*/
class UnsupportedTypeException extends SpoutException
+1 -1
View File
@@ -59,7 +59,7 @@ class EncodingHelper
{
$byteOffsetToSkipBom = 0;
if ($this->hasBom($filePointer, $encoding)) {
if ($this->hasBOM($filePointer, $encoding)) {
$bomUsed = $this->supportedEncodingsWithBom[$encoding];
// we skip the N first bytes
+6 -5
View File
@@ -13,15 +13,15 @@ use Box\Spout\Common\Exception\IOException;
*/
class FileSystemHelper
{
/** @var string Path of the base folder where all the I/O can occur */
protected $baseFolderPath;
/** @var string Real path of the base folder where all the I/O can occur */
protected $baseFolderRealPath;
/**
* @param string $baseFolderPath The path of the base folder where all the I/O can occur
*/
public function __construct($baseFolderPath)
{
$this->baseFolderPath = $baseFolderPath;
$this->baseFolderRealPath = realpath($baseFolderPath);
}
/**
@@ -124,9 +124,10 @@ class FileSystemHelper
*/
protected function throwIfOperationNotInBaseFolder($operationFolderPath)
{
$isInBaseFolder = (strpos($operationFolderPath, $this->baseFolderPath) === 0);
$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->baseFolderPath}");
throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderRealPath}");
}
}
}
@@ -164,7 +164,43 @@ class GlobalFunctionsHelper
*/
public function file_get_contents($filePath)
{
return file_get_contents($filePath);
$realFilePath = $this->convertToUseRealPath($filePath);
return file_get_contents($realFilePath);
}
/**
* Updates the given file path to use a real path.
* This is to avoid issues on some Windows setup.
*
* @param string $filePath File path
* @return string The file path using a real path
*/
protected function convertToUseRealPath($filePath)
{
$realFilePath = $filePath;
if ($this->isZipStream($filePath)) {
if (preg_match('/zip:\/\/(.*)#(.*)/', $filePath, $matches)) {
$documentPath = $matches[1];
$documentInsideZipPath = $matches[2];
$realFilePath = 'zip://' . realpath($documentPath) . '#' . $documentInsideZipPath;
}
} else {
$realFilePath = realpath($filePath);
}
return $realFilePath;
}
/**
* Returns whether the given path is a zip stream.
*
* @param string $path Path pointing to a document
* @return bool TRUE if path is a zip stream, FALSE otherwise
*/
protected function isZipStream($path)
{
return (strpos($path, 'zip://') === 0);
}
/**
@@ -216,6 +252,19 @@ class GlobalFunctionsHelper
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()
@@ -245,32 +294,14 @@ class GlobalFunctionsHelper
}
/**
* Wrapper around global function stream_get_line()
* @see stream_get_line()
* Wrapper around global function stream_get_wrappers()
* @see stream_get_wrappers()
*
* @param resource $handle
* @param int $length
* @param string|void $ending
* @return string|bool
*/
public function stream_get_line($handle, $length, $ending = null)
{
return stream_get_line($handle, $length, $ending);
}
/**
* Wrapper around global function str_getcsv()
* @see str_getcsv()
*
* @param string $input
* @param string|void $delimiter
* @param string|void $enclosure
* @param string|void $escape
* @return array
*/
public function str_getcsv($input, $delimiter = null, $enclosure = null, $escape = null)
public function stream_get_wrappers()
{
return str_getcsv($input, $delimiter, $enclosure, $escape);
return stream_get_wrappers();
}
/**
+41
View File
@@ -0,0 +1,41 @@
<?php
namespace Box\Spout\Common;
/**
* Class Singleton
* Defines a class as a singleton.
*
* @package Box\Spout\Common
*/
trait Singleton
{
protected static $instance;
/**
* @return static
*/
final public static function getInstance()
{
return isset(static::$instance)
? static::$instance
: static::$instance = new static;
}
/**
* Singleton constructor.
*/
final private function __construct()
{
$this->init();
}
/**
* Initializes the singleton
* @return void
*/
protected function init() {}
final private function __wakeup() {}
final private function __clone() {}
}
+5 -2
View File
@@ -5,9 +5,12 @@ namespace Box\Spout\Common;
/**
* Class Type
* This class references the supported types
*
* @api
*/
abstract class Type
{
const CSV = "csv";
const XLSX = "xlsx";
const CSV = 'csv';
const XLSX = 'xlsx';
const ODS = 'ods';
}
+117 -4
View File
@@ -19,6 +19,23 @@ abstract class AbstractReader implements ReaderInterface
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var \Box\Spout\Reader\Common\ReaderOptions Reader's customized options */
protected $options;
/**
* Returns the reader's current options
*
* @return \Box\Spout\Reader\Common\ReaderOptions
*/
abstract protected function getOptions();
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
abstract protected function doesSupportStreamWrapper();
/**
* Opens the file at the given file path to make it ready to be read
*
@@ -32,7 +49,7 @@ abstract class AbstractReader implements ReaderInterface
*
* @return \Iterator To iterate over sheets
*/
abstract public function getConcreteSheetIterator();
abstract protected function getConcreteSheetIterator();
/**
* Closes the reader. To be used after reading the file.
@@ -42,7 +59,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)
@@ -51,16 +68,47 @@ abstract class AbstractReader implements ReaderInterface
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)) {
@@ -71,13 +119,75 @@ abstract class AbstractReader implements ReaderInterface
}
try {
$this->openReader($filePath);
$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, ...)
*
@@ -86,12 +196,14 @@ abstract class AbstractReader implements ReaderInterface
*/
protected function isPhpStream($filePath)
{
return (strpos($filePath, 'php://') === 0);
$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
*/
@@ -107,6 +219,7 @@ abstract class AbstractReader implements ReaderInterface
/**
* Closes the reader, preventing any additional reading
*
* @api
* @return void
*/
public function close()
+47 -15
View File
@@ -4,7 +4,6 @@ namespace Box\Spout\Reader\CSV;
use Box\Spout\Reader\AbstractReader;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Helper\EncodingHelper;
/**
* Class Reader
@@ -20,14 +19,21 @@ class Reader extends AbstractReader
/** @var SheetIterator To iterator over the CSV unique "sheet" */
protected $sheetIterator;
/** @var string Defines the character used to delimit fields (one character only) */
protected $fieldDelimiter = ',';
/** @var string Original value for the "auto_detect_line_endings" INI value */
protected $originalAutoDetectLineEndings;
/** @var string Defines the character used to enclose fields (one character only) */
protected $fieldEnclosure = '"';
/** @var string Encoding of the CSV file to be read */
protected $encoding = EncodingHelper::ENCODING_UTF8;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* Sets the field delimiter for the CSV.
@@ -38,7 +44,7 @@ class Reader extends AbstractReader
*/
public function setFieldDelimiter($fieldDelimiter)
{
$this->fieldDelimiter = $fieldDelimiter;
$this->getOptions()->setFieldDelimiter($fieldDelimiter);
return $this;
}
@@ -51,7 +57,7 @@ class Reader extends AbstractReader
*/
public function setFieldEnclosure($fieldEnclosure)
{
$this->fieldEnclosure = $fieldEnclosure;
$this->getOptions()->setFieldEnclosure($fieldEnclosure);
return $this;
}
@@ -64,10 +70,33 @@ class Reader extends AbstractReader
*/
public function setEncoding($encoding)
{
$this->encoding = $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.
@@ -78,6 +107,9 @@ class Reader extends AbstractReader
*/
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.");
@@ -85,9 +117,7 @@ class Reader extends AbstractReader
$this->sheetIterator = new SheetIterator(
$this->filePointer,
$this->fieldDelimiter,
$this->fieldEnclosure,
$this->encoding,
$this->getOptions(),
$this->globalFunctionsHelper
);
}
@@ -97,7 +127,7 @@ class Reader extends AbstractReader
*
* @return SheetIterator To iterate over sheets
*/
public function getConcreteSheetIterator()
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
@@ -113,5 +143,7 @@ class Reader extends AbstractReader
if ($this->filePointer) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
ini_set('auto_detect_line_endings', $this->originalAutoDetectLineEndings);
}
}
+110
View File
@@ -0,0 +1,110 @@
<?php
namespace Box\Spout\Reader\CSV;
use Box\Spout\Common\Helper\EncodingHelper;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\CSV
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
/** @var string Defines the character used to delimit fields (one character only) */
protected $fieldDelimiter = ',';
/** @var string Defines the character used to enclose fields (one character only) */
protected $fieldEnclosure = '"';
/** @var string Encoding of the CSV file to be read */
protected $encoding = EncodingHelper::ENCODING_UTF8;
/** @var string Defines the End of line */
protected $endOfLineCharacter = "\n";
/**
* @return string
*/
public function getFieldDelimiter()
{
return $this->fieldDelimiter;
}
/**
* Sets the field delimiter for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldDelimiter Character that delimits fields
* @return ReaderOptions
*/
public function setFieldDelimiter($fieldDelimiter)
{
$this->fieldDelimiter = $fieldDelimiter;
return $this;
}
/**
* @return string
*/
public function getFieldEnclosure()
{
return $this->fieldEnclosure;
}
/**
* Sets the field enclosure for the CSV.
* Needs to be called before opening the reader.
*
* @param string $fieldEnclosure Character that enclose fields
* @return ReaderOptions
*/
public function setFieldEnclosure($fieldEnclosure)
{
$this->fieldEnclosure = $fieldEnclosure;
return $this;
}
/**
* @return string
*/
public function getEncoding()
{
return $this->encoding;
}
/**
* Sets the encoding of the CSV file to be read.
* Needs to be called before opening the reader.
*
* @param string $encoding Encoding of the CSV file to be read
* @return ReaderOptions
*/
public function setEncoding($encoding)
{
$this->encoding = $encoding;
return $this;
}
/**
* @return string EOL for the CSV
*/
public function getEndOfLineCharacter()
{
return $this->endOfLineCharacter;
}
/**
* Sets the EOL for the CSV.
* Needs to be called before opening the reader.
*
* @param string $endOfLineCharacter used to properly get lines from the CSV file.
* @return ReaderOptions
*/
public function setEndOfLineCharacter($endOfLineCharacter)
{
$this->endOfLineCharacter = $endOfLineCharacter;
return $this;
}
}
+88 -41
View File
@@ -13,6 +13,11 @@ use Box\Spout\Common\Helper\EncodingHelper;
*/
class RowIterator implements IteratorInterface
{
/**
* Value passed to fgetcsv. 0 means "unlimited" (slightly slower but accomodates for very long lines).
*/
const MAX_READ_BYTES_PER_LINE = 0;
/** @var resource Pointer to the CSV file to read */
protected $filePointer;
@@ -34,6 +39,12 @@ class RowIterator implements IteratorInterface
/** @var string Encoding of the CSV file to be read */
protected $encoding;
/** @var string End of line delimiter, given by the user as input. */
protected $inputEOLDelimiter;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
@@ -45,17 +56,17 @@ class RowIterator implements IteratorInterface
/**
* @param resource $filePointer Pointer to the CSV file to read
* @param string $fieldDelimiter Character that delimits fields
* @param string $fieldEnclosure Character that enclose fields
* @param string $encoding Encoding of the CSV file to be read
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->filePointer = $filePointer;
$this->fieldDelimiter = $fieldDelimiter;
$this->fieldEnclosure = $fieldEnclosure;
$this->encoding = $encoding;
$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);
@@ -95,7 +106,7 @@ class RowIterator implements IteratorInterface
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return boolean
* @return bool
*/
public function valid()
{
@@ -103,7 +114,7 @@ class RowIterator implements IteratorInterface
}
/**
* Move forward to next element. Empty rows are skipped.
* Move forward to next element. Reads data for the next unprocessed row.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
@@ -111,48 +122,84 @@ class RowIterator implements IteratorInterface
*/
public function next()
{
$lineData = false;
$this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
if (!$this->hasReachedEndOfFile) {
do {
$utf8EncodedLineData = $this->getNextUTF8EncodedLine();
if ($utf8EncodedLineData !== false) {
$lineData = $this->globalFunctionsHelper->str_getcsv($utf8EncodedLineData, $this->fieldDelimiter, $this->fieldEnclosure);
}
$hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
} while (($lineData === false && !$hasNowReachedEndOfFile) || $this->isEmptyLine($lineData));
if ($lineData !== false) {
$this->rowDataBuffer = $lineData;
$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 = $hasNowReachedEndOfFile;
}
$this->readDataForNextRow();
}
}
/**
* Returns the next line, converted if necessary to UTF-8.
* Neither fgets nor fgetcsv don't work with non UTF-8 data... so we need to do some things manually.
*
* @return string|false The next line for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
* @return void
* @throws \Box\Spout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
*/
protected function getNextUTF8EncodedLine()
protected function readDataForNextRow()
{
// Read until the EOL delimiter or EOF is reached. The delimiter's encoding needs to match the CSV's encoding.
$encodedEOLDelimiter = $this->getEncodedEOLDelimiter();
$encodedLineData = $this->globalFunctionsHelper->stream_get_line($this->filePointer, 0, $encodedEOLDelimiter);
do {
$rowData = $this->getNextUTF8EncodedRow();
} while ($this->shouldReadNextRow($rowData));
// If the line could have been read, it can be converted to UTF-8
$utf8EncodedLineData = ($encodedLineData !== false) ?
$this->encodingHelper->attemptConversionToUTF8($encodedLineData, $this->encoding) :
false;
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;
}
}
return $utf8EncodedLineData;
/**
* @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;
}
/**
@@ -164,14 +211,14 @@ class RowIterator implements IteratorInterface
protected function getEncodedEOLDelimiter()
{
if (!isset($this->encodedEOLDelimiter)) {
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8("\n", $this->encoding);
$this->encodedEOLDelimiter = $this->encodingHelper->attemptConversionFromUTF8($this->inputEOLDelimiter, $this->encoding);
}
return $this->encodedEOLDelimiter;
}
/**
* @param array $lineData Array containing the cells value for the line
* @param array|bool $lineData Array containing the cells value for the line
* @return bool Whether the given line is empty
*/
protected function isEmptyLine($lineData)
+6 -7
View File
@@ -11,23 +11,22 @@ use Box\Spout\Reader\SheetInterface;
*/
class Sheet implements SheetInterface
{
/** @var RowIterator To iterate over the CSV's rows */
/** @var \Box\Spout\Reader\CSV\RowIterator To iterate over the CSV's rows */
protected $rowIterator;
/**
* @param resource $filePointer Pointer to the CSV file to read
* @param string $fieldDelimiter Character that delimits fields
* @param string $fieldEnclosure Character that enclose fields
* @param string $encoding Encoding of the CSV file to be read
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->rowIterator = new RowIterator($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
$this->rowIterator = new RowIterator($filePointer, $options, $globalFunctionsHelper);
}
/**
* @return RowIterator
* @api
* @return \Box\Spout\Reader\CSV\RowIterator
*/
public function getRowIterator()
{
+6 -8
View File
@@ -12,7 +12,7 @@ use Box\Spout\Reader\IteratorInterface;
*/
class SheetIterator implements IteratorInterface
{
/** @var Sheet The CSV unique "sheet" */
/** @var \Box\Spout\Reader\CSV\Sheet The CSV unique "sheet" */
protected $sheet;
/** @var bool Whether the unique "sheet" has already been read */
@@ -20,14 +20,12 @@ class SheetIterator implements IteratorInterface
/**
* @param resource $filePointer
* @param string $fieldDelimiter Character that delimits fields
* @param string $fieldEnclosure Character that enclose fields
* @param string $encoding Encoding of the CSV file to be read
* @param \Box\Spout\Reader\CSV\ReaderOptions $options
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper)
public function __construct($filePointer, $options, $globalFunctionsHelper)
{
$this->sheet = new Sheet($filePointer, $fieldDelimiter, $fieldEnclosure, $encoding, $globalFunctionsHelper);
$this->sheet = new Sheet($filePointer, $options, $globalFunctionsHelper);
}
/**
@@ -45,7 +43,7 @@ class SheetIterator implements IteratorInterface
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return boolean
* @return bool
*/
public function valid()
{
@@ -67,7 +65,7 @@ class SheetIterator implements IteratorInterface
* Return the current element
* @link http://php.net/manual/en/iterator.current.php
*
* @return Sheet
* @return \Box\Spout\Reader\CSV\Sheet
*/
public function current()
{
+58
View File
@@ -0,0 +1,58 @@
<?php
namespace Box\Spout\Reader\Common;
/**
* Class ReaderOptions
* Readers' common options
*
* @package Box\Spout\Reader\Common
*/
class ReaderOptions
{
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates = false;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows = false;
/**
* @return bool Whether date/time values should be returned as PHP objects or be formatted as strings.
*/
public function shouldFormatDates()
{
return $this->shouldFormatDates;
}
/**
* Sets whether date/time values should be returned as PHP objects or be formatted as strings.
*
* @param bool $shouldFormatDates
* @return ReaderOptions
*/
public function setShouldFormatDates($shouldFormatDates)
{
$this->shouldFormatDates = $shouldFormatDates;
return $this;
}
/**
* @return bool Whether empty rows should be returned or skipped.
*/
public function shouldPreserveEmptyRows()
{
return $this->shouldPreserveEmptyRows;
}
/**
* Sets whether empty rows should be returned or skipped.
*
* @param bool $shouldPreserveEmptyRows
* @return ReaderOptions
*/
public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
{
$this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows;
return $this;
}
}
+152
View File
@@ -0,0 +1,152 @@
<?php
namespace Box\Spout\Reader\Common;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class XMLProcessor
* Helps process XML files
*
* @package Box\Spout\Reader\Common
*/
class XMLProcessor
{
/* Node types */
const NODE_TYPE_START = XMLReader::ELEMENT;
const NODE_TYPE_END = XMLReader::END_ELEMENT;
/* Keys associated to reflection attributes to invoke a callback */
const CALLBACK_REFLECTION_METHOD = 'reflectionMethod';
const CALLBACK_REFLECTION_OBJECT = 'reflectionObject';
/* Values returned by the callbacks to indicate what the processor should do next */
const PROCESSING_CONTINUE = 1;
const PROCESSING_STOP = 2;
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var array Registered callbacks */
private $callbacks = [];
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object
*/
public function __construct($xmlReader)
{
$this->xmlReader = $xmlReader;
}
/**
* @param string $nodeName A callback may be triggered when a node with this name is read
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @param callable $callback Callback to execute when the read node has the given name and type
* @return XMLProcessor
*/
public function registerCallback($nodeName, $nodeType, $callback)
{
$callbackKey = $this->getCallbackKey($nodeName, $nodeType);
$this->callbacks[$callbackKey] = $this->getInvokableCallbackData($callback);
return $this;
}
/**
* @param string $nodeName Name of the node
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @return string Key used to store the associated callback
*/
private function getCallbackKey($nodeName, $nodeType)
{
return "$nodeName$nodeType";
}
/**
* Because the callback can be a "protected" function, we don't want to use call_user_func() directly
* but instead invoke the callback using Reflection. This allows the invocation of "protected" functions.
* Since some functions can be called a lot, we pre-process the callback to only return the elements that
* will be needed to invoke the callback later.
*
* @param callable $callback Array reference to a callback: [OBJECT, METHOD_NAME]
* @return array Associative array containing the elements needed to invoke the callback using Reflection
*/
private function getInvokableCallbackData($callback)
{
$callbackObject = $callback[0];
$callbackMethodName = $callback[1];
$reflectionMethod = new \ReflectionMethod(get_class($callbackObject), $callbackMethodName);
$reflectionMethod->setAccessible(true);
return [
self::CALLBACK_REFLECTION_METHOD => $reflectionMethod,
self::CALLBACK_REFLECTION_OBJECT => $callbackObject,
];
}
/**
* Resumes the reading of the XML file where it was left off.
* Stops whenever a callback indicates that reading should stop or at the end of the file.
*
* @return void
* @throws \Box\Spout\Reader\Exception\XMLProcessingException
*/
public function readUntilStopped()
{
while ($this->xmlReader->read()) {
$nodeType = $this->xmlReader->nodeType;
$nodeNamePossiblyWithPrefix = $this->xmlReader->name;
$nodeNameWithoutPrefix = $this->xmlReader->localName;
$callbackData = $this->getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType);
if ($callbackData !== null) {
$callbackResponse = $this->invokeCallback($callbackData, [$this->xmlReader]);
if ($callbackResponse === self::PROCESSING_STOP) {
// stop reading
break;
}
}
}
}
/**
* @param string $nodeNamePossiblyWithPrefix Name of the node, possibly prefixed
* @param string $nodeNameWithoutPrefix Name of the same node, un-prefixed
* @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
* @return array|null Callback data to be used for execution when a node of the given name/type is read or NULL if none found
*/
private function getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType)
{
// With prefixed nodes, we should match if (by order of preference):
// 1. the callback was registered with the prefixed node name (e.g. "x:worksheet")
// 2. the callback was registered with the un-prefixed node name (e.g. "worksheet")
$callbackKeyForPossiblyPrefixedName = $this->getCallbackKey($nodeNamePossiblyWithPrefix, $nodeType);
$callbackKeyForUnPrefixedName = $this->getCallbackKey($nodeNameWithoutPrefix, $nodeType);
$hasPrefix = ($nodeNamePossiblyWithPrefix !== $nodeNameWithoutPrefix);
$callbackKeyToUse = $callbackKeyForUnPrefixedName;
if ($hasPrefix && isset($this->callbacks[$callbackKeyForPossiblyPrefixedName])) {
$callbackKeyToUse = $callbackKeyForPossiblyPrefixedName;
}
// Using isset here because it is way faster than array_key_exists...
return isset($this->callbacks[$callbackKeyToUse]) ? $this->callbacks[$callbackKeyToUse] : null;
}
/**
* @param array $callbackData Associative array containing data to invoke the callback using Reflection
* @param array $args Arguments to pass to the callback
* @return int Callback response
*/
private function invokeCallback($callbackData, $args)
{
$reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD];
$callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT];
return $reflectionMethod->invokeArgs($callbackObject, $args);
}
}
@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Reader\Exception;
/**
* Class IteratorNotRewindableException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class IteratorNotRewindableException extends ReaderException
{
}
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\Exception;
/**
* Class NoSheetsFoundException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class NoSheetsFoundException extends ReaderException
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\Exception;
/**
* Class ReaderNotOpenedException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class ReaderNotOpenedException extends ReaderException
@@ -5,6 +5,7 @@ namespace Box\Spout\Reader\Exception;
/**
* Class SharedStringNotFoundException
*
* @api
* @package Box\Spout\Reader\Exception
*/
class SharedStringNotFoundException extends ReaderException
@@ -0,0 +1,231 @@
<?php
namespace Box\Spout\Reader\ODS\Helper;
/**
* Class CellValueFormatter
* This class provides helper functions to format cell values
*
* @package Box\Spout\Reader\ODS\Helper
*/
class CellValueFormatter
{
/** Definition of all possible cell types */
const CELL_TYPE_STRING = 'string';
const CELL_TYPE_FLOAT = 'float';
const CELL_TYPE_BOOLEAN = 'boolean';
const CELL_TYPE_DATE = 'date';
const CELL_TYPE_TIME = 'time';
const CELL_TYPE_CURRENCY = 'currency';
const CELL_TYPE_PERCENTAGE = 'percentage';
const CELL_TYPE_VOID = 'void';
/** Definition of XML nodes names used to parse data */
const XML_NODE_P = 'p';
const XML_NODE_S = 'text:s';
const XML_NODE_A = 'text:a';
const XML_NODE_SPAN = 'text:span';
/** Definition of XML attribute used to parse data */
const XML_ATTRIBUTE_TYPE = 'office:value-type';
const XML_ATTRIBUTE_VALUE = 'office:value';
const XML_ATTRIBUTE_BOOLEAN_VALUE = 'office:boolean-value';
const XML_ATTRIBUTE_DATE_VALUE = 'office:date-value';
const XML_ATTRIBUTE_TIME_VALUE = 'office:time-value';
const XML_ATTRIBUTE_CURRENCY = 'office:currency';
const XML_ATTRIBUTE_C = 'text:c';
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
protected $escaper;
/**
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/
public function __construct($shouldFormatDates)
{
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
* @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
*/
public function extractAndFormatNodeValue($node)
{
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE);
switch ($cellType) {
case self::CELL_TYPE_STRING:
return $this->formatStringCellValue($node);
case self::CELL_TYPE_FLOAT:
return $this->formatFloatCellValue($node);
case self::CELL_TYPE_BOOLEAN:
return $this->formatBooleanCellValue($node);
case self::CELL_TYPE_DATE:
return $this->formatDateCellValue($node);
case self::CELL_TYPE_TIME:
return $this->formatTimeCellValue($node);
case self::CELL_TYPE_CURRENCY:
return $this->formatCurrencyCellValue($node);
case self::CELL_TYPE_PERCENTAGE:
return $this->formatPercentageCellValue($node);
case self::CELL_TYPE_VOID:
default:
return '';
}
}
/**
* Returns the cell String value.
*
* @param \DOMNode $node
* @return string The value associated with the cell
*/
protected function formatStringCellValue($node)
{
$pNodeValues = [];
$pNodes = $node->getElementsByTagName(self::XML_NODE_P);
foreach ($pNodes as $pNode) {
$currentPValue = '';
foreach ($pNode->childNodes as $childNode) {
if ($childNode instanceof \DOMText) {
$currentPValue .= $childNode->nodeValue;
} else if ($childNode->nodeName === self::XML_NODE_S) {
$spaceAttribute = $childNode->getAttribute(self::XML_ATTRIBUTE_C);
$numSpaces = (!empty($spaceAttribute)) ? intval($spaceAttribute) : 1;
$currentPValue .= str_repeat(' ', $numSpaces);
} else if ($childNode->nodeName === self::XML_NODE_A || $childNode->nodeName === self::XML_NODE_SPAN) {
$currentPValue .= $childNode->nodeValue;
}
}
$pNodeValues[] = $currentPValue;
}
$escapedCellValue = implode("\n", $pNodeValues);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell Numeric value from the given node.
*
* @param \DOMNode $node
* @return int|float The value associated with the cell
*/
protected function formatFloatCellValue($node)
{
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
$nodeIntValue = intval($nodeValue);
// The "==" is intentionally not a "===" because only the value matters, not the type
$cellValue = ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
return $cellValue;
}
/**
* Returns the cell Boolean value from the given node.
*
* @param \DOMNode $node
* @return bool The value associated with the cell
*/
protected function formatBooleanCellValue($node)
{
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_BOOLEAN_VALUE);
// !! is similar to boolval()
$cellValue = !!$nodeValue;
return $cellValue;
}
/**
* Returns the cell Date value from the given node.
*
* @param \DOMNode $node
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatDateCellValue($node)
{
// The XML node looks like this:
// <table:table-cell calcext:value-type="date" office:date-value="2016-05-19T16:39:00" office:value-type="date">
// <text:p>05/19/16 04:39 PM</text:p>
// </table:table-cell>
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "date-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
return new \DateTime($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}
/**
* Returns the cell Time value from the given node.
*
* @param \DOMNode $node
* @return \DateInterval|string|null The value associated with the cell or NULL if invalid time value
*/
protected function formatTimeCellValue($node)
{
// The XML node looks like this:
// <table:table-cell calcext:value-type="time" office:time-value="PT13H24M00S" office:value-type="time">
// <text:p>01:24:00 PM</text:p>
// </table:table-cell>
if ($this->shouldFormatDates) {
// The date is already formatted in the "p" tag
$nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
return $nodeWithValueAlreadyFormatted->nodeValue;
} else {
// otherwise, get it from the "time-value" attribute
try {
$nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
return new \DateInterval($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}
/**
* Returns the cell Currency value from the given node.
*
* @param \DOMNode $node
* @return string The value associated with the cell (e.g. "100 USD" or "9.99 EUR")
*/
protected function formatCurrencyCellValue($node)
{
$value = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
$currency = $node->getAttribute(self::XML_ATTRIBUTE_CURRENCY);
return "$value $currency";
}
/**
* Returns the cell Percentage value from the given node.
*
* @param \DOMNode $node
* @return int|float The value associated with the cell
*/
protected function formatPercentageCellValue($node)
{
// percentages are formatted like floats
return $this->formatFloatCellValue($node);
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\AbstractReader;
/**
* Class Reader
* This class provides support to read data from a ODS file
*
* @package Box\Spout\Reader\ODS
*/
class Reader extends AbstractReader
{
/** @var \ZipArchive */
protected $zip;
/** @var SheetIterator To iterator over the ODS sheets */
protected $sheetIterator;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* Returns whether stream wrappers are supported
*
* @return bool
*/
protected function doesSupportStreamWrapper()
{
return false;
}
/**
* Opens the file at the given file path to make it ready to be read.
*
* @param string $filePath Path of the file to be read
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the file at the given path or its content cannot be read
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
protected function openReader($filePath)
{
$this->zip = new \ZipArchive();
if ($this->zip->open($filePath) === true) {
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions());
} else {
throw new IOException("Could not open $filePath for reading.");
}
}
/**
* Returns an iterator to iterate over sheets.
*
* @return SheetIterator To iterate over sheets
*/
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
/**
* Closes the reader. To be used after reading the file.
*
* @return void
*/
protected function closeReader()
{
if ($this->zip) {
$this->zip->close();
}
}
}
+14
View File
@@ -0,0 +1,14 @@
<?php
namespace Box\Spout\Reader\ODS;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\ODS
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
// No extra options
}
+351
View File
@@ -0,0 +1,351 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\IteratorNotRewindableException;
use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\ODS\Helper\CellValueFormatter;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\Common\XMLProcessor;
/**
* Class RowIterator
*
* @package Box\Spout\Reader\ODS
*/
class RowIterator implements IteratorInterface
{
/** Definition of XML nodes names used to parse data */
const XML_NODE_TABLE = 'table:table';
const XML_NODE_ROW = 'table:table-row';
const XML_NODE_CELL = 'table:table-cell';
const MAX_COLUMNS_EXCEL = 16384;
/** Definition of XML attribute used to parse data */
const XML_ATTRIBUTE_NUM_ROWS_REPEATED = 'table:number-rows-repeated';
const XML_ATTRIBUTE_NUM_COLUMNS_REPEATED = 'table:number-columns-repeated';
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
protected $xmlProcessor;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var Helper\CellValueFormatter Helper to format cell values */
protected $cellValueFormatter;
/** @var bool Whether the iterator has already been rewound once */
protected $hasAlreadyBeenRewound = false;
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
protected $currentlyProcessedRowData = [];
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
protected $rowDataBuffer = null;
/** @var bool Indicates whether all rows have been read */
protected $hasReachedEndOfFile = false;
/** @var int Last row index processed (one-based) */
protected $lastRowIndexProcessed = 0;
/** @var int Row index to be processed next (one-based) */
protected $nextRowIndexToBeProcessed = 1;
/** @var mixed|null Value of the last processed cell (because when reading cell at column N+1, cell N is processed) */
protected $lastProcessedCellValue = null;
/** @var int Number of times the last processed row should be repeated */
protected $numRowsRepeated = 1;
/** @var int Number of times the last cell value should be copied to the cells on its right */
protected $numColumnsRepeated = 1;
/** @var bool Whether at least one cell has been read for the row currently being processed */
protected $hasAlreadyReadOneCellInCurrentRow = false;
/**
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
*/
public function __construct($xmlReader, $options)
{
$this->xmlReader = $xmlReader;
$this->shouldPreserveEmptyRows = $options->shouldPreserveEmptyRows();
$this->cellValueFormatter = new CellValueFormatter($options->shouldFormatDates());
// Register all callbacks to process different nodes when reading the XML file
$this->xmlProcessor = new XMLProcessor($this->xmlReader);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
$this->xmlProcessor->registerCallback(self::XML_NODE_TABLE, XMLProcessor::NODE_TYPE_END, [$this, 'processTableEndingNode']);
}
/**
* Rewind the Iterator to the first element.
* NOTE: It can only be done once, as it is not possible to read an XML file backwards.
* @link http://php.net/manual/en/iterator.rewind.php
*
* @return void
* @throws \Box\Spout\Reader\Exception\IteratorNotRewindableException If the iterator is rewound more than once
*/
public function rewind()
{
// Because sheet and row data is located in the file, we can't rewind both the
// sheet iterator and the row iterator, as XML file cannot be read backwards.
// Therefore, rewinding the row iterator has been disabled.
if ($this->hasAlreadyBeenRewound) {
throw new IteratorNotRewindableException();
}
$this->hasAlreadyBeenRewound = true;
$this->lastRowIndexProcessed = 0;
$this->nextRowIndexToBeProcessed = 1;
$this->rowDataBuffer = null;
$this->hasReachedEndOfFile = false;
$this->next();
}
/**
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return bool
*/
public function valid()
{
return (!$this->hasReachedEndOfFile);
}
/**
* Move forward to next element. Empty rows will be skipped.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
public function next()
{
if ($this->doesNeedDataForNextRowToBeProcessed()) {
$this->readDataForNextRow();
}
$this->lastRowIndexProcessed++;
}
/**
* Returns whether we need data for the next row to be processed.
* We DO need to read data if:
* - we have not read any rows yet
* OR
* - the next row to be processed immediately follows the last read row
*
* @return bool Whether we need data for the next row to be processed.
*/
protected function doesNeedDataForNextRowToBeProcessed()
{
$hasReadAtLeastOneRow = ($this->lastRowIndexProcessed !== 0);
return (
!$hasReadAtLeastOneRow ||
$this->lastRowIndexProcessed === $this->nextRowIndexToBeProcessed - 1
);
}
/**
* @return void
* @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
* @throws \Box\Spout\Common\Exception\IOException If unable to read the sheet data XML
*/
protected function readDataForNextRow()
{
$this->currentlyProcessedRowData = [];
try {
$this->xmlProcessor->readUntilStopped();
} catch (XMLProcessingException $exception) {
throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
}
$this->rowDataBuffer = $this->currentlyProcessedRowData;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowStartingNode($xmlReader)
{
// Reset data from current row
$this->hasAlreadyReadOneCellInCurrentRow = false;
$this->lastProcessedCellValue = null;
$this->numColumnsRepeated = 1;
$this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader);
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processCellStartingNode($xmlReader)
{
$currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
$node = $xmlReader->expand();
$currentCellValue = $this->getCellValue($node);
// process cell N only after having read cell N+1 (see below why)
if ($this->hasAlreadyReadOneCellInCurrentRow) {
for ($i = 0; $i < $this->numColumnsRepeated; $i++) {
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
}
}
$this->hasAlreadyReadOneCellInCurrentRow = true;
$this->lastProcessedCellValue = $currentCellValue;
$this->numColumnsRepeated = $currentNumColumnsRepeated;
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowEndingNode()
{
$isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRowData, $this->lastProcessedCellValue);
// if the fetched row is empty and we don't want to preserve it...
if (!$this->shouldPreserveEmptyRows && $isEmptyRow) {
// ... skip it
return XMLProcessor::PROCESSING_CONTINUE;
}
// if the row is empty, we don't want to return more than one cell
$actualNumColumnsRepeated = (!$isEmptyRow) ? $this->numColumnsRepeated : 1;
// Only add the value if the last read cell is not a trailing empty cell repeater in Excel.
// The current count of read columns is determined by counting the values in "$this->currentlyProcessedRowData".
// This is to avoid creating a lot of empty cells, as Excel adds a last empty "<table:table-cell>"
// with a number-columns-repeated value equals to the number of (supported columns - used columns).
// In Excel, the number of supported columns is 16384, but we don't want to returns rows with
// always 16384 cells.
if ((count($this->currentlyProcessedRowData) + $actualNumColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
for ($i = 0; $i < $actualNumColumnsRepeated; $i++) {
$this->currentlyProcessedRowData[] = $this->lastProcessedCellValue;
}
}
// If we are processing row N and the row is repeated M times,
// then the next row to be processed will be row (N+M).
$this->nextRowIndexToBeProcessed += $this->numRowsRepeated;
// at this point, we have all the data we need for the row
// so that we can populate the buffer
return XMLProcessor::PROCESSING_STOP;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processTableEndingNode()
{
// The closing "</table:table>" marks the end of the file
$this->hasReachedEndOfFile = true;
return XMLProcessor::PROCESSING_STOP;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-row>" starting node
* @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing
*/
protected function getNumRowsRepeatedForCurrentNode($xmlReader)
{
$numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED);
return ($numRowsRepeated !== null) ? intval($numRowsRepeated) : 1;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<table:table-cell>" starting node
* @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
*/
protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
{
$numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
return ($numColumnsRepeated !== null) ? intval($numColumnsRepeated) : 1;
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|\DateInterval|null The value associated with the cell, empty string if cell's type is void/undefined, null on error
*/
protected function getCellValue($node)
{
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
}
/**
* After finishing processing each cell, a row is considered empty if it contains
* no cells or if the value of the last read cell is an empty string.
* After finishing processing each cell, the last read cell is not part of the
* row data yet (as we still need to apply the "num-columns-repeated" attribute).
*
* @param array $rowData
* @param string|int|float|bool|\DateTime|\DateInterval|null The value of the last read cell
* @return bool Whether the row is empty
*/
protected function isEmptyRow($rowData, $lastReadCellValue)
{
return (
count($rowData) === 0 &&
(!isset($lastReadCellValue) || trim($lastReadCellValue) === '')
);
}
/**
* Return the current element, from the buffer.
* @link http://php.net/manual/en/iterator.current.php
*
* @return array|null
*/
public function current()
{
return $this->rowDataBuffer;
}
/**
* Return the key of the current element
* @link http://php.net/manual/en/iterator.key.php
*
* @return int
*/
public function key()
{
return $this->lastRowIndexProcessed;
}
/**
* Cleans up what was created to iterate over the object.
*
* @return void
*/
public function end()
{
$this->xmlReader->close();
}
}
+67
View File
@@ -0,0 +1,67 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Reader\SheetInterface;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class Sheet
* Represents a sheet within a ODS file
*
* @package Box\Spout\Reader\ODS
*/
class Sheet implements SheetInterface
{
/** @var \Box\Spout\Reader\ODS\RowIterator To iterate over sheet's rows */
protected $rowIterator;
/** @var int ID of the sheet */
protected $id;
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
protected $index;
/** @var string Name of the sheet */
protected $name;
/**
* @param XMLReader $xmlReader XML Reader, positioned on the "<table:table>" element
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
* @param string $sheetName Name of the sheet
*/
public function __construct($xmlReader, $sheetIndex, $sheetName, $options)
{
$this->rowIterator = new RowIterator($xmlReader, $options);
$this->index = $sheetIndex;
$this->name = $sheetName;
}
/**
* @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;
}
}
+142
View File
@@ -0,0 +1,142 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class SheetIterator
* Iterate over ODS sheet.
*
* @package Box\Spout\Reader\ODS
*/
class SheetIterator implements IteratorInterface
{
const CONTENT_XML_FILE_PATH = 'content.xml';
/** Definition of XML nodes name and attribute used to parse sheet data */
const XML_NODE_TABLE = 'table:table';
const XML_ATTRIBUTE_TABLE_NAME = 'table:name';
/** @var string $filePath Path of the file to be read */
protected $filePath;
/** @var \Box\Spout\Reader\ODS\ReaderOptions Reader's current options */
protected $options;
/** @var XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Common\Escaper\ODS Used to unescape XML data */
protected $escaper;
/** @var bool Whether there are still at least a sheet to be read */
protected $hasFoundSheet;
/** @var int The index of the sheet being read (zero-based) */
protected $currentSheetIndex;
/**
* @param string $filePath Path of the file to be read
* @param \Box\Spout\Reader\ODS\ReaderOptions $options Reader's current options
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
public function __construct($filePath, $options)
{
$this->filePath = $filePath;
$this->options = $options;
$this->xmlReader = new XMLReader();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
}
/**
* 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);
return new Sheet($this->xmlReader, $this->currentSheetIndex, $sheetName, $this->options);
}
/**
* 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();
}
}
+4
View File
@@ -18,6 +18,7 @@ class ReaderFactory
/**
* This creates an instance of the appropriate reader, given the type of the file to be read
*
* @api
* @param string $readerType Type of the reader to instantiate
* @return ReaderInterface
* @throws \Box\Spout\Common\Exception\UnsupportedTypeException
@@ -33,6 +34,9 @@ class ReaderFactory
case Type::XLSX:
$reader = new XLSX\Reader();
break;
case Type::ODS:
$reader = new ODS\Reader();
break;
default:
throw new UnsupportedTypeException('No readers supporting the given type: ' . $readerType);
}
@@ -40,8 +40,6 @@ class SimpleXMLElement
}
$this->resetXMLInternalErrorsSetting();
return $this->simpleXMLElement;
}
/**
+69 -15
View File
@@ -14,41 +14,44 @@ class XMLReader extends \XMLReader
{
use XMLInternalErrorsHelper;
const ZIP_WRAPPER = 'zip://';
/**
* Set the URI containing the XML to parse
* @see \XMLReader::open
* Opens the XML Reader to read a file located inside a ZIP file.
*
* @param string $URI URI pointing to the document
* @param string|null|void $encoding The document encoding
* @param int $options A bitmask of the LIBXML_* constants
* @param string $zipFilePath Path to the ZIP file
* @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
* @return bool TRUE on success or FALSE on failure
*/
public function open($URI, $encoding = null, $options = 0)
public function openFileInZip($zipFilePath, $fileInsideZipPath)
{
$wasOpenSuccessful = false;
$realPathURI = $this->getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
// HHVM does not check if file exists within zip file
// @link https://github.com/facebook/hhvm/issues/5779
if ($this->isRunningHHVM() && $this->isZipStream($URI)) {
if ($this->fileExistsWithinZip($URI)) {
$wasOpenSuccessful = parent::open($URI, $encoding, $options|LIBXML_NONET);
if ($this->isRunningHHVM()) {
if ($this->fileExistsWithinZip($realPathURI)) {
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
}
} else {
$wasOpenSuccessful = parent::open($URI, $encoding, $options|LIBXML_NONET);
$wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
}
return $wasOpenSuccessful;
}
/**
* Returns whether the given URI is a zip stream.
* Returns the real path for the given path components.
* This is useful to avoid issues on some Windows setup.
*
* @param string $URI URI pointing to a document
* @return bool TRUE if URI is a zip stream, FALSE otherwise
* @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
*/
protected function isZipStream($URI)
public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
{
return (strpos($URI, 'zip://') === 0);
return (self::ZIP_WRAPPER . realpath($zipFilePath) . '#' . $fileInsideZipPath);
}
/**
@@ -104,6 +107,23 @@ class XMLReader extends \XMLReader
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
@@ -122,4 +142,38 @@ class XMLReader extends \XMLReader
return $wasNextSuccessful;
}
/**
* @param string $nodeName
* @return bool Whether the XML Reader is currently positioned on the starting node with given name
*/
public function isPositionedOnStartingNode($nodeName)
{
return $this->isPositionedOnNode($nodeName, XMLReader::ELEMENT);
}
/**
* @param string $nodeName
* @return bool Whether the XML Reader is currently positioned on the ending node with given name
*/
public function isPositionedOnEndingNode($nodeName)
{
return $this->isPositionedOnNode($nodeName, XMLReader::END_ELEMENT);
}
/**
* @param string $nodeName
* @param int $nodeType
* @return bool Whether the XML Reader is currently positioned on the node with given name and type
*/
private function isPositionedOnNode($nodeName, $nodeType)
{
// In some cases, the node has a prefix (for instance, "<sheet>" can also be "<x:sheet>").
// So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
// @see https://github.com/box/spout/issues/233
$hasPrefix = (strpos($nodeName, ':') !== false);
$currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
return ($this->nodeType === $nodeType && $currentNodeName === $nodeName);
}
}
+34 -25
View File
@@ -12,6 +12,14 @@ use Box\Spout\Common\Exception\InvalidArgumentException;
*/
class CellHelper
{
// Using ord() is super slow... Using a pre-computed hash table instead.
private static $columnLetterToIndexMapping = [
'A' => 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6,
'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13,
'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20,
'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25,
];
/**
* Fills the missing indexes of an array with a given value.
* For instance, $dataArray = []; $a[1] = 1; $a[3] = 3;
@@ -23,6 +31,9 @@ class CellHelper
*/
public static function fillMissingArrayIndexes($dataArray, $fillValue = '')
{
if (empty($dataArray)) {
return [];
}
$existingIndexes = array_keys($dataArray);
$newIndexes = array_fill_keys(range(0, max($existingIndexes)), $fillValue);
@@ -50,34 +61,31 @@ class CellHelper
}
$columnIndex = 0;
$capitalAAsciiValue = ord('A');
$capitalZAsciiValue = ord('Z');
$step = $capitalZAsciiValue - $capitalAAsciiValue + 1;
// Remove row information
$column = preg_replace('/\d/', '', $cellIndex);
$columnLength = strlen($column);
$columnLetters = preg_replace('/\d/', '', $cellIndex);
/*
* This is how the following loop will process the data:
* A => 0
* Z => 25
* AA => 26 : (26^(2-1) * (0+1)) + 0
* AB => 27 : (26^(2-1) * (0+1)) + 1
* BC => 54 : (26^(2-1) * (1+1)) + 2
* BCZ => 1455 : (26^(3-1) * (1+1)) + (26^(2-1) * (2+1)) + 25
*/
foreach (str_split($column) as $single_cell_index)
{
$currentColumnIndex = ord($single_cell_index) - $capitalAAsciiValue;
// strlen() is super slow too... Using isset() is way faster and not too unreadable,
// since we checked before that there are between 1 and 3 letters.
$columnLength = isset($columnLetters[1]) ? (isset($columnLetters[2]) ? 3 : 2) : 1;
if ($columnLength === 1) {
$columnIndex += $currentColumnIndex;
} else {
$columnIndex += pow($step, ($columnLength - 1)) * ($currentColumnIndex + 1);
}
$columnLength--;
// Looping over the different letters of the column is slower than this method.
// Also, not using the pow() function because it's slooooow...
switch ($columnLength) {
case 1:
$columnIndex = (self::$columnLetterToIndexMapping[$columnLetters]);
break;
case 2:
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 26;
$secondLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[1]];
$columnIndex = $firstLetterIndex + $secondLetterIndex;
break;
case 3:
$firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 676;
$secondLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[1]] + 1) * 26;
$thirdLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[2]];
$columnIndex = $firstLetterIndex + $secondLetterIndex + $thirdLetterIndex;
break;
}
return $columnIndex;
@@ -86,12 +94,13 @@ class CellHelper
/**
* Returns whether a cell index is valid, in an Excel world.
* To be valid, the cell index should start with capital letters and be followed by numbers.
* There can only be 3 letters, as there can only be 16,384 rows, which is equivalent to 'XFE'.
*
* @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
* @return bool
*/
protected static function isValidCellIndex($cellIndex)
{
return (preg_match('/^[A-Z]+\d+$/', $cellIndex) === 1);
return (preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex) === 1);
}
}
@@ -0,0 +1,300 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
/**
* Class CellValueFormatter
* This class provides helper functions to format cell values
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class CellValueFormatter
{
/** Definition of all possible cell types */
const CELL_TYPE_INLINE_STRING = 'inlineStr';
const CELL_TYPE_STR = 'str';
const CELL_TYPE_SHARED_STRING = 's';
const CELL_TYPE_BOOLEAN = 'b';
const CELL_TYPE_NUMERIC = 'n';
const CELL_TYPE_DATE = 'd';
const CELL_TYPE_ERROR = 'e';
/** Definition of XML nodes names used to parse data */
const XML_NODE_VALUE = 'v';
const XML_NODE_INLINE_STRING_VALUE = 't';
/** Definition of XML attributes used to parse data */
const XML_ATTRIBUTE_TYPE = 't';
const XML_ATTRIBUTE_STYLE_ID = 's';
/** Constants used for date formatting */
const NUM_SECONDS_IN_ONE_DAY = 86400;
const NUM_SECONDS_IN_ONE_HOUR = 3600;
const NUM_SECONDS_IN_ONE_MINUTE = 60;
/**
* February 29th, 1900 is NOT a leap year but Excel thinks it is...
* @see https://en.wikipedia.org/wiki/Year_1900_problem#Microsoft_Excel
*/
const ERRONEOUS_EXCEL_LEAP_YEAR_DAY = 60;
/** @var SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var StyleHelper Helper to work with styles */
protected $styleHelper;
/** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */
protected $shouldFormatDates;
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
protected $escaper;
/**
* @param SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
* @param StyleHelper $styleHelper Helper to work with styles
* @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
*/
public function __construct($sharedStringsHelper, $styleHelper, $shouldFormatDates)
{
$this->sharedStringsHelper = $sharedStringsHelper;
$this->styleHelper = $styleHelper;
$this->shouldFormatDates = $shouldFormatDates;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
}
/**
* Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
*
* @param \DOMNode $node
* @return string|int|float|bool|\DateTime|null The value associated with the cell (null when the cell has an error)
*/
public function extractAndFormatNodeValue($node)
{
// Default cell type is "n"
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
$cellStyleId = intval($node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID));
$vNodeValue = $this->getVNodeValue($node);
if (($vNodeValue === '') && ($cellType !== self::CELL_TYPE_INLINE_STRING)) {
return $vNodeValue;
}
switch ($cellType) {
case self::CELL_TYPE_INLINE_STRING:
return $this->formatInlineStringCellValue($node);
case self::CELL_TYPE_SHARED_STRING:
return $this->formatSharedStringCellValue($vNodeValue);
case self::CELL_TYPE_STR:
return $this->formatStrCellValue($vNodeValue);
case self::CELL_TYPE_BOOLEAN:
return $this->formatBooleanCellValue($vNodeValue);
case self::CELL_TYPE_NUMERIC:
return $this->formatNumericCellValue($vNodeValue, $cellStyleId);
case self::CELL_TYPE_DATE:
return $this->formatDateCellValue($vNodeValue);
default:
return null;
}
}
/**
* Returns the cell's string value from a node's nested value node
*
* @param \DOMNode $node
* @return string The value associated with the cell
*/
protected function getVNodeValue($node)
{
// for cell types having a "v" tag containing the value.
// if not, the returned value should be empty string.
$vNode = $node->getElementsByTagName(self::XML_NODE_VALUE)->item(0);
return ($vNode !== null) ? $vNode->nodeValue : '';
}
/**
* Returns the cell String value where string is inline.
*
* @param \DOMNode $node
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatInlineStringCellValue($node)
{
// inline strings are formatted this way:
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
$cellValue = $this->escaper->unescape($tNode->nodeValue);
return $cellValue;
}
/**
* Returns the cell String value from shared-strings file using nodeValue index.
*
* @param string $nodeValue
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatSharedStringCellValue($nodeValue)
{
// shared strings are formatted this way:
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
$sharedStringIndex = intval($nodeValue);
$escapedCellValue = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell String value, where string is stored in value node.
*
* @param string $nodeValue
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatStrCellValue($nodeValue)
{
$escapedCellValue = trim($nodeValue);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
/**
* Returns the cell Numeric value from string of nodeValue.
* The value can also represent a timestamp and a DateTime will be returned.
*
* @param string $nodeValue
* @param int $cellStyleId 0 being the default style
* @return int|float|\DateTime|null The value associated with the cell
*/
protected function formatNumericCellValue($nodeValue, $cellStyleId)
{
// Numeric values can represent numbers as well as timestamps.
// We need to look at the style of the cell to determine whether it is one or the other.
$shouldFormatAsDate = $this->styleHelper->shouldFormatNumericValueAsDate($cellStyleId);
if ($shouldFormatAsDate) {
return $this->formatExcelTimestampValue(floatval($nodeValue), $cellStyleId);
} else {
$nodeIntValue = intval($nodeValue);
return ($nodeIntValue == $nodeValue) ? $nodeIntValue : floatval($nodeValue);
}
}
/**
* Returns a cell's PHP Date value, associated to the given timestamp.
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
* NOTE: The timestamp can also represent a time, if it is a value between 0 and 1.
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|null The value associated with the cell or NULL if invalid date value
*/
protected function formatExcelTimestampValue($nodeValue, $cellStyleId)
{
// Fix for the erroneous leap year in Excel
if (ceil($nodeValue) > self::ERRONEOUS_EXCEL_LEAP_YEAR_DAY) {
--$nodeValue;
}
if ($nodeValue >= 1) {
// Values greater than 1 represent "dates". The value 1.0 representing the "base" date: 1900-01-01.
return $this->formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId);
} else if ($nodeValue >= 0) {
// Values between 0 and 1 represent "times".
return $this->formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId);
} else {
// invalid date
return null;
}
}
/**
* Returns a cell's PHP DateTime value, associated to the given timestamp.
* Only the time value matters. The date part is set to Jan 1st, 1900 (base Excel date).
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|string The value associated with the cell
*/
protected function formatExcelTimestampValueAsTimeValue($nodeValue, $cellStyleId)
{
$time = round($nodeValue * self::NUM_SECONDS_IN_ONE_DAY);
$hours = floor($time / self::NUM_SECONDS_IN_ONE_HOUR);
$minutes = floor($time / self::NUM_SECONDS_IN_ONE_MINUTE) - ($hours * self::NUM_SECONDS_IN_ONE_MINUTE);
$seconds = $time - ($hours * self::NUM_SECONDS_IN_ONE_HOUR) - ($minutes * self::NUM_SECONDS_IN_ONE_MINUTE);
// using the base Excel date (Jan 1st, 1900) - not relevant here
$dateObj = new \DateTime('1900-01-01');
$dateObj->setTime($hours, $minutes, $seconds);
if ($this->shouldFormatDates) {
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
return $dateObj->format($phpDateFormat);
} else {
return $dateObj;
}
}
/**
* Returns a cell's PHP Date value, associated to the given timestamp.
* NOTE: The timestamp is a float representing the number of days since January 1st, 1900.
*
* @param float $nodeValue
* @param int $cellStyleId 0 being the default style
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatExcelTimestampValueAsDateValue($nodeValue, $cellStyleId)
{
// Do not use any unix timestamps for calculation to prevent
// issues with numbers exceeding 2^31.
$secondsRemainder = fmod($nodeValue, 1) * self::NUM_SECONDS_IN_ONE_DAY;
$secondsRemainder = round($secondsRemainder, 0);
try {
$dateObj = \DateTime::createFromFormat('|Y-m-d', '1899-12-31');
$dateObj->modify('+' . intval($nodeValue) . 'days');
$dateObj->modify('+' . $secondsRemainder . 'seconds');
if ($this->shouldFormatDates) {
$styleNumberFormatCode = $this->styleHelper->getNumberFormatCode($cellStyleId);
$phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
return $dateObj->format($phpDateFormat);
} else {
return $dateObj;
}
} catch (\Exception $e) {
return null;
}
}
/**
* Returns the cell Boolean value from a specific node's Value.
*
* @param string $nodeValue
* @return bool The value associated with the cell
*/
protected function formatBooleanCellValue($nodeValue)
{
// !! is similar to boolval()
$cellValue = !!$nodeValue;
return $cellValue;
}
/**
* Returns a cell's PHP Date value, associated to the given stored nodeValue.
* @see ECMA-376 Part 1 - §18.17.4
*
* @param string $nodeValue ISO 8601 Date string
* @return \DateTime|string|null The value associated with the cell or NULL if invalid date value
*/
protected function formatDateCellValue($nodeValue)
{
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
try {
return ($this->shouldFormatDates) ? $nodeValue : new \DateTime($nodeValue);
} catch (\Exception $e) {
return null;
}
}
}
@@ -0,0 +1,124 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
/**
* Class DateFormatHelper
* This class provides helper functions to format Excel dates
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class DateFormatHelper
{
const KEY_GENERAL = 'general';
const KEY_HOUR_12 = '12h';
const KEY_HOUR_24 = '24h';
/**
* This map is used to replace Excel format characters by their PHP equivalent.
* Keys should be ordered from longest to smallest.
*
* @var array Mapping between Excel format characters and PHP format characters
*/
private static $excelDateFormatToPHPDateFormatMapping = [
self::KEY_GENERAL => [
// Time
'am/pm' => 'A', // Uppercase Ante meridiem and Post meridiem
':mm' => ':i', // Minutes with leading zeros - if preceded by a ":" (otherwise month)
'mm:' => 'i:', // Minutes with leading zeros - if followed by a ":" (otherwise month)
'ss' => 's', // Seconds, with leading zeros
'.s' => '', // Ignore (fractional seconds format does not exist in PHP)
// Date
'e' => 'Y', // Full numeric representation of a year, 4 digits
'yyyy' => 'Y', // Full numeric representation of a year, 4 digits
'yy' => 'y', // Two digit representation of a year
'mmmmm' => 'M', // Short textual representation of a month, three letters ("mmmmm" should only contain the 1st letter...)
'mmmm' => 'F', // Full textual representation of a month
'mmm' => 'M', // Short textual representation of a month, three letters
'mm' => 'm', // Numeric representation of a month, with leading zeros
'm' => 'n', // Numeric representation of a month, without leading zeros
'dddd' => 'l', // Full textual representation of the day of the week
'ddd' => 'D', // Textual representation of a day, three letters
'dd' => 'd', // Day of the month, 2 digits with leading zeros
'd' => 'j', // Day of the month without leading zeros
],
self::KEY_HOUR_12 => [
'hh' => 'h', // 12-hour format of an hour without leading zeros
'h' => 'g', // 12-hour format of an hour without leading zeros
],
self::KEY_HOUR_24 => [
'hh' => 'H', // 24-hour hours with leading zero
'h' => 'G', // 24-hour format of an hour without leading zeros
],
];
/**
* Converts the given Excel date format to a format understandable by the PHP date function.
*
* @param string $excelDateFormat Excel date format
* @return string PHP date format (as defined here: http://php.net/manual/en/function.date.php)
*/
public static function toPHPDateFormat($excelDateFormat)
{
// Remove brackets potentially present at the beginning of the format string
// and text portion of the format at the end of it (starting with ";")
// See §18.8.31 of ECMA-376 for more detail.
$dateFormat = preg_replace('/^(?:\[\$[^\]]+?\])?([^;]*).*/', '$1', $excelDateFormat);
// Double quotes are used to escape characters that must not be interpreted.
// For instance, ["Day " dd] should result in "Day 13" and we should not try to interpret "D", "a", "y"
// By exploding the format string using double quote as a delimiter, we can get all parts
// that must be transformed (even indexes) and all parts that must not be (odd indexes).
$dateFormatParts = explode('"', $dateFormat);
foreach ($dateFormatParts as $partIndex => $dateFormatPart) {
// do not look at odd indexes
if ($partIndex % 2 === 1) {
continue;
}
// Make sure all characters are lowercase, as the mapping table is using lowercase characters
$transformedPart = strtolower($dateFormatPart);
// Remove escapes related to non-format characters
$transformedPart = str_replace('\\', '', $transformedPart);
// Apply general transformation first...
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_GENERAL]);
// ... then apply hour transformation, for 12-hour or 24-hour format
if (self::has12HourFormatMarker($dateFormatPart)) {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_12]);
} else {
$transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_24]);
}
// overwrite the parts array with the new transformed part
$dateFormatParts[$partIndex] = $transformedPart;
}
// Merge all transformed parts back together
$phpDateFormat = implode('"', $dateFormatParts);
// Finally, to have the date format compatible with the DateTime::format() function, we need to escape
// all characters that are inside double quotes (and double quotes must be removed).
// For instance, ["Day " dd] should become [\D\a\y\ dd]
$phpDateFormat = preg_replace_callback('/"(.+?)"/', function($matches) {
$stringToEscape = $matches[1];
$letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY);
return '\\' . implode('\\', $letters);
}, $phpDateFormat);
return $phpDateFormat;
}
/**
* @param string $excelDateFormat Date format as defined by Excel
* @return bool Whether the given date format has the 12-hour format marker
*/
private static function has12HourFormatMarker($excelDateFormat)
{
return (stripos($excelDateFormat, 'am/pm') !== false);
}
}
@@ -78,7 +78,7 @@ class CachingStrategyFactory
* Returns the best caching strategy, given the number of unique shared strings
* and the amount of memory available.
*
* @param int $sharedStringsUniqueCount Number of unique shared strings
* @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
*/
@@ -95,11 +95,16 @@ class CachingStrategyFactory
* 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 $sharedStringsUniqueCount Number of unique shared strings
* @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) {
@@ -143,7 +143,9 @@ class FileBasedStrategy implements CachingStrategyInterface
}
$sharedString = null;
if (array_key_exists($indexInFile, $this->inMemoryTempFileContents)) {
// Using isset here because it is way faster than array_key_exists...
if (isset($this->inMemoryTempFileContents[$indexInFile])) {
$escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
$sharedString = $this->unescapeLineFeed($escapedSharedString);
}
@@ -34,7 +34,7 @@ class SharedStringsHelper
/**
* @param string $filePath Path of the XLSX file being read
* @param string|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
* @param string|null|void $tempFolder Temporary folder where the temporary files to store shared strings will be stored
*/
public function __construct($filePath, $tempFolder = null)
{
@@ -79,10 +79,11 @@ class SharedStringsHelper
{
$xmlReader = new XMLReader();
$sharedStringIndex = 0;
$escaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$sharedStringsFilePath = $this->getSharedStringsFilePath();
if ($xmlReader->open($sharedStringsFilePath, null, LIBXML_NONET) === false) {
if ($xmlReader->open($sharedStringsFilePath) === false) {
throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".');
}
@@ -90,44 +91,22 @@ class SharedStringsHelper
$sharedStringsUniqueCount = $this->getSharedStringsUniqueCount($xmlReader);
$this->cachingStrategy = $this->getBestSharedStringsCachingStrategy($sharedStringsUniqueCount);
while ($xmlReader->read() && $xmlReader->name !== 'si') {
// do nothing until a 'si' tag is reached
}
$xmlReader->readUntilNodeFound('si');
while ($xmlReader->name === 'si') {
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
// removes nodes that should not be read, like the pronunciation of the Kanji characters
$cleanNode = $this->removeSuperfluousTextNodes($node);
// find all text nodes 't'; there can be multiple if the cell contains formatting
$textNodes = $cleanNode->xpath('//ns:t');
$textValue = '';
foreach ($textNodes as $textNode) {
if ($this->shouldPreserveWhitespace($textNode)) {
$textValue .= $textNode->__toString();
} else {
$textValue .= trim($textNode->__toString());
}
}
$unescapedTextValue = $escaper->unescape($textValue);
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
$this->processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper);
$sharedStringIndex++;
// jump to the next 'si' tag
$xmlReader->next('si');
}
$this->cachingStrategy->closeCache();
} catch (XMLProcessingException $exception) {
throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
}
$this->cachingStrategy->closeCache();
$xmlReader->close();
}
@@ -143,7 +122,7 @@ class SharedStringsHelper
* Returns the shared strings unique count, as specified in <sst> tag.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
* @return int Number of unique shared strings in the sharedStrings.xml file
* @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)
@@ -155,13 +134,21 @@ class SharedStringsHelper
$xmlReader->read();
}
return intval($xmlReader->getAttribute('uniqueCount'));
$uniqueCount = $xmlReader->getAttribute('uniqueCount');
// 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('count');
}
return ($uniqueCount !== null) ? intval($uniqueCount) : null;
}
/**
* Returns the best shared strings caching strategy.
*
* @param int $sharedStringsUniqueCount
* @param int|null $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
* @return CachingStrategyInterface
*/
protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
@@ -170,6 +157,31 @@ class SharedStringsHelper
->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
* @param int $sharedStringIndex Index of the processed shared strings item
* @param \Box\Spout\Common\Escaper\XLSX $escaper Helper to escape values
* @return void
*/
protected function processSharedStringsItem($xmlReader, $sharedStringIndex, $escaper)
{
$node = $this->getSimpleXmlElementNodeFromXMLReader($xmlReader);
$node->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_SHARED_STRINGS_XML);
// removes nodes that should not be read, like the pronunciation of the Kanji characters
$cleanNode = $this->removeSuperfluousTextNodes($node);
// find all text nodes "t"; there can be multiple if the cell contains formatting
$textNodes = $cleanNode->xpath('//ns:t');
$textValue = $this->extractTextValueForNodes($textNodes);
$unescapedTextValue = $escaper->unescape($textValue);
$this->cachingStrategy->addStringForIndex($unescapedTextValue, $sharedStringIndex);
}
/**
* Returns a SimpleXMLElement node from the current node in the given XMLReader instance.
* This is to simplify the parsing of the subtree.
@@ -201,6 +213,8 @@ class SharedStringsHelper
{
$tagsToRemove = [
'rPh', // Pronunciation of the text
'pPr', // Paragraph Properties / Previous Paragraph Properties
'rPr', // Run Properties for the Paragraph Mark / Previous Run Properties for the Paragraph Mark
];
foreach ($tagsToRemove as $tagToRemove) {
@@ -211,6 +225,24 @@ class SharedStringsHelper
return $parentNode;
}
/**
* @param array $textNodes Text XML nodes ("<t>")
* @return string The value associated with the given text node(s)
*/
protected function extractTextValueForNodes($textNodes)
{
$textValue = '';
foreach ($textNodes as $nodeIndex => $textNode) {
$textNodeAsString = $textNode->__toString();
$shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
$textValue .= ($shouldPreserveWhitespace) ? $textNodeAsString : trim($textNodeAsString);
}
return $textValue;
}
/**
* If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
*
+62 -131
View File
@@ -2,7 +2,7 @@
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Reader\Wrapper\SimpleXMLElement;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Sheet;
/**
@@ -13,45 +13,32 @@ use Box\Spout\Reader\XLSX\Sheet;
*/
class SheetHelper
{
/** Extension for XML files */
const XML_EXTENSION = '.xml';
/** Paths of XML files relative to the XLSX file root */
const CONTENT_TYPES_XML_FILE_PATH = '[Content_Types].xml';
const WORKBOOK_XML_RELS_FILE_PATH = 'xl/_rels/workbook.xml.rels';
const WORKBOOK_XML_FILE_PATH = 'xl/workbook.xml';
/** Namespaces for the XML files */
const MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML = 'http://schemas.openxmlformats.org/package/2006/content-types';
const MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS = 'http://schemas.openxmlformats.org/package/2006/relationships';
const MAIN_NAMESPACE_FOR_WORKBOOK_XML = 'http://schemas.openxmlformats.org/spreadsheetml/2006/main';
/** Value of the Override attribute used in [Content_Types].xml to define sheets */
const OVERRIDE_CONTENT_TYPES_ATTRIBUTE = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml';
/** @var string Path of the XLSX file being read */
protected $filePath;
/** @var \Box\Spout\Reader\XLSX\ReaderOptions Reader's current options */
protected $options;
/** @var \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper Helper to work with global functions */
protected $globalFunctionsHelper;
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml.rels file */
protected $workbookXMLRelsAsXMLElement;
/** @var \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representing the workbook.xml file */
protected $workbookXMLAsXMLElement;
/**
* @param string $filePath Path of the XLSX file being read
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper Helper to work with shared strings
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
*/
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper)
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
{
$this->filePath = $filePath;
$this->options = $options;
$this->sharedStringsHelper = $sharedStringsHelper;
$this->globalFunctionsHelper = $globalFunctionsHelper;
}
@@ -65,137 +52,81 @@ class SheetHelper
public function getSheets()
{
$sheets = [];
$sheetIndex = 0;
$contentTypesAsXMLElement = $this->getFileAsXMLElementWithNamespace(
self::CONTENT_TYPES_XML_FILE_PATH,
self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML
);
$xmlReader = new XMLReader();
if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode('sheet')) {
$sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $sheetIndex);
$sheetIndex++;
} else if ($xmlReader->isPositionedOnEndingNode('sheets')) {
// stop reading once all sheets have been read
break;
}
}
// find all nodes defining a sheet
$sheetNodes = $contentTypesAsXMLElement->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]');
$numSheetNodes = count($sheetNodes);
for ($i = 0; $i < $numSheetNodes; $i++) {
$sheetNode = $sheetNodes[$i];
$sheetDataXMLFilePath = $sheetNode->getAttribute('PartName');
$sheets[] = $this->getSheetFromXML($sheetDataXMLFilePath, $i);
$xmlReader->close();
}
return $sheets;
}
/**
* Returns an instance of a sheet, given the path of its data XML file.
* We first look at "xl/_rels/workbook.xml.rels" to find the relationship ID of the sheet.
* Then we look at "xl/worbook.xml" to find the sheet entry associated to the found ID.
* The entry contains the ID and name of the sheet.
* 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").
*
* If this piece of data can't be found by parsing the different XML files, the ID will default
* to the sheet index, based on order in [Content_Types].xml. Similarly, the sheet's name will
* default to the data sheet XML file name ("xl/worksheets/sheet2.xml" => "sheet2").
*
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @param int $sheetIndexZeroBased Index of the sheet, based on order in [Content_Types].xml (zero-based)
* @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)
* @return \Box\Spout\Reader\XLSX\Sheet Sheet instance
*/
protected function getSheetFromXML($sheetDataXMLFilePath, $sheetIndexZeroBased)
protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased)
{
$sheetId = $sheetIndexZeroBased + 1;
$sheetName = $this->getDefaultSheetName($sheetDataXMLFilePath);
$sheetId = $xmlReaderOnSheetNode->getAttribute('r:id');
$escapedSheetName = $xmlReaderOnSheetNode->getAttribute('name');
/*
* In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml"
* In workbook.xml.rels, it is only "worksheets/sheet1.xml"
*/
$sheetDataXMLFilePathInWorkbookXMLRels = ltrim($sheetDataXMLFilePath, '/xl/');
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$sheetName = $escaper->unescape($escapedSheetName);
// find the node associated to the given file path
$workbookXMLResElement = $this->getWorkbookXMLRelsAsXMLElement();
$relationshipNodes = $workbookXMLResElement->xpath('//ns:Relationship[@Target="' . $sheetDataXMLFilePathInWorkbookXMLRels . '"]');
$sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
if (count($relationshipNodes) === 1) {
$relationshipNode = $relationshipNodes[0];
$sheetId = $relationshipNode->getAttribute('Id');
return new Sheet($this->filePath, $sheetDataXMLFilePath, $sheetIndexZeroBased, $sheetName, $this->options, $this->sharedStringsHelper);
}
$workbookXMLElement = $this->getWorkbookXMLAsXMLElement();
$sheetNodes = $workbookXMLElement->xpath('//ns:sheet[@r:id="' . $sheetId . '"]');
/**
* @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 = '';
if (count($sheetNodes) === 1) {
$sheetNode = $sheetNodes[0];
$sheetId = (int) $sheetNode->getAttribute('sheetId');
$escapedSheetName = $sheetNode->getAttribute('name');
// 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('Relationship')) {
$relationshipSheetId = $xmlReader->getAttribute('Id');
$escaper = new \Box\Spout\Common\Escaper\XLSX();
$sheetName = $escaper->unescape($escapedSheetName);
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('Target');
// sometimes, the sheet data file path already contains "/xl/"...
if (strpos($sheetDataXMLFilePath, '/xl/') !== 0) {
$sheetDataXMLFilePath = '/xl/' . $sheetDataXMLFilePath;
break;
}
}
}
}
$xmlReader->close();
}
return new Sheet($this->filePath, $sheetDataXMLFilePath, $this->sharedStringsHelper, $sheetId, $sheetIndexZeroBased, $sheetName);
}
/**
* Returns the default name of the sheet whose data is located
* at the given path.
*
* @param string $sheetDataXMLFilePath Path of the sheet data XML file
* @return string The default sheet name
*/
protected function getDefaultSheetName($sheetDataXMLFilePath)
{
return $this->globalFunctionsHelper->basename($sheetDataXMLFilePath, self::XML_EXTENSION);
}
/**
* Returns a representation of the workbook.xml.rels file, ready to be parsed.
* The returned value is cached.
*
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
*/
protected function getWorkbookXMLRelsAsXMLElement()
{
if (!$this->workbookXMLRelsAsXMLElement) {
$this->workbookXMLRelsAsXMLElement = $this->getFileAsXMLElementWithNamespace(
self::WORKBOOK_XML_RELS_FILE_PATH,
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML_RELS
);
}
return $this->workbookXMLRelsAsXMLElement;
}
/**
* Returns a representation of the workbook.xml file, ready to be parsed.
* The returned value is cached.
*
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement XML element representating the workbook.xml.rels file
*/
protected function getWorkbookXMLAsXMLElement()
{
if (!$this->workbookXMLAsXMLElement) {
$this->workbookXMLAsXMLElement = $this->getFileAsXMLElementWithNamespace(
self::WORKBOOK_XML_FILE_PATH,
self::MAIN_NAMESPACE_FOR_WORKBOOK_XML
);
}
return $this->workbookXMLAsXMLElement;
}
/**
* Loads the contents of the given file in an XML parser and register the given XPath namespace.
*
* @param string $xmlFilePath The path of the XML file inside the XLSX file
* @param string $mainNamespace The main XPath namespace to register
* @return \Box\Spout\Reader\Wrapper\SimpleXMLElement The XML element representing the file
*/
protected function getFileAsXMLElementWithNamespace($xmlFilePath, $mainNamespace)
{
$xmlContents = $this->globalFunctionsHelper->file_get_contents('zip://' . $this->filePath . '#' . $xmlFilePath);
$xmlElement = new SimpleXMLElement($xmlContents);
$xmlElement->registerXPathNamespace('ns', $mainNamespace);
return $xmlElement;
return $sheetDataXMLFilePath;
}
}
@@ -0,0 +1,330 @@
<?php
namespace Box\Spout\Reader\XLSX\Helper;
use Box\Spout\Reader\Wrapper\XMLReader;
/**
* Class StyleHelper
* This class provides helper functions related to XLSX styles
*
* @package Box\Spout\Reader\XLSX\Helper
*/
class StyleHelper
{
/** Paths of XML files relative to the XLSX file root */
const STYLES_XML_FILE_PATH = 'xl/styles.xml';
/** Nodes used to find relevant information in the styles XML file */
const XML_NODE_NUM_FMTS = 'numFmts';
const XML_NODE_NUM_FMT = 'numFmt';
const XML_NODE_CELL_XFS = 'cellXfs';
const XML_NODE_XF = 'xf';
/** Attributes used to find relevant information in the styles XML file */
const XML_ATTRIBUTE_NUM_FMT_ID = 'numFmtId';
const XML_ATTRIBUTE_FORMAT_CODE = 'formatCode';
const XML_ATTRIBUTE_APPLY_NUMBER_FORMAT = 'applyNumberFormat';
/** By convention, default style ID is 0 */
const DEFAULT_STYLE_ID = 0;
const NUMBER_FORMAT_GENERAL = 'General';
/**
* @see https://msdn.microsoft.com/en-us/library/ff529597(v=office.12).aspx
* @var array Mapping between built-in numFmtId and the associated format - for dates only
*/
protected static $builtinNumFmtIdToNumFormatMapping = [
14 => 'm/d/yyyy', // @NOTE: ECMA spec is 'mm-dd-yy'
15 => 'd-mmm-yy',
16 => 'd-mmm',
17 => 'mmm-yy',
18 => 'h:mm AM/PM',
19 => 'h:mm:ss AM/PM',
20 => 'h:mm',
21 => 'h:mm:ss',
22 => 'm/d/yyyy h:mm', // @NOTE: ECMA spec is 'm/d/yy h:mm',
45 => 'mm:ss',
46 => '[h]:mm:ss',
47 => 'mm:ss.0', // @NOTE: ECMA spec is 'mmss.0',
];
/** @var string Path of the XLSX file being read */
protected $filePath;
/** @var array Array containing the IDs of built-in number formats indicating a date */
protected $builtinNumFmtIdIndicatingDates;
/** @var array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */
protected $customNumberFormats;
/** @var array Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */
protected $stylesAttributes;
/** @var array Cache containing a mapping NUM_FMT_ID => IS_DATE_FORMAT. Used to avoid lots of recalculations */
protected $numFmtIdToIsDateFormatCache = [];
/**
* @param string $filePath Path of the XLSX file being read
*/
public function __construct($filePath)
{
$this->filePath = $filePath;
$this->builtinNumFmtIdIndicatingDates = array_keys(self::$builtinNumFmtIdToNumFormatMapping);
}
/**
* Returns whether the style with the given ID should consider
* numeric values as timestamps and format the cell as a date.
*
* @param int $styleId Zero-based style ID
* @return bool Whether the cell with the given cell should display a date instead of a numeric value
*/
public function shouldFormatNumericValueAsDate($styleId)
{
$stylesAttributes = $this->getStylesAttributes();
// Default style (0) does not format numeric values as timestamps. Only custom styles do.
// Also if the style ID does not exist in the styles.xml file, format as numeric value.
// Using isset here because it is way faster than array_key_exists...
if ($styleId === self::DEFAULT_STYLE_ID || !isset($stylesAttributes[$styleId])) {
return false;
}
$styleAttributes = $stylesAttributes[$styleId];
return $this->doesStyleIndicateDate($styleAttributes);
}
/**
* Reads the styles.xml file and extract the relevant information from the file.
*
* @return void
*/
protected function extractRelevantInfo()
{
$this->customNumberFormats = [];
$this->stylesAttributes = [];
$xmlReader = new XMLReader();
if ($xmlReader->openFileInZip($this->filePath, self::STYLES_XML_FILE_PATH)) {
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
$this->extractNumberFormats($xmlReader);
} else if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
$this->extractStyleAttributes($xmlReader);
}
}
$xmlReader->close();
}
}
/**
* Extracts number formats from the "numFmt" nodes.
* For simplicity, the styles attributes are kept in memory. This is possible thanks
* to the reuse of formats. So 1 million cells should not use 1 million formats.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "numFmts" node
* @return void
*/
protected function extractNumberFormats($xmlReader)
{
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMT)) {
$numFmtId = intval($xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
$formatCode = $xmlReader->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
$this->customNumberFormats[$numFmtId] = $formatCode;
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_NUM_FMTS)) {
// Once done reading "numFmts" node's children
break;
}
}
}
/**
* Extracts style attributes from the "xf" nodes, inside the "cellXfs" section.
* For simplicity, the styles attributes are kept in memory. This is possible thanks
* to the reuse of styles. So 1 million cells should not use 1 million styles.
*
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "cellXfs" node
* @return void
*/
protected function extractStyleAttributes($xmlReader)
{
while ($xmlReader->read()) {
if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_XF)) {
$numFmtId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID);
$normalizedNumFmtId = ($numFmtId !== null) ? intval($numFmtId) : null;
$applyNumberFormat = $xmlReader->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT);
$normalizedApplyNumberFormat = ($applyNumberFormat !== null) ? !!$applyNumberFormat : null;
$this->stylesAttributes[] = [
self::XML_ATTRIBUTE_NUM_FMT_ID => $normalizedNumFmtId,
self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => $normalizedApplyNumberFormat,
];
} else if ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_CELL_XFS)) {
// Once done reading "cellXfs" node's children
break;
}
}
}
/**
* @return array The custom number formats
*/
protected function getCustomNumberFormats()
{
if (!isset($this->customNumberFormats)) {
$this->extractRelevantInfo();
}
return $this->customNumberFormats;
}
/**
* @return array The styles attributes
*/
protected function getStylesAttributes()
{
if (!isset($this->stylesAttributes)) {
$this->extractRelevantInfo();
}
return $this->stylesAttributes;
}
/**
* @param array $styleAttributes Array containing the style attributes (2 keys: "applyNumberFormat" and "numFmtId")
* @return bool Whether the style with the given attributes indicates that the number is a date
*/
protected function doesStyleIndicateDate($styleAttributes)
{
$applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
// A style may apply a date format if it has:
// - "applyNumberFormat" attribute not set to "false"
// - "numFmtId" attribute set
// This is a preliminary check, as having "numFmtId" set just means the style should apply a specific number format,
// but this is not necessarily a date.
if ($applyNumberFormat === false || $numFmtId === null) {
return false;
}
return $this->doesNumFmtIdIndicateDate($numFmtId);
}
/**
* Returns whether the number format ID indicates that the number is a date.
* The result is cached to avoid recomputing the same thing over and over, as
* "numFmtId" attributes can be shared between multiple styles.
*
* @param int $numFmtId
* @return bool Whether the number format ID indicates that the number is a date
*/
protected function doesNumFmtIdIndicateDate($numFmtId)
{
if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) {
$formatCode = $this->getFormatCodeForNumFmtId($numFmtId);
$this->numFmtIdToIsDateFormatCache[$numFmtId] = (
$this->isNumFmtIdBuiltInDateFormat($numFmtId) ||
$this->isFormatCodeCustomDateFormat($formatCode)
);
}
return $this->numFmtIdToIsDateFormatCache[$numFmtId];
}
/**
* @param int $numFmtId
* @return string|null The custom number format or NULL if none defined for the given numFmtId
*/
protected function getFormatCodeForNumFmtId($numFmtId)
{
$customNumberFormats = $this->getCustomNumberFormats();
// Using isset here because it is way faster than array_key_exists...
return (isset($customNumberFormats[$numFmtId])) ? $customNumberFormats[$numFmtId] : null;
}
/**
* @param int $numFmtId
* @return bool Whether the number format ID indicates that the number is a date
*/
protected function isNumFmtIdBuiltInDateFormat($numFmtId)
{
return in_array($numFmtId, $this->builtinNumFmtIdIndicatingDates);
}
/**
* @param string|null $formatCode
* @return bool Whether the given format code indicates that the number is a date
*/
protected function isFormatCodeCustomDateFormat($formatCode)
{
// if no associated format code or if using the default "General" format
if ($formatCode === null || strcasecmp($formatCode, self::NUMBER_FORMAT_GENERAL) === 0) {
return false;
}
return $this->isFormatCodeMatchingDateFormatPattern($formatCode);
}
/**
* @param string $formatCode
* @return bool Whether the given format code matches a date format pattern
*/
protected function isFormatCodeMatchingDateFormatPattern($formatCode)
{
// Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\")
$pattern = '((?<!\\\)\[.+?(?<!\\\)\])';
$formatCode = preg_replace($pattern, '', $formatCode);
// custom date formats contain specific characters to represent the date:
// e - yy - m - d - h - s
// and all of their variants (yyyy - mm - dd...)
$dateFormatCharacters = ['e', 'yy', 'm', 'd', 'h', 's'];
$hasFoundDateFormatCharacter = false;
foreach ($dateFormatCharacters as $dateFormatCharacter) {
// character not preceded by "\" (case insensitive)
$pattern = '/(?<!\\\)' . $dateFormatCharacter . '/i';
if (preg_match($pattern, $formatCode)) {
$hasFoundDateFormatCharacter = true;
break;
}
}
return $hasFoundDateFormatCharacter;
}
/**
* Returns the format as defined in "styles.xml" of the given style.
* NOTE: It is assumed that the style DOES have a number format associated to it.
*
* @param int $styleId Zero-based style ID
* @return string The number format code associated with the given style
*/
public function getNumberFormatCode($styleId)
{
$stylesAttributes = $this->getStylesAttributes();
$styleAttributes = $stylesAttributes[$styleId];
$numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
$numberFormatCode = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
} else {
$customNumberFormats = $this->getCustomNumberFormats();
$numberFormatCode = $customNumberFormats[$numFmtId];
}
return $numberFormatCode;
}
}
+27 -7
View File
@@ -14,9 +14,6 @@ use Box\Spout\Reader\XLSX\Helper\SharedStringsHelper;
*/
class Reader extends AbstractReader
{
/** @var string Temporary folder where the temporary files will be created */
protected $tempFolder;
/** @var \ZipArchive */
protected $zip;
@@ -27,16 +24,39 @@ class Reader extends AbstractReader
protected $sheetIterator;
/**
* Returns the reader's current options
*
* @return ReaderOptions
*/
protected function getOptions()
{
if (!isset($this->options)) {
$this->options = new ReaderOptions();
}
return $this->options;
}
/**
* @param string $tempFolder Temporary folder where the temporary files will be created
* @return Reader
*/
public function setTempFolder($tempFolder)
{
$this->tempFolder = $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
@@ -52,14 +72,14 @@ class Reader extends AbstractReader
$this->zip = new \ZipArchive();
if ($this->zip->open($filePath) === true) {
$this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder);
$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->sharedStringsHelper, $this->globalFunctionsHelper);
$this->sheetIterator = new SheetIterator($filePath, $this->getOptions(), $this->sharedStringsHelper, $this->globalFunctionsHelper);
} else {
throw new IOException("Could not open $filePath for reading.");
}
@@ -70,7 +90,7 @@ class Reader extends AbstractReader
*
* @return SheetIterator To iterate over sheets
*/
public function getConcreteSheetIterator()
protected function getConcreteSheetIterator()
{
return $this->sheetIterator;
}
+33
View File
@@ -0,0 +1,33 @@
<?php
namespace Box\Spout\Reader\XLSX;
/**
* Class ReaderOptions
* This class is used to customize the reader's behavior
*
* @package Box\Spout\Reader\XLSX
*/
class ReaderOptions extends \Box\Spout\Reader\Common\ReaderOptions
{
/** @var string|null Temporary folder where the temporary files will be created */
protected $tempFolder = null;
/**
* @return string|null Temporary folder where the temporary files will be created
*/
public function getTempFolder()
{
return $this->tempFolder;
}
/**
* @param string|null $tempFolder Temporary folder where the temporary files will be created
* @return ReaderOptions
*/
public function setTempFolder($tempFolder)
{
$this->tempFolder = $tempFolder;
return $this;
}
}
+228 -187
View File
@@ -7,6 +7,9 @@ use Box\Spout\Reader\Exception\XMLProcessingException;
use Box\Spout\Reader\IteratorInterface;
use Box\Spout\Reader\Wrapper\XMLReader;
use Box\Spout\Reader\XLSX\Helper\CellHelper;
use Box\Spout\Reader\XLSX\Helper\CellValueFormatter;
use Box\Spout\Reader\XLSX\Helper\StyleHelper;
use Box\Spout\Reader\Common\XMLProcessor;
/**
* Class RowIterator
@@ -15,28 +18,17 @@ use Box\Spout\Reader\XLSX\Helper\CellHelper;
*/
class RowIterator implements IteratorInterface
{
/** Definition of all possible cell types */
const CELL_TYPE_INLINE_STRING = 'inlineStr';
const CELL_TYPE_STR = 'str';
const CELL_TYPE_SHARED_STRING = 's';
const CELL_TYPE_BOOLEAN = 'b';
const CELL_TYPE_NUMERIC = 'n';
const CELL_TYPE_DATE = 'd';
const CELL_TYPE_ERROR = 'e';
/** Definition of XML nodes names used to parse data */
const XML_NODE_DIMENSION = 'dimension';
const XML_NODE_WORKSHEET = 'worksheet';
const XML_NODE_ROW = 'row';
const XML_NODE_CELL = 'c';
const XML_NODE_VALUE = 'v';
const XML_NODE_INLINE_STRING_VALUE = 't';
/** Definition of XML attributes used to parse data */
const XML_ATTRIBUTE_REF = 'ref';
const XML_ATTRIBUTE_SPANS = 'spans';
const XML_ATTRIBUTE_ROW_INDEX = 'r';
const XML_ATTRIBUTE_CELL_INDEX = 'r';
const XML_ATTRIBUTE_TYPE = 't';
/** @var string Path of the XLSX file being read */
protected $filePath;
@@ -44,18 +36,27 @@ class RowIterator implements IteratorInterface
/** @var string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml */
protected $sheetDataXMLFilePath;
/** @var Helper\SharedStringsHelper Helper to work with shared strings */
protected $sharedStringsHelper;
/** @var \Box\Spout\Reader\Wrapper\XMLReader The XMLReader object that will help read sheet's XML data */
protected $xmlReader;
/** @var \Box\Spout\Common\Escaper\XLSX Used to unescape XML data */
protected $escaper;
/** @var \Box\Spout\Reader\Common\XMLProcessor Helper Object to process XML nodes */
protected $xmlProcessor;
/** @var int Number of read rows */
/** @var Helper\CellValueFormatter Helper to format cell values */
protected $cellValueFormatter;
/** @var Helper\StyleHelper $styleHelper Helper to work with styles */
protected $styleHelper;
/**
* TODO: This variable can be deleted when row indices get preserved
* @var int Number of read rows
*/
protected $numReadRows = 0;
/** @var array Contains the data for the currently processed row (key = cell index, value = cell value) */
protected $currentlyProcessedRowData = [];
/** @var array|null Buffer used to store the row data, while checking if there are more rows to read */
protected $rowDataBuffer = null;
@@ -65,19 +66,43 @@ class RowIterator implements IteratorInterface
/** @var int The number of columns the sheet has (0 meaning undefined) */
protected $numColumns = 0;
/** @var bool Whether empty rows should be returned or skipped */
protected $shouldPreserveEmptyRows;
/** @var int Last row index processed (one-based) */
protected $lastRowIndexProcessed = 0;
/** @var int Row index to be processed next (one-based) */
protected $nextRowIndexToBeProcessed = 0;
/** @var int Last column index processed (zero-based) */
protected $lastColumnIndexProcessed = -1;
/**
* @param string $filePath Path of the XLSX file being read
* @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param Helper\SharedStringsHelper $sharedStringsHelper Helper to work with shared strings
*/
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper)
public function __construct($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper)
{
$this->filePath = $filePath;
$this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
$this->sharedStringsHelper = $sharedStringsHelper;
$this->xmlReader = new XMLReader();
$this->escaper = new \Box\Spout\Common\Escaper\XLSX();
$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']);
}
/**
@@ -109,6 +134,8 @@ class RowIterator implements IteratorInterface
}
$this->numReadRows = 0;
$this->lastRowIndexProcessed = 0;
$this->nextRowIndexToBeProcessed = 0;
$this->rowDataBuffer = null;
$this->hasReachedEndOfFile = false;
$this->numColumns = 0;
@@ -120,7 +147,7 @@ class RowIterator implements IteratorInterface
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return boolean
* @return bool
*/
public function valid()
{
@@ -128,7 +155,7 @@ class RowIterator implements IteratorInterface
}
/**
* Move forward to next element. Empty rows will be skipped.
* Move forward to next element. Reads data describing the next unprocessed row.
* @link http://php.net/manual/en/iterator.next.php
*
* @return void
@@ -137,162 +164,171 @@ class RowIterator implements IteratorInterface
*/
public function next()
{
$isInsideRowTag = false;
$rowData = [];
$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 {
while ($this->xmlReader->read()) {
if ($this->xmlReader->nodeType === XMLReader::ELEMENT && $this->xmlReader->name === self::XML_NODE_DIMENSION) {
// Read dimensions of the sheet
$dimensionRef = $this->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)) {
$lastCellIndex = $matches[1];
$this->numColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1;
}
} else if ($this->xmlReader->nodeType === XMLReader::ELEMENT && $this->xmlReader->name === self::XML_NODE_ROW) {
// Start of the row description
$isInsideRowTag = true;
// Read spans info if present
$numberOfColumnsForRow = $this->numColumns;
$spans = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
if ($spans) {
list(, $numberOfColumnsForRow) = explode(':', $spans);
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
}
$rowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
} else if ($isInsideRowTag && $this->xmlReader->nodeType === XMLReader::ELEMENT && $this->xmlReader->name === self::XML_NODE_CELL) {
// Start of a cell description
$currentCellIndex = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
$currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex);
$node = $this->xmlReader->expand();
$rowData[$currentColumnIndex] = $this->getCellValue($node);
} else if ($this->xmlReader->nodeType === XMLReader::END_ELEMENT && $this->xmlReader->name === self::XML_NODE_ROW) {
// End of the row description
// If needed, we fill the empty cells
$rowData = ($this->numColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData);
$this->numReadRows++;
break;
} else if ($this->xmlReader->nodeType === XMLReader::END_ELEMENT && $this->xmlReader->name === self::XML_NODE_WORKSHEET) {
// The closing "</worksheet>" marks the end of the file
$this->hasReachedEndOfFile = true;
}
}
$this->xmlProcessor->readUntilStopped();
} catch (XMLProcessingException $exception) {
throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
}
$this->rowDataBuffer = $rowData;
$this->rowDataBuffer = $this->currentlyProcessedRowData;
}
/**
* Returns the cell's string value from a node's nested value node
*
* @param \DOMNode $node
* @return string The value associated with the cell
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<dimension>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function getVNodeValue($node)
protected function processDimensionStartingNode($xmlReader)
{
// 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);
if ($vNode !== null) {
return $vNode->nodeValue;
// 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 "";
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* 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)
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function formatInlineStringCellValue($node)
protected function processRowStartingNode($xmlReader)
{
// inline strings are formatted this way:
// <c r="A1" t="inlineStr"><is><t>[INLINE_STRING]</t></is></c>
$tNode = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE)->item(0);
$escapedCellValue = trim($tNode->nodeValue);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
// Reset index of the last processed column
$this->lastColumnIndexProcessed = -1;
/**
* Returns the cell String value from shared-strings file using nodeValue index.
*
* @param string $nodeValue
* @return string The value associated with the cell (null when the cell has an error)
*/
protected function formatSharedStringCellValue($nodeValue)
{
// shared strings are formatted this way:
// <c r="A1" t="s"><v>[SHARED_STRING_INDEX]</v></c>
$sharedStringIndex = intval($nodeValue);
$escapedCellValue = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex);
$cellValue = $this->escaper->unescape($escapedCellValue);
return $cellValue;
}
// Mark the last processed row as the one currently being read
$this->lastRowIndexProcessed = $this->getRowIndex($xmlReader);
/**
* 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.
*
* @param string $nodeValue
* @return int|float The value associated with the cell
*/
protected function formatNumericCellValue($nodeValue)
{
$cellValue = is_int($nodeValue) ? intval($nodeValue) : floatval($nodeValue);
return $cellValue;
}
/**
* 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.
*
* @param string $nodeValue
* @return \DateTime|null The value associated with the cell (null when the cell has an error)
*/
protected function formatDateCellValue($nodeValue)
{
// Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
try {
$cellValue = new \DateTime($nodeValue);
return $cellValue;
} catch (\Exception $e) {
return null;
// Read spans info if present
$numberOfColumnsForRow = $this->numColumns;
$spans = $xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
if ($spans) {
list(, $numberOfColumnsForRow) = explode(':', $spans);
$numberOfColumnsForRow = intval($numberOfColumnsForRow);
}
$this->currentlyProcessedRowData = ($numberOfColumnsForRow !== 0) ? array_fill(0, $numberOfColumnsForRow, '') : [];
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<cell>" starting node
* @return int A return code that indicates what action should the processor take next
*/
protected function processCellStartingNode($xmlReader)
{
$currentColumnIndex = $this->getColumnIndex($xmlReader);
$node = $xmlReader->expand();
$this->currentlyProcessedRowData[$currentColumnIndex] = $this->getCellValue($node);
$this->lastColumnIndexProcessed = $currentColumnIndex;
return XMLProcessor::PROCESSING_CONTINUE;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processRowEndingNode()
{
// if the fetched row is empty and we don't want to preserve it..,
if (!$this->shouldPreserveEmptyRows && $this->isEmptyRow($this->currentlyProcessedRowData)) {
// ... skip it
return XMLProcessor::PROCESSING_CONTINUE;
}
$this->numReadRows++;
// If needed, we fill the empty cells
if ($this->numColumns === 0) {
$this->currentlyProcessedRowData = CellHelper::fillMissingArrayIndexes($this->currentlyProcessedRowData);
}
// at this point, we have all the data we need for the row
// so that we can populate the buffer
return XMLProcessor::PROCESSING_STOP;
}
/**
* @return int A return code that indicates what action should the processor take next
*/
protected function processWorksheetEndingNode()
{
// The closing "</worksheet>" marks the end of the file
$this->hasReachedEndOfFile = true;
return XMLProcessor::PROCESSING_STOP;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<row>" node
* @return int Row index
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
protected function getRowIndex($xmlReader)
{
// Get "r" attribute if present (from something like <row r="3"...>
$currentRowIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ROW_INDEX);
return ($currentRowIndex !== null) ?
intval($currentRowIndex) :
$this->lastRowIndexProcessed + 1;
}
/**
* @param \Box\Spout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "<c>" node
* @return int Column index
* @throws \Box\Spout\Common\Exception\InvalidArgumentException When the given cell index is invalid
*/
protected function getColumnIndex($xmlReader)
{
// Get "r" attribute if present (from something like <c r="A1"...>
$currentCellIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
return ($currentCellIndex !== null) ?
CellHelper::getColumnIndexFromCellIndex($currentCellIndex) :
$this->lastColumnIndexProcessed + 1;
}
/**
@@ -303,52 +339,57 @@ class RowIterator implements IteratorInterface
*/
protected function getCellValue($node)
{
// Default cell type is "n"
$cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
$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);
case self::CELL_TYPE_DATE:
return $this->formatDateCellValue($vNodeValue);
default:
return null;
}
return $this->cellValueFormatter->extractAndFormatNodeValue($node);
}
/**
* Return the current element, from the buffer.
* @param array $rowData
* @return bool Whether the given row is empty
*/
protected function isEmptyRow($rowData)
{
return (count($rowData) === 1 && $rowData[0] === '');
}
/**
* 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()
{
return $this->rowDataBuffer;
$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
* 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()
{
return $this->numReadRows;
// 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;
}
+12 -21
View File
@@ -12,13 +12,10 @@ use Box\Spout\Reader\SheetInterface;
*/
class Sheet implements SheetInterface
{
/** @var RowIterator To iterate over sheet's rows */
/** @var \Box\Spout\Reader\XLSX\RowIterator To iterate over sheet's rows */
protected $rowIterator;
/** @var int ID of the sheet */
protected $id;
/** @var int Index of the sheet, based on order of creation (zero-based) */
/** @var int Index of the sheet, based on order in the workbook (zero-based) */
protected $index;
/** @var string Name of the sheet */
@@ -27,21 +24,21 @@ 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 int $sheetId ID of the sheet
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
* @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
* @param string $sheetName Name of the sheet
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param Helper\SharedStringsHelper Helper to work with shared strings
*/
public function __construct($filePath, $sheetDataXMLFilePath, $sharedStringsHelper, $sheetId, $sheetIndex, $sheetName)
public function __construct($filePath, $sheetDataXMLFilePath, $sheetIndex, $sheetName, $options, $sharedStringsHelper)
{
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $sharedStringsHelper);
$this->id = $sheetId;
$this->rowIterator = new RowIterator($filePath, $sheetDataXMLFilePath, $options, $sharedStringsHelper);
$this->index = $sheetIndex;
$this->name = $sheetName;
}
/**
* @return RowIterator
* @api
* @return \Box\Spout\Reader\XLSX\RowIterator
*/
public function getRowIterator()
{
@@ -49,15 +46,8 @@ class Sheet implements SheetInterface
}
/**
* @return int ID of the sheet
*/
public function getId()
{
return $this->id;
}
/**
* @return int Index of the sheet, based on order of creation (zero-based)
* @api
* @return int Index of the sheet, based on order in the workbook (zero-based)
*/
public function getIndex()
{
@@ -65,6 +55,7 @@ class Sheet implements SheetInterface
}
/**
* @api
* @return string Name of the sheet
*/
public function getName()
+8 -6
View File
@@ -14,7 +14,7 @@ use Box\Spout\Reader\Exception\NoSheetsFoundException;
*/
class SheetIterator implements IteratorInterface
{
/** @var Sheet[] The list of sheet present in the file */
/** @var \Box\Spout\Reader\XLSX\Sheet[] The list of sheet present in the file */
protected $sheets;
/** @var int The index of the sheet being read (zero-based) */
@@ -22,14 +22,15 @@ class SheetIterator implements IteratorInterface
/**
* @param string $filePath Path of the file to be read
* @param \Box\Spout\Reader\XLSX\ReaderOptions $options Reader's current options
* @param \Box\Spout\Reader\XLSX\Helper\SharedStringsHelper $sharedStringsHelper
* @param \Box\Spout\Common\Helper\GlobalFunctionsHelper $globalFunctionsHelper
* @throws \Box\Spout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
*/
public function __construct($filePath, $sharedStringsHelper, $globalFunctionsHelper)
public function __construct($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper)
{
// Fetch all available sheets
$sheetHelper = new SheetHelper($filePath, $sharedStringsHelper, $globalFunctionsHelper);
$sheetHelper = new SheetHelper($filePath, $options, $sharedStringsHelper, $globalFunctionsHelper);
$this->sheets = $sheetHelper->getSheets();
if (count($this->sheets) === 0) {
@@ -52,7 +53,7 @@ class SheetIterator implements IteratorInterface
* Checks if current position is valid
* @link http://php.net/manual/en/iterator.valid.php
*
* @return boolean
* @return bool
*/
public function valid()
{
@@ -67,7 +68,8 @@ class SheetIterator implements IteratorInterface
*/
public function next()
{
if (array_key_exists($this->currentSheetIndex, $this->sheets)) {
// Using isset here because it is way faster than array_key_exists...
if (isset($this->sheets[$this->currentSheetIndex])) {
$currentSheet = $this->sheets[$this->currentSheetIndex];
$currentSheet->getRowIterator()->end();
@@ -79,7 +81,7 @@ class SheetIterator implements IteratorInterface
* Return the current element
* @link http://php.net/manual/en/iterator.current.php
*
* @return Sheet
* @return \Box\Spout\Reader\XLSX\Sheet
*/
public function current()
{
@@ -0,0 +1,119 @@
<?php
namespace Box\Spout\Writer;
use Box\Spout\Writer\Exception\WriterNotOpenedException;
/**
* Class AbstractMultiSheetsWriter
*
* @package Box\Spout\Writer
* @abstract
*/
abstract class AbstractMultiSheetsWriter extends AbstractWriter
{
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically = true;
/**
* @return Common\Internal\WorkbookInterface The workbook representing the file to be written
*/
abstract protected function getWorkbook();
/**
* Sets whether new sheets should be automatically created when the max rows limit per sheet is reached.
* This must be set before opening the writer.
*
* @api
* @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached
* @return AbstractMultiSheetsWriter
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
return $this;
}
/**
* Returns all the workbook's sheets
*
* @api
* @return Common\Sheet[] All the workbook's sheets
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function getSheets()
{
$this->throwIfBookIsNotAvailable();
$externalSheets = [];
$worksheets = $this->getWorkbook()->getWorksheets();
/** @var Common\Internal\WorksheetInterface $worksheet */
foreach ($worksheets as $worksheet) {
$externalSheets[] = $worksheet->getExternalSheet();
}
return $externalSheets;
}
/**
* Creates a new sheet and make it the current sheet. The data will now be written to this sheet.
*
* @api
* @return Common\Sheet The created sheet
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function addNewSheetAndMakeItCurrent()
{
$this->throwIfBookIsNotAvailable();
$worksheet = $this->getWorkbook()->addNewSheetAndMakeItCurrent();
return $worksheet->getExternalSheet();
}
/**
* Returns the current sheet
*
* @api
* @return Common\Sheet The current sheet
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
*/
public function getCurrentSheet()
{
$this->throwIfBookIsNotAvailable();
return $this->getWorkbook()->getCurrentWorksheet()->getExternalSheet();
}
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @api
* @param Common\Sheet $sheet The sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet)
{
$this->throwIfBookIsNotAvailable();
$this->getWorkbook()->setCurrentSheet($sheet);
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
*/
protected function throwIfBookIsNotAvailable()
{
if (!$this->getWorkbook()) {
throw new WriterNotOpenedException('The writer must be opened before performing this action.');
}
}
}
+85 -5
View File
@@ -4,6 +4,9 @@ namespace Box\Spout\Writer;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\SpoutException;
use Box\Spout\Common\Helper\FileSystemHelper;
use Box\Spout\Writer\Exception\WriterAlreadyOpenedException;
use Box\Spout\Writer\Exception\WriterNotOpenedException;
use Box\Spout\Writer\Style\StyleBuilder;
@@ -70,6 +73,21 @@ abstract class AbstractWriter implements WriterInterface
$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
@@ -84,6 +102,7 @@ abstract class AbstractWriter implements WriterInterface
* 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
@@ -107,6 +126,7 @@ abstract class AbstractWriter implements WriterInterface
*
* @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
@@ -118,6 +138,10 @@ abstract class AbstractWriter implements WriterInterface
$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 . '"');
@@ -152,23 +176,48 @@ abstract class AbstractWriter implements WriterInterface
}
}
/**
* 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)) {
$this->addRowToWriter($dataRow, $this->rowStyle);
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.');
@@ -181,6 +230,7 @@ abstract class AbstractWriter implements WriterInterface
* 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
@@ -204,13 +254,13 @@ abstract class AbstractWriter implements WriterInterface
/**
* 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
@@ -219,7 +269,8 @@ abstract class AbstractWriter implements WriterInterface
public function addRows(array $dataRows)
{
if (!empty($dataRows)) {
if (!is_array($dataRows[0])) {
$firstRow = reset($dataRows);
if (!is_array($firstRow)) {
throw new InvalidArgumentException('The input should be an array of arrays');
}
@@ -235,6 +286,7 @@ abstract class AbstractWriter implements WriterInterface
* 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
@@ -293,12 +345,40 @@ abstract class AbstractWriter implements WriterInterface
* 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);
}
}
}
+21 -6
View File
@@ -29,9 +29,13 @@ class Writer extends AbstractWriter
/** @var int */
protected $lastWrittenRowIndex = 0;
/** @var bool */
protected $shouldAddBOM = true;
/**
* Sets the field delimiter for the CSV
*
* @api
* @param string $fieldDelimiter Character that delimits fields
* @return Writer
*/
@@ -44,6 +48,7 @@ class Writer extends AbstractWriter
/**
* Sets the field enclosure for the CSV
*
* @api
* @param string $fieldEnclosure Character that enclose fields
* @return Writer
*/
@@ -53,6 +58,18 @@ class Writer extends AbstractWriter
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.
*
@@ -60,8 +77,10 @@ class Writer extends AbstractWriter
*/
protected function openWriter()
{
// Adds UTF-8 BOM for Unicode compatibility
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
if ($this->shouldAddBOM) {
// Adds UTF-8 BOM for Unicode compatibility
$this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
}
}
/**
@@ -94,10 +113,6 @@ class Writer extends AbstractWriter
*/
protected function closeWriter()
{
if ($this->filePointer) {
$this->globalFunctionsHelper->fclose($this->filePointer);
}
$this->lastWrittenRowIndex = 0;
}
}
@@ -0,0 +1,138 @@
<?php
namespace Box\Spout\Writer\Common\Helper;
/**
* Class AbstractStyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\Common\Helper
*/
abstract class AbstractStyleHelper
{
/** @var array [SERIALIZED_STYLE] => [STYLE_ID] mapping table, keeping track of the registered styles */
protected $serializedStyleToStyleIdMappingTable = [];
/** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */
protected $styleIdToStyleMappingTable = [];
/**
* @param \Box\Spout\Writer\Style\Style $defaultStyle
*/
public function __construct($defaultStyle)
{
// This ensures that the default style is the first one to be registered
$this->registerStyle($defaultStyle);
}
/**
* Registers the given style as a used style.
* Duplicate styles won't be registered more than once.
*
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
*/
public function registerStyle($style)
{
$serializedStyle = $style->serialize();
if (!$this->hasStyleAlreadyBeenRegistered($style)) {
$nextStyleId = count($this->serializedStyleToStyleIdMappingTable);
$style->setId($nextStyleId);
$this->serializedStyleToStyleIdMappingTable[$serializedStyle] = $nextStyleId;
$this->styleIdToStyleMappingTable[$nextStyleId] = $style;
}
return $this->getStyleFromSerializedStyle($serializedStyle);
}
/**
* Returns whether the given style has already been registered.
*
* @param \Box\Spout\Writer\Style\Style $style
* @return bool
*/
protected function hasStyleAlreadyBeenRegistered($style)
{
$serializedStyle = $style->serialize();
// Using isset here because it is way faster than array_key_exists...
return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
}
/**
* Returns the registered style associated to the given serialization.
*
* @param string $serializedStyle The serialized style from which the actual style should be fetched from
* @return \Box\Spout\Writer\Style\Style
*/
protected function getStyleFromSerializedStyle($serializedStyle)
{
$styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle];
return $this->styleIdToStyleMappingTable[$styleId];
}
/**
* @return \Box\Spout\Writer\Style\Style[] List of registered styles
*/
protected function getRegisteredStyles()
{
return array_values($this->styleIdToStyleMappingTable);
}
/**
* Returns the default style
*
* @return \Box\Spout\Writer\Style\Style Default style
*/
protected function getDefaultStyle()
{
// By construction, the default style has ID 0
return $this->styleIdToStyleMappingTable[0];
}
/**
* Apply additional styles if the given row needs it.
* Typically, set "wrap text" if a cell contains a new line.
*
* @param \Box\Spout\Writer\Style\Style $style The original style
* @param array $dataRow The row the style will be applied to
* @return \Box\Spout\Writer\Style\Style The updated style
*/
public function applyExtraStylesIfNeeded($style, $dataRow)
{
$updatedStyle = $this->applyWrapTextIfCellContainsNewLine($style, $dataRow);
return $updatedStyle;
}
/**
* Set the "wrap text" option if a cell of the given row contains a new line.
*
* @NOTE: There is a bug on the Mac version of Excel (2011 and below) where new lines
* are ignored even when the "wrap text" option is set. This only occurs with
* inline strings (shared strings do work fine).
* A workaround would be to encode "\n" as "_x000D_" but it does not work
* on the Windows version of Excel...
*
* @param \Box\Spout\Writer\Style\Style $style The original style
* @param array $dataRow The row the style will be applied to
* @return \Box\Spout\Writer\Style\Style The eventually updated style
*/
protected function applyWrapTextIfCellContainsNewLine($style, $dataRow)
{
// if the "wrap text" option is already set, no-op
if ($style->hasSetWrapText()) {
return $style;
}
foreach ($dataRow as $cell) {
if (is_string($cell) && strpos($cell, "\n") !== false) {
$style->setShouldWrapText();
break;
}
}
return $style;
}
}
@@ -1,15 +1,18 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
namespace Box\Spout\Writer\Common\Helper;
/**
* Class CellHelper
* This class provides helper functions when working with cells
*
* @package Box\Spout\Writer\XLSX\Helper
* @package Box\Spout\Writer\Common\Helper
*/
class CellHelper
{
/** @var array Cache containing the mapping column index => cell index */
private static $columnIndexToCellIndexCache = [];
/**
* Returns the cell index (base 26) associated to the base 10 column index.
* Excel uses A to Z letters for column indexing, where A is the 1st column,
@@ -21,18 +24,35 @@ class CellHelper
*/
public static function getCellIndexFromColumnIndex($columnIndex)
{
$cellIndex = '';
$capitalAAsciiValue = ord('A');
$originalColumnIndex = $columnIndex;
do {
$modulus = $columnIndex % 26;
$cellIndex = chr($capitalAAsciiValue + $modulus) . $cellIndex;
// Using isset here because it is way faster than array_key_exists...
if (!isset(self::$columnIndexToCellIndexCache[$originalColumnIndex])) {
$cellIndex = '';
$capitalAAsciiValue = ord('A');
// substracting 1 because it's zero-based
$columnIndex = intval($columnIndex / 26) - 1;
} while ($columnIndex >= 0);
do {
$modulus = $columnIndex % 26;
$cellIndex = chr($capitalAAsciiValue + $modulus) . $cellIndex;
return $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 === '');
}
/**
@@ -0,0 +1,217 @@
<?php
namespace Box\Spout\Writer\Common\Helper;
/**
* Class ZipHelper
* This class provides helper functions to create zip files
*
* @package Box\Spout\Writer\Common\Helper
*/
class ZipHelper
{
const ZIP_EXTENSION = '.zip';
/** Controls what to do when trying to add an existing file */
const EXISTING_FILES_SKIP = 'skip';
const EXISTING_FILES_OVERWRITE = 'overwrite';
/** @var string Path of the folder where the zip file will be created */
protected $tmpFolderPath;
/** @var \ZipArchive The ZipArchive instance */
protected $zip;
/**
* @param string $tmpFolderPath Path of the temp folder where the zip file will be created
*/
public function __construct($tmpFolderPath)
{
$this->tmpFolderPath = $tmpFolderPath;
}
/**
* Returns the already created ZipArchive instance or
* creates one if none exists.
*
* @return \ZipArchive
*/
protected function createOrGetZip()
{
if (!isset($this->zip)) {
$this->zip = new \ZipArchive();
$zipFilePath = $this->getZipFilePath();
$this->zip->open($zipFilePath, \ZipArchive::CREATE|\ZipArchive::OVERWRITE);
}
return $this->zip;
}
/**
* @return string Path where the zip file of the given folder will be created
*/
public function getZipFilePath()
{
return $this->tmpFolderPath . self::ZIP_EXTENSION;
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will be compressed.
*
* Example of use:
* addFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
public function addFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$this->addFileToArchiveWithCompressionMethod(
$rootFolderPath,
$localFilePath,
$existingFileMode,
\ZipArchive::CM_DEFAULT
);
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will NOT be compressed.
*
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
public function addUncompressedFileToArchive($rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$this->addFileToArchiveWithCompressionMethod(
$rootFolderPath,
$localFilePath,
$existingFileMode,
\ZipArchive::CM_STORE
);
}
/**
* Adds the given file, located under the given root folder to the archive.
* The file will NOT be compressed.
*
* Example of use:
* addUncompressedFileToArchive('/tmp/xlsx/foo', 'bar/baz.xml');
* => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
*
* @param string $rootFolderPath Path of the root folder that will be ignored in the archive tree.
* @param string $localFilePath Path of the file to be added, under the root folder
* @param string $existingFileMode Controls what to do when trying to add an existing file
* @param int $compressionMethod The compression method
* @return void
*/
protected function addFileToArchiveWithCompressionMethod($rootFolderPath, $localFilePath, $existingFileMode, $compressionMethod)
{
$zip = $this->createOrGetZip();
if (!$this->shouldSkipFile($zip, $localFilePath, $existingFileMode)) {
$normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath . '/' . $localFilePath);
$zip->addFile($normalizedFullFilePath, $localFilePath);
if (self::canChooseCompressionMethod()) {
$zip->setCompressionName($localFilePath, $compressionMethod);
}
}
}
/**
* @return bool Whether it is possible to choose the desired compression method to be used
*/
public static function canChooseCompressionMethod()
{
// setCompressionName() is a PHP7+ method...
return (method_exists(new \ZipArchive(), 'setCompressionName'));
}
/**
* @param string $folderPath Path to the folder to be zipped
* @param string|void $existingFileMode Controls what to do when trying to add an existing file
* @return void
*/
public function addFolderToArchive($folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
{
$zip = $this->createOrGetZip();
$folderRealPath = $this->getNormalizedRealPath($folderPath) . '/';
$itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
foreach ($itemIterator as $itemInfo) {
$itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
$itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
$zip->addFile($itemRealPath, $itemLocalPath);
}
}
}
/**
* @param \ZipArchive $zip
* @param string $itemLocalPath
* @param string $existingFileMode
* @return bool Whether the file should be added to the archive or skipped
*/
protected function shouldSkipFile($zip, $itemLocalPath, $existingFileMode)
{
// Skip files if:
// - EXISTING_FILES_SKIP mode chosen
// - File already exists in the archive
return ($existingFileMode === self::EXISTING_FILES_SKIP && $zip->locateName($itemLocalPath) !== false);
}
/**
* Returns canonicalized absolute pathname, containing only forward slashes.
*
* @param string $path Path to normalize
* @return string Normalized and canonicalized path
*/
protected function getNormalizedRealPath($path)
{
$realPath = realpath($path);
return str_replace(DIRECTORY_SEPARATOR, '/', $realPath);
}
/**
* Closes the archive and copies it into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function closeArchiveAndCopyToStream($streamPointer)
{
$zip = $this->createOrGetZip();
$zip->close();
unset($this->zip);
$this->copyZipToStream($streamPointer);
}
/**
* Streams the contents of the zip file into the given stream
*
* @param resource $pointer Pointer to the stream to copy the zip
* @return void
*/
protected function copyZipToStream($pointer)
{
$zipFilePointer = fopen($this->getZipFilePath(), 'r');
stream_copy_to_stream($zipFilePointer, $pointer);
fclose($zipFilePointer);
}
}
@@ -0,0 +1,192 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
use Box\Spout\Writer\Exception\SheetNotFoundException;
/**
* Class Workbook
* Represents a workbook within a spreadsheet file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\Common
*/
abstract class AbstractWorkbook implements WorkbookInterface
{
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically;
/** @var string Timestamp based unique ID identifying the workbook */
protected $internalId;
/** @var WorksheetInterface[] Array containing the workbook's sheets */
protected $worksheets = [];
/** @var WorksheetInterface The worksheet where data will be written to */
protected $currentWorksheet;
/**
* @param bool $shouldCreateNewSheetsAutomatically
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function __construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
$this->internalId = uniqid();
}
/**
* @return \Box\Spout\Writer\Common\Helper\AbstractStyleHelper The specific style helper
*/
abstract protected function getStyleHelper();
/**
* @return int Maximum number of rows/columns a sheet can contain
*/
abstract protected function getMaxRowsPerWorksheet();
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
abstract public function addNewSheet();
/**
* Creates a new sheet in the workbook and make it the current sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheetAndMakeItCurrent()
{
$worksheet = $this->addNewSheet();
$this->setCurrentWorksheet($worksheet);
return $worksheet;
}
/**
* @return WorksheetInterface[] All the workbook's sheets
*/
public function getWorksheets()
{
return $this->worksheets;
}
/**
* Returns the current sheet
*
* @return WorksheetInterface The current sheet
*/
public function getCurrentWorksheet()
{
return $this->currentWorksheet;
}
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet)
{
$worksheet = $this->getWorksheetFromExternalSheet($sheet);
if ($worksheet !== null) {
$this->currentWorksheet = $worksheet;
} else {
throw new SheetNotFoundException('The given sheet does not exist in the workbook.');
}
}
/**
* @param WorksheetInterface $worksheet
* @return void
*/
protected function setCurrentWorksheet($worksheet)
{
$this->currentWorksheet = $worksheet;
}
/**
* Returns the worksheet associated to the given external sheet.
*
* @param \Box\Spout\Writer\Common\Sheet $sheet
* @return WorksheetInterface|null The worksheet associated to the given external sheet or null if not found.
*/
protected function getWorksheetFromExternalSheet($sheet)
{
$worksheetFound = null;
foreach ($this->worksheets as $worksheet) {
if ($worksheet->getExternalSheet() === $sheet) {
$worksheetFound = $worksheet;
break;
}
}
return $worksheetFound;
}
/**
* Adds data to the current sheet.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
*/
public function addRowToCurrentWorksheet($dataRow, $style)
{
$currentWorksheet = $this->getCurrentWorksheet();
$hasReachedMaxRows = $this->hasCurrentWorkseetReachedMaxRows();
$styleHelper = $this->getStyleHelper();
// if we reached the maximum number of rows for the current sheet...
if ($hasReachedMaxRows) {
// ... continue writing in a new sheet if option set
if ($this->shouldCreateNewSheetsAutomatically) {
$currentWorksheet = $this->addNewSheetAndMakeItCurrent();
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
$currentWorksheet->addRow($dataRow, $registeredStyle);
} else {
// otherwise, do nothing as the data won't be read anyways
}
} else {
$updatedStyle = $styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $styleHelper->registerStyle($updatedStyle);
$currentWorksheet->addRow($dataRow, $registeredStyle);
}
}
/**
* @return bool Whether the current worksheet has reached the maximum number of rows per sheet.
*/
protected function hasCurrentWorkseetReachedMaxRows()
{
$currentWorksheet = $this->getCurrentWorksheet();
return ($currentWorksheet->getLastWrittenRowIndex() >= $this->getMaxRowsPerWorksheet());
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
abstract public function close($finalFilePointer);
}
@@ -0,0 +1,74 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
/**
* Interface WorkbookInterface
*
* @package Box\Spout\Writer\Common\Internal
*/
interface WorkbookInterface
{
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheet();
/**
* Creates a new sheet in the workbook and make it the current sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @return WorksheetInterface The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheetAndMakeItCurrent();
/**
* @return WorksheetInterface[] All the workbook's sheets
*/
public function getWorksheets();
/**
* Returns the current sheet
*
* @return WorksheetInterface The current sheet
*/
public function getCurrentWorksheet();
/**
* Sets the given sheet as the current one. New data will be written to this sheet.
* The writing will resume where it stopped (i.e. data won't be truncated).
*
* @param \Box\Spout\Writer\Common\Sheet $sheet The "external" sheet to set as current
* @return void
* @throws \Box\Spout\Writer\Exception\SheetNotFoundException If the given sheet does not exist in the workbook
*/
public function setCurrentSheet($sheet);
/**
* Adds data to the current sheet.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If trying to create a new sheet and unable to open the sheet for writing
* @throws \Box\Spout\Writer\Exception\WriterException If unable to write data
*/
public function addRowToCurrentWorksheet($dataRow, $style);
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
public function close($finalFilePointer);
}
@@ -0,0 +1,40 @@
<?php
namespace Box\Spout\Writer\Common\Internal;
/**
* Interface WorksheetInterface
*
* @package Box\Spout\Writer\Common\Internal
*/
interface WorksheetInterface
{
/**
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
*/
public function getExternalSheet();
/**
* @return int The index of the last written row
*/
public function getLastWrittenRowIndex();
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
*/
public function addRow($dataRow, $style);
/**
* Closes the worksheet
*
* @return void
*/
public function close();
}
@@ -1,15 +1,15 @@
<?php
namespace Box\Spout\Writer\XLSX;
namespace Box\Spout\Writer\Common;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Exception\InvalidSheetNameException;
/**
* Class Sheet
* External representation of a worksheet within a XLSX file
* External representation of a worksheet
*
* @package Box\Spout\Writer\XLSX
* @package Box\Spout\Writer\Common
*/
class Sheet
{
@@ -21,12 +21,15 @@ class Sheet
/** @var array Invalid characters that cannot be contained in the sheet name */
private static $INVALID_CHARACTERS_IN_SHEET_NAME = ['\\', '/', '?', '*', ':', '[', ']'];
/** @var array Associative array [SHEET_INDEX] => [SHEET_NAME] keeping track of sheets' name to enforce uniqueness */
/** @var array Associative array [WORKBOOK_ID] => [[SHEET_INDEX] => [SHEET_NAME]] keeping track of sheets' name to enforce uniqueness per workbook */
protected static $SHEETS_NAME_USED = [];
/** @var int Index of the sheet, based on order of creation (zero-based) */
/** @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;
@@ -34,17 +37,24 @@ class Sheet
protected $stringHelper;
/**
* @param int $sheetIndex Index of the sheet, based on order of creation (zero-based)
* @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)
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));
}
/**
* @return int Index of the sheet, based on order of creation (zero-based)
* @api
* @return int Index of the sheet, based on order in the workbook (zero-based)
*/
public function getIndex()
{
@@ -52,6 +62,7 @@ class Sheet
}
/**
* @api
* @return string Name of the sheet
*/
public function getName()
@@ -66,49 +77,65 @@ class Sheet
* - 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)
{
if (!$this->isNameValid($name)) {
$errorMessage = "The sheet's name is invalid. It did not meet at least one of these requirements:\n";
$errorMessage .= " - It should not be blank\n";
$errorMessage .= " - It should not exceed 31 characters\n";
$errorMessage .= " - It should not contain these characters: \\ / ? * : [ or ]\n";
$errorMessage .= " - It should be unique";
throw new InvalidSheetNameException($errorMessage);
}
$this->throwIfNameIsInvalid($name);
$this->name = $name;
self::$SHEETS_NAME_USED[$this->index] = $name;
self::$SHEETS_NAME_USED[$this->associatedWorkbookId][$this->index] = $name;
return $this;
}
/**
* Returns whether the given sheet's name is valid.
* Throws an exception if the given sheet's name is not valid.
* @see Sheet::setName for validity rules.
*
* @param string $name
* @return bool TRUE if the name is valid, FALSE otherwise.
* @return void
* @throws \Box\Spout\Writer\Exception\InvalidSheetNameException If the sheet's name is invalid.
*/
protected function isNameValid($name)
protected function throwIfNameIsInvalid($name)
{
if (!is_string($name)) {
return false;
$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);
return (
$nameLength > 0 &&
$nameLength <= self::MAX_LENGTH_SHEET_NAME &&
!$this->doesContainInvalidCharacters($name) &&
$this->isNameUnique($name) &&
!$this->doesStartOrEndWithSingleQuote($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);
}
}
/**
@@ -145,7 +172,7 @@ class Sheet
*/
protected function isNameUnique($name)
{
foreach (self::$SHEETS_NAME_USED as $sheetIndex => $sheetName) {
foreach (self::$SHEETS_NAME_USED[$this->associatedWorkbookId] as $sheetIndex => $sheetName) {
if ($sheetIndex !== $this->index && $sheetName === $name) {
return false;
}
@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidNameException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid name identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedNames())));
}
}
@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidStyleException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid style identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedStyles())));
}
}
@@ -0,0 +1,16 @@
<?php
namespace Box\Spout\Writer\Exception\Border;
use Box\Spout\Writer\Exception\WriterException;
use Box\Spout\Writer\Style\BorderPart;
class InvalidWidthException extends WriterException
{
public function __construct($name)
{
$msg = '%s is not a valid width identifier for a border. Valid identifiers are: %s.';
parent::__construct(sprintf($msg, $name, implode(',', BorderPart::getAllowedWidths())));
}
}
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
/**
* Class InvalidColorException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class InvalidColorException extends WriterException
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
/**
* Class InvalidSheetNameException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class InvalidSheetNameException extends WriterException
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
/**
* Class SheetNotFoundException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class SheetNotFoundException extends WriterException
@@ -0,0 +1,13 @@
<?php
namespace Box\Spout\Writer\Exception;
/**
* Class WriterAlreadyOpenedException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class WriterAlreadyOpenedException extends WriterException
{
}
@@ -5,6 +5,7 @@ namespace Box\Spout\Writer\Exception;
/**
* Class WriterNotOpenedException
*
* @api
* @package Box\Spout\Writer\Exception
*/
class WriterNotOpenedException extends WriterException
@@ -0,0 +1,68 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Style\BorderPart;
use Box\Spout\Writer\Style\Border;
/**
* Class BorderHelper
*
* The fo:border, fo:border-top, fo:border-bottom, fo:border-left and fo:border-right attributes
* specify border properties
* http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1419780_253892949
*
* Example table-cell-properties
*
* <style:table-cell-properties
* fo:border-bottom="0.74pt solid #ffc000" style:diagonal-bl-tr="none"
* style:diagonal-tl-br="none" fo:border-left="none" fo:border-right="none"
* style:rotation-align="none" fo:border-top="none"/>
*/
class BorderHelper
{
/**
* Width mappings
*
* @var array
*/
protected static $widthMap = [
Border::WIDTH_THIN => '0.75pt',
Border::WIDTH_MEDIUM => '1.75pt',
Border::WIDTH_THICK => '2.5pt',
];
/**
* Style mapping
*
* @var array
*/
protected static $styleMap = [
Border::STYLE_SOLID => 'solid',
Border::STYLE_DASHED => 'dashed',
Border::STYLE_DOTTED => 'dotted',
Border::STYLE_DOUBLE => 'double',
];
/**
* @param BorderPart $borderPart
* @return string
*/
public static function serializeBorderPart(BorderPart $borderPart)
{
$definition = 'fo:border-%s="%s"';
if ($borderPart->getStyle() === Border::STYLE_NONE) {
$borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none');
} else {
$attributes = [
self::$widthMap[$borderPart->getWidth()],
self::$styleMap[$borderPart->getStyle()],
'#' . $borderPart->getColor(),
];
$borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
}
return $borderPartDefinition;
}
}
@@ -0,0 +1,279 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\ODS\Internal\Worksheet;
/**
* Class FileSystemHelper
* This class provides helper functions to help with the file system operations
* like files/folders creation & deletion for ODS files
*
* @package Box\Spout\Writer\ODS\Helper
*/
class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
{
const APP_NAME = 'Spout';
const MIMETYPE = 'application/vnd.oasis.opendocument.spreadsheet';
const META_INF_FOLDER_NAME = 'META-INF';
const SHEETS_CONTENT_TEMP_FOLDER_NAME = 'worksheets-temp';
const MANIFEST_XML_FILE_NAME = 'manifest.xml';
const CONTENT_XML_FILE_NAME = 'content.xml';
const META_XML_FILE_NAME = 'meta.xml';
const MIMETYPE_FILE_NAME = 'mimetype';
const STYLES_XML_FILE_NAME = 'styles.xml';
/** @var string Path to the root folder inside the temp folder where the files to create the ODS will be stored */
protected $rootFolder;
/** @var string Path to the "META-INF" folder inside the root folder */
protected $metaInfFolder;
/** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */
protected $sheetsContentTempFolder;
/**
* @return string
*/
public function getRootFolder()
{
return $this->rootFolder;
}
/**
* @return string
*/
public function getSheetsContentTempFolder()
{
return $this->sheetsContentTempFolder;
}
/**
* Creates all the folders needed to create a ODS file, as well as the files that won't change.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function createBaseFilesAndFolders()
{
$this
->createRootFolder()
->createMetaInfoFolderAndFile()
->createSheetsContentTempFolder()
->createMetaFile()
->createMimetypeFile();
}
/**
* Creates the folder that will be used as root
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createRootFolder()
{
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
return $this;
}
/**
* Creates the "META-INF" folder under the root folder as well as the "manifest.xml" file in it
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or the "manifest.xml" file
*/
protected function createMetaInfoFolderAndFile()
{
$this->metaInfFolder = $this->createFolder($this->rootFolder, self::META_INF_FOLDER_NAME);
$this->createManifestFile();
return $this;
}
/**
* Creates the "manifest.xml" file under the "META-INF" folder (under root)
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createManifestFile()
{
$manifestXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8"?>
<manifest:manifest xmlns:manifest="urn:oasis:names:tc:opendocument:xmlns:manifest:1.0" manifest:version="1.2">
<manifest:file-entry manifest:full-path="/" manifest:media-type="application/vnd.oasis.opendocument.spreadsheet"/>
<manifest:file-entry manifest:full-path="styles.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="content.xml" manifest:media-type="text/xml"/>
<manifest:file-entry manifest:full-path="meta.xml" manifest:media-type="text/xml"/>
</manifest:manifest>
EOD;
$this->createFileWithContents($this->metaInfFolder, self::MANIFEST_XML_FILE_NAME, $manifestXmlFileContents);
return $this;
}
/**
* Creates the temp folder where specific sheets content will be written to.
* This folder is not part of the final ODS file and is only used to be able to jump between sheets.
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the folder
*/
protected function createSheetsContentTempFolder()
{
$this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, self::SHEETS_CONTENT_TEMP_FOLDER_NAME);
return $this;
}
/**
* Creates the "meta.xml" file under the root folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createMetaFile()
{
$appName = self::APP_NAME;
$createdDate = (new \DateTime())->format(\DateTime::W3C);
$metaXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-meta office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:meta="urn:oasis:names:tc:opendocument:xmlns:meta:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
<office:meta>
<dc:creator>$appName</dc:creator>
<meta:creation-date>$createdDate</meta:creation-date>
<dc:date>$createdDate</dc:date>
</office:meta>
</office:document-meta>
EOD;
$this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents);
return $this;
}
/**
* Creates the "mimetype" file under the root folder
*
* @return FileSystemHelper
* @throws \Box\Spout\Common\Exception\IOException If unable to create the file
*/
protected function createMimetypeFile()
{
$this->createFileWithContents($this->rootFolder, self::MIMETYPE_FILE_NAME, self::MIMETYPE);
return $this;
}
/**
* Creates the "content.xml" file under the root folder
*
* @param Worksheet[] $worksheets
* @param StyleHelper $styleHelper
* @return FileSystemHelper
*/
public function createContentFile($worksheets, $styleHelper)
{
$contentXmlFileContents = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-content office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:calcext="urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
EOD;
$contentXmlFileContents .= $styleHelper->getContentXmlFontFaceSectionContent();
$contentXmlFileContents .= $styleHelper->getContentXmlAutomaticStylesSectionContent(count($worksheets));
$contentXmlFileContents .= '<office:body><office:spreadsheet>';
$this->createFileWithContents($this->rootFolder, self::CONTENT_XML_FILE_NAME, $contentXmlFileContents);
// Append sheets content to "content.xml"
$contentXmlFilePath = $this->rootFolder . '/' . self::CONTENT_XML_FILE_NAME;
$contentXmlHandle = fopen($contentXmlFilePath, 'a');
foreach ($worksheets as $worksheet) {
// write the "<table:table>" node, with the final sheet's name
fwrite($contentXmlHandle, $worksheet->getTableElementStartAsString());
$worksheetFilePath = $worksheet->getWorksheetFilePath();
$this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle);
fwrite($contentXmlHandle, '</table:table>');
}
$contentXmlFileContents = '</office:spreadsheet></office:body></office:document-content>';
fwrite($contentXmlHandle, $contentXmlFileContents);
fclose($contentXmlHandle);
return $this;
}
/**
* Streams the content of the file at the given path into the target resource.
* Depending on which mode the target resource was created with, it will truncate then copy
* or append the content to the target file.
*
* @param string $sourceFilePath Path of the file whose content will be copied
* @param resource $targetResource Target resource that will receive the content
* @return void
*/
protected function copyFileContentsToTarget($sourceFilePath, $targetResource)
{
$sourceHandle = fopen($sourceFilePath, 'r');
stream_copy_to_stream($sourceHandle, $targetResource);
fclose($sourceHandle);
}
/**
* Deletes the temporary folder where sheets content was stored.
*
* @return FileSystemHelper
*/
public function deleteWorksheetTempFolder()
{
$this->deleteFolderRecursively($this->sheetsContentTempFolder);
return $this;
}
/**
* Creates the "styles.xml" file under the root folder
*
* @param StyleHelper $styleHelper
* @param int $numWorksheets Number of created worksheets
* @return FileSystemHelper
*/
public function createStylesFile($styleHelper, $numWorksheets)
{
$stylesXmlFileContents = $styleHelper->getStylesXMLFileContent($numWorksheets);
$this->createFileWithContents($this->rootFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
return $this;
}
/**
* Zips the root folder and streams the contents of the zip into the given stream
*
* @param resource $streamPointer Pointer to the stream to copy the zip
* @return void
*/
public function zipRootFolderAndCopyToStream($streamPointer)
{
$zipHelper = new ZipHelper($this->rootFolder);
// In order to have the file's mime type detected properly, files need to be added
// to the zip file in a particular order.
// @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
$zipHelper->addUncompressedFileToArchive($this->rootFolder, self::MIMETYPE_FILE_NAME);
$zipHelper->addFolderToArchive($this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
$zipHelper->closeArchiveAndCopyToStream($streamPointer);
// once the zip is copied, remove it
$this->deleteFile($zipHelper->getZipFilePath());
}
}
+356
View File
@@ -0,0 +1,356 @@
<?php
namespace Box\Spout\Writer\ODS\Helper;
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
use Box\Spout\Writer\Style\BorderPart;
/**
* Class StyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\ODS\Helper
*/
class StyleHelper extends AbstractStyleHelper
{
/** @var string[] [FONT_NAME] => [] Map whose keys contain all the fonts used */
protected $usedFontsSet = [];
/**
* Registers the given style as a used style.
* Duplicate styles won't be registered more than once.
*
* @param \Box\Spout\Writer\Style\Style $style The style to be registered
* @return \Box\Spout\Writer\Style\Style The registered style, updated with an internal ID.
*/
public function registerStyle($style)
{
$this->usedFontsSet[$style->getFontName()] = true;
return parent::registerStyle($style);
}
/**
* @return string[] List of used fonts name
*/
protected function getUsedFonts()
{
return array_keys($this->usedFontsSet);
}
/**
* Returns the content of the "styles.xml" file, given a list of styles.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
public function getStylesXMLFileContent($numWorksheets)
{
$content = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<office:document-styles office:version="1.2" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:draw="urn:oasis:names:tc:opendocument:xmlns:drawing:1.0" xmlns:fo="urn:oasis:names:tc:opendocument:xmlns:xsl-fo-compatible:1.0" xmlns:msoxl="http://schemas.microsoft.com/office/excel/formula" xmlns:number="urn:oasis:names:tc:opendocument:xmlns:datastyle:1.0" xmlns:office="urn:oasis:names:tc:opendocument:xmlns:office:1.0" xmlns:style="urn:oasis:names:tc:opendocument:xmlns:style:1.0" xmlns:svg="urn:oasis:names:tc:opendocument:xmlns:svg-compatible:1.0" xmlns:table="urn:oasis:names:tc:opendocument:xmlns:table:1.0" xmlns:text="urn:oasis:names:tc:opendocument:xmlns:text:1.0" xmlns:xlink="http://www.w3.org/1999/xlink">
EOD;
$content .= $this->getFontFaceSectionContent();
$content .= $this->getStylesSectionContent();
$content .= $this->getAutomaticStylesSectionContent($numWorksheets);
$content .= $this->getMasterStylesSectionContent($numWorksheets);
$content .= <<<EOD
</office:document-styles>
EOD;
return $content;
}
/**
* Returns the content of the "<office:font-face-decls>" section, inside "styles.xml" file.
*
* @return string
*/
protected function getFontFaceSectionContent()
{
$content = '<office:font-face-decls>';
foreach ($this->getUsedFonts() as $fontName) {
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
}
$content .= '</office:font-face-decls>';
return $content;
}
/**
* Returns the content of the "<office:styles>" section, inside "styles.xml" file.
*
* @return string
*/
protected function getStylesSectionContent()
{
$defaultStyle = $this->getDefaultStyle();
return <<<EOD
<office:styles>
<number:number-style style:name="N0">
<number:number number:min-integer-digits="1"/>
</number:number-style>
<style:style style:data-style-name="N0" style:family="table-cell" style:name="Default">
<style:table-cell-properties fo:background-color="transparent" style:vertical-align="automatic"/>
<style:text-properties fo:color="#{$defaultStyle->getFontColor()}"
fo:font-size="{$defaultStyle->getFontSize()}pt" style:font-size-asian="{$defaultStyle->getFontSize()}pt" style:font-size-complex="{$defaultStyle->getFontSize()}pt"
style:font-name="{$defaultStyle->getFontName()}" style:font-name-asian="{$defaultStyle->getFontName()}" style:font-name-complex="{$defaultStyle->getFontName()}"/>
</style:style>
</office:styles>
EOD;
}
/**
* Returns the content of the "<office:automatic-styles>" section, inside "styles.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
protected function getAutomaticStylesSectionContent($numWorksheets)
{
$content = '<office:automatic-styles>';
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:page-layout style:name="pm$i">
<style:page-layout-properties style:first-page-number="continue" style:print="objects charts drawings" style:table-centering="none"/>
<style:header-style/>
<style:footer-style/>
</style:page-layout>
EOD;
}
$content .= '</office:automatic-styles>';
return $content;
}
/**
* Returns the content of the "<office:master-styles>" section, inside "styles.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
protected function getMasterStylesSectionContent($numWorksheets)
{
$content = '<office:master-styles>';
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:master-page style:name="mp$i" style:page-layout-name="pm$i">
<style:header/>
<style:header-left style:display="false"/>
<style:footer/>
<style:footer-left style:display="false"/>
</style:master-page>
EOD;
}
$content .= '</office:master-styles>';
return $content;
}
/**
* Returns the contents of the "<office:font-face-decls>" section, inside "content.xml" file.
*
* @return string
*/
public function getContentXmlFontFaceSectionContent()
{
$content = '<office:font-face-decls>';
foreach ($this->getUsedFonts() as $fontName) {
$content .= '<style:font-face style:name="' . $fontName . '" svg:font-family="' . $fontName . '"/>';
}
$content .= '</office:font-face-decls>';
return $content;
}
/**
* Returns the contents of the "<office:automatic-styles>" section, inside "content.xml" file.
*
* @param int $numWorksheets Number of worksheets created
* @return string
*/
public function getContentXmlAutomaticStylesSectionContent($numWorksheets)
{
$content = '<office:automatic-styles>';
foreach ($this->getRegisteredStyles() as $style) {
$content .= $this->getStyleSectionContent($style);
}
$content .= <<<EOD
<style:style style:family="table-column" style:name="co1">
<style:table-column-properties fo:break-before="auto"/>
</style:style>
<style:style style:family="table-row" style:name="ro1">
<style:table-row-properties fo:break-before="auto" style:row-height="15pt" style:use-optimal-row-height="true"/>
</style:style>
EOD;
for ($i = 1; $i <= $numWorksheets; $i++) {
$content .= <<<EOD
<style:style style:family="table" style:master-page-name="mp$i" style:name="ta$i">
<style:table-properties style:writing-mode="lr-tb" table:display="true"/>
</style:style>
EOD;
}
$content .= '</office:automatic-styles>';
return $content;
}
/**
* Returns the contents of the "<style:style>" section, inside "<office:automatic-styles>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
protected function getStyleSectionContent($style)
{
$styleIndex = $style->getId() + 1; // 1-based
$content = '<style:style style:data-style-name="N0" style:family="table-cell" style:name="ce' . $styleIndex . '" style:parent-style-name="Default">';
$content .= $this->getTextPropertiesSectionContent($style);
$content .= $this->getTableCellPropertiesSectionContent($style);
$content .= '</style:style>';
return $content;
}
/**
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getTextPropertiesSectionContent($style)
{
$content = '';
if ($style->shouldApplyFont()) {
$content .= $this->getFontSectionContent($style);
}
return $content;
}
/**
* Returns the contents of the "<style:text-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getFontSectionContent($style)
{
$defaultStyle = $this->getDefaultStyle();
$content = '<style:text-properties';
$fontColor = $style->getFontColor();
if ($fontColor !== $defaultStyle->getFontColor()) {
$content .= ' fo:color="#' . $fontColor . '"';
}
$fontName = $style->getFontName();
if ($fontName !== $defaultStyle->getFontName()) {
$content .= ' style:font-name="' . $fontName . '" style:font-name-asian="' . $fontName . '" style:font-name-complex="' . $fontName . '"';
}
$fontSize = $style->getFontSize();
if ($fontSize !== $defaultStyle->getFontSize()) {
$content .= ' fo:font-size="' . $fontSize . 'pt" style:font-size-asian="' . $fontSize . 'pt" style:font-size-complex="' . $fontSize . 'pt"';
}
if ($style->isFontBold()) {
$content .= ' fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"';
}
if ($style->isFontItalic()) {
$content .= ' fo:font-style="italic" style:font-style-asian="italic" style:font-style-complex="italic"';
}
if ($style->isFontUnderline()) {
$content .= ' style:text-underline-style="solid" style:text-underline-type="single"';
}
if ($style->isFontStrikethrough()) {
$content .= ' style:text-line-through-style="solid"';
}
$content .= '/>';
return $content;
}
/**
* Returns the contents of the "<style:table-cell-properties>" section, inside "<style:style>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getTableCellPropertiesSectionContent($style)
{
$content = '';
if ($style->shouldWrapText()) {
$content .= $this->getWrapTextXMLContent();
}
if ($style->shouldApplyBorder()) {
$content .= $this->getBorderXMLContent($style);
}
if ($style->shouldApplyBackgroundColor()) {
$content .= $this->getBackgroundColorXMLContent($style);
}
return $content;
}
/**
* Returns the contents of the wrap text definition for the "<style:table-cell-properties>" section
*
* @return string
*/
private function getWrapTextXMLContent()
{
return '<style:table-cell-properties fo:wrap-option="wrap" style:vertical-align="automatic"/>';
}
/**
* Returns the contents of the borders definition for the "<style:table-cell-properties>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getBorderXMLContent($style)
{
$borderProperty = '<style:table-cell-properties %s />';
$borders = array_map(function (BorderPart $borderPart) {
return BorderHelper::serializeBorderPart($borderPart);
}, $style->getBorder()->getParts());
return sprintf($borderProperty, implode(' ', $borders));
}
/**
* Returns the contents of the background color definition for the "<style:table-cell-properties>" section
*
* @param \Box\Spout\Writer\Style\Style $style
* @return string
*/
private function getBackgroundColorXMLContent($style)
{
return sprintf(
'<style:table-cell-properties fo:background-color="#%s"/>',
$style->getBackgroundColor()
);
}
}
+119
View File
@@ -0,0 +1,119 @@
<?php
namespace Box\Spout\Writer\ODS\Internal;
use Box\Spout\Writer\Common\Internal\AbstractWorkbook;
use Box\Spout\Writer\ODS\Helper\FileSystemHelper;
use Box\Spout\Writer\ODS\Helper\StyleHelper;
use Box\Spout\Writer\Common\Sheet;
/**
* Class Workbook
* Represents a workbook within a ODS file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\ODS\Internal
*/
class Workbook extends AbstractWorkbook
{
/**
* Maximum number of rows a ODS sheet can contain
* @see https://ask.libreoffice.org/en/question/8631/upper-limit-to-number-of-rows-in-calc/
*/
protected static $maxRowsPerWorksheet = 1048576;
/** @var \Box\Spout\Writer\ODS\Helper\FileSystemHelper Helper to perform file system operations */
protected $fileSystemHelper;
/** @var \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles */
protected $styleHelper;
/**
* @param string $tempFolder
* @param bool $shouldCreateNewSheetsAutomatically
* @param \Box\Spout\Writer\Style\Style $defaultRowStyle
* @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders
*/
public function __construct($tempFolder, $shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
parent::__construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle);
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
$this->fileSystemHelper->createBaseFilesAndFolders();
$this->styleHelper = new StyleHelper($defaultRowStyle);
}
/**
* @return \Box\Spout\Writer\ODS\Helper\StyleHelper Helper to apply styles to ODS files
*/
protected function getStyleHelper()
{
return $this->styleHelper;
}
/**
* @return int Maximum number of rows/columns a sheet can contain
*/
protected function getMaxRowsPerWorksheet()
{
return self::$maxRowsPerWorksheet;
}
/**
* Creates a new sheet in the workbook. The current sheet remains unchanged.
*
* @return Worksheet The created sheet
* @throws \Box\Spout\Common\Exception\IOException If unable to open the sheet for writing
*/
public function addNewSheet()
{
$newSheetIndex = count($this->worksheets);
$sheet = new Sheet($newSheetIndex, $this->internalId);
$sheetsContentTempFolder = $this->fileSystemHelper->getSheetsContentTempFolder();
$worksheet = new Worksheet($sheet, $sheetsContentTempFolder);
$this->worksheets[] = $worksheet;
return $worksheet;
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the ODS file.
* All the temporary files are then deleted.
*
* @param resource $finalFilePointer Pointer to the ODS that will be created
* @return void
*/
public function close($finalFilePointer)
{
/** @var Worksheet[] $worksheets */
$worksheets = $this->worksheets;
$numWorksheets = count($worksheets);
foreach ($worksheets as $worksheet) {
$worksheet->close();
}
// Finish creating all the necessary files before zipping everything together
$this->fileSystemHelper
->createContentFile($worksheets, $this->styleHelper)
->deleteWorksheetTempFolder()
->createStylesFile($this->styleHelper, $numWorksheets)
->zipRootFolderAndCopyToStream($finalFilePointer);
$this->cleanupTempFolder();
}
/**
* Deletes the root folder created in the temp folder and all its contents.
*
* @return void
*/
protected function cleanupTempFolder()
{
$xlsxRootFolder = $this->fileSystemHelper->getRootFolder();
$this->fileSystemHelper->deleteFolderRecursively($xlsxRootFolder);
}
}
+233
View File
@@ -0,0 +1,233 @@
<?php
namespace Box\Spout\Writer\ODS\Internal;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Common\Helper\CellHelper;
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
/**
* Class Worksheet
* Represents a worksheet within a ODS file. The difference with the Sheet object is
* that this class provides an interface to write data
*
* @package Box\Spout\Writer\ODS\Internal
*/
class Worksheet implements WorksheetInterface
{
/** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
protected $externalSheet;
/** @var string Path to the XML file that will contain the sheet data */
protected $worksheetFilePath;
/** @var \Box\Spout\Common\Escaper\ODS Strings escaper */
protected $stringsEscaper;
/** @var \Box\Spout\Common\Helper\StringHelper To help with string manipulation */
protected $stringHelper;
/** @var Resource Pointer to the temporary sheet data file (e.g. worksheets-temp/sheet1.xml) */
protected $sheetFilePointer;
/** @var int Maximum number of columns among all the written rows */
protected $maxNumColumns = 1;
/** @var int Index of the last written row */
protected $lastWrittenRowIndex = 0;
/**
* @param \Box\Spout\Writer\Common\Sheet $externalSheet The associated "external" sheet
* @param string $worksheetFilesFolder Temporary folder where the files to create the ODS will be stored
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
public function __construct($externalSheet, $worksheetFilesFolder)
{
$this->externalSheet = $externalSheet;
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\ODS::getInstance();
$this->worksheetFilePath = $worksheetFilesFolder . '/sheet' . $externalSheet->getIndex() . '.xml';
$this->stringHelper = new StringHelper();
$this->startSheet();
}
/**
* Prepares the worksheet to accept data
* The XML file does not contain the "<table:table>" node as it contains the sheet's name
* which may change during the execution of the program. It will be added at the end.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function startSheet()
{
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
$this->throwIfSheetFilePointerIsNotAvailable();
}
/**
* Checks if the book has been created. Throws an exception if not created yet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing
*/
protected function throwIfSheetFilePointerIsNotAvailable()
{
if (!$this->sheetFilePointer) {
throw new IOException('Unable to open sheet for writing.');
}
}
/**
* @return string Path to the temporary sheet content XML file
*/
public function getWorksheetFilePath()
{
return $this->worksheetFilePath;
}
/**
* Returns the table XML root node as string.
*
* @return string <table> node as string
*/
public function getTableElementStartAsString()
{
$escapedSheetName = $this->stringsEscaper->escape($this->externalSheet->getName());
$tableStyleName = 'ta' . ($this->externalSheet->getIndex() + 1);
$tableElement = '<table:table table:style-name="' . $tableStyleName . '" table:name="' . $escapedSheetName . '">';
$tableElement .= '<table:table-column table:default-cell-style-name="ce1" table:style-name="co1" table:number-columns-repeated="' . $this->maxNumColumns . '"/>';
return $tableElement;
}
/**
* @return \Box\Spout\Writer\Common\Sheet The "external" sheet
*/
public function getExternalSheet()
{
return $this->externalSheet;
}
/**
* @return int The index of the last written row
*/
public function getLastWrittenRowIndex()
{
return $this->lastWrittenRowIndex;
}
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written. Cannot be empty.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row. NULL means use default style.
* @return void
* @throws \Box\Spout\Common\Exception\IOException If the data cannot be written
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
*/
public function addRow($dataRow, $style)
{
// $dataRow can be an associative array. We need to transform
// it into a regular array, as we'll use the numeric indexes.
$dataRowWithNumericIndexes = array_values($dataRow);
$styleIndex = ($style->getId() + 1); // 1-based
$cellsCount = count($dataRow);
$this->maxNumColumns = max($this->maxNumColumns, $cellsCount);
$data = '<table:table-row table:style-name="ro1">';
$currentCellIndex = 0;
$nextCellIndex = 1;
for ($i = 0; $i < $cellsCount; $i++) {
$currentCellValue = $dataRowWithNumericIndexes[$currentCellIndex];
// Using isset here because it is way faster than array_key_exists...
if (!isset($dataRowWithNumericIndexes[$nextCellIndex]) ||
$currentCellValue !== $dataRowWithNumericIndexes[$nextCellIndex]) {
$numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
$data .= $this->getCellXML($currentCellValue, $styleIndex, $numTimesValueRepeated);
$currentCellIndex = $nextCellIndex;
}
$nextCellIndex++;
}
$data .= '</table:table-row>';
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $data);
if ($wasWriteSuccessful === false) {
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
}
// only update the count if the write worked
$this->lastWrittenRowIndex++;
}
/**
* Returns the cell XML content, given its value.
*
* @param mixed $cellValue The value to be written
* @param int $styleIndex Index of the used style
* @param int $numTimesValueRepeated Number of times the value is consecutively repeated
* @return string The cell XML content
* @throws \Box\Spout\Common\Exception\InvalidArgumentException If a cell value's type is not supported
*/
protected function getCellXML($cellValue, $styleIndex, $numTimesValueRepeated)
{
$data = '<table:table-cell table:style-name="ce' . $styleIndex . '"';
if ($numTimesValueRepeated !== 1) {
$data .= ' table:number-columns-repeated="' . $numTimesValueRepeated . '"';
}
if (CellHelper::isNonEmptyString($cellValue)) {
$data .= ' office:value-type="string" calcext:value-type="string">';
$cellValueLines = explode("\n", $cellValue);
foreach ($cellValueLines as $cellValueLine) {
$data .= '<text:p>' . $this->stringsEscaper->escape($cellValueLine) . '</text:p>';
}
$data .= '</table:table-cell>';
} else if (CellHelper::isBoolean($cellValue)) {
$data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="' . $cellValue . '">';
$data .= '<text:p>' . $cellValue . '</text:p>';
$data .= '</table:table-cell>';
} else if (CellHelper::isNumeric($cellValue)) {
$data .= ' office:value-type="float" calcext:value-type="float" office:value="' . $cellValue . '">';
$data .= '<text:p>' . $cellValue . '</text:p>';
$data .= '</table:table-cell>';
} else if (empty($cellValue)) {
$data .= '/>';
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
return $data;
}
/**
* Closes the worksheet
*
* @return void
*/
public function close()
{
if (!is_resource($this->sheetFilePointer)) {
return;
}
fclose($this->sheetFilePointer);
}
}
+93
View File
@@ -0,0 +1,93 @@
<?php
namespace Box\Spout\Writer\ODS;
use Box\Spout\Writer\AbstractMultiSheetsWriter;
use Box\Spout\Writer\Common;
use Box\Spout\Writer\ODS\Internal\Workbook;
/**
* Class Writer
* This class provides base support to write data to ODS files
*
* @package Box\Spout\Writer\ODS
*/
class Writer extends AbstractMultiSheetsWriter
{
/** @var string Content-Type value for the header */
protected static $headerContentType = 'application/vnd.oasis.opendocument.spreadsheet';
/** @var string Temporary folder where the files to create the ODS will be stored */
protected $tempFolder;
/** @var Internal\Workbook The workbook for the ODS file */
protected $book;
/**
* Sets a custom temporary folder for creating intermediate files/folders.
* This must be set before opening the writer.
*
* @api
* @param string $tempFolder Temporary folder where the files to create the ODS will be stored
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setTempFolder($tempFolder)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->tempFolder = $tempFolder;
return $this;
}
/**
* Configures the write and sets the current sheet pointer to a new sheet.
*
* @return void
* @throws \Box\Spout\Common\Exception\IOException If unable to open the file for writing
*/
protected function openWriter()
{
$tempFolder = ($this->tempFolder) ? : sys_get_temp_dir();
$this->book = new Workbook($tempFolder, $this->shouldCreateNewSheetsAutomatically, $this->defaultRowStyle);
$this->book->addNewSheetAndMakeItCurrent();
}
/**
* @return Internal\Workbook The workbook representing the file to be written
*/
protected function getWorkbook()
{
return $this->book;
}
/**
* Adds data to the currently opened writer.
* If shouldCreateNewSheetsAutomatically option is set to true, it will handle pagination
* with the creation of new worksheets if one worksheet has reached its maximum capicity.
*
* @param array $dataRow Array containing data to be written.
* Example $dataRow = ['data1', 1234, null, '', 'data5'];
* @param \Box\Spout\Writer\Style\Style $style Style to be applied to the row.
* @return void
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the book is not created yet
* @throws \Box\Spout\Common\Exception\IOException If unable to write data
*/
protected function addRowToWriter(array $dataRow, $style)
{
$this->throwIfBookIsNotAvailable();
$this->book->addRowToCurrentWorksheet($dataRow, $style);
}
/**
* Closes the writer, preventing any additional writing.
*
* @return void
*/
protected function closeWriter()
{
if ($this->book) {
$this->book->close($this->filePointer);
}
}
}
+85
View File
@@ -0,0 +1,85 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class Border
*/
class Border
{
const LEFT = 'left';
const RIGHT = 'right';
const TOP = 'top';
const BOTTOM = 'bottom';
const STYLE_NONE = 'none';
const STYLE_SOLID = 'solid';
const STYLE_DASHED = 'dashed';
const STYLE_DOTTED = 'dotted';
const STYLE_DOUBLE = 'double';
const WIDTH_THIN = 'thin';
const WIDTH_MEDIUM = 'medium';
const WIDTH_THICK = 'thick';
/**
* @var array A list of BorderPart objects for this border.
*/
protected $parts = [];
/**
* @param array|void $borderParts
*/
public function __construct(array $borderParts = [])
{
$this->setParts($borderParts);
}
/**
* @param $name The name of the border part
* @return null|BorderPart
*/
public function getPart($name)
{
return $this->hasPart($name) ? $this->parts[$name] : null;
}
/**
* @param $name The name of the border part
* @return bool
*/
public function hasPart($name)
{
return isset($this->parts[$name]);
}
/**
* @return array
*/
public function getParts()
{
return $this->parts;
}
/**
* Set BorderParts
* @param array $parts
*/
public function setParts($parts)
{
unset($this->parts);
foreach ($parts as $part) {
$this->addPart($part);
}
}
/**
* @param BorderPart $borderPart
* @return self
*/
public function addPart(BorderPart $borderPart)
{
$this->parts[$borderPart->getName()] = $borderPart;
return $this;
}
}
+75
View File
@@ -0,0 +1,75 @@
<?php
namespace Box\Spout\Writer\Style;
/**
* Class BorderBuilder
*/
class BorderBuilder
{
/**
* @var Border
*/
protected $border;
public function __construct()
{
$this->border = new Border();
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style));
return $this;
}
/**
* @param string|void $color Border A RGB color code
* @param string|void $width Border width @see BorderPart::allowedWidths
* @param string|void $style Border style @see BorderPart::allowedStyles
* @return BorderBuilder
*/
public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style));
return $this;
}
/**
* @return Border
*/
public function build()
{
return $this->border;
}
}
+184
View File
@@ -0,0 +1,184 @@
<?php
namespace Box\Spout\Writer\Style;
use Box\Spout\Writer\Exception\Border\InvalidNameException;
use Box\Spout\Writer\Exception\Border\InvalidStyleException;
use Box\Spout\Writer\Exception\Border\InvalidWidthException;
/**
* Class BorderPart
*/
class BorderPart
{
/**
* @var string The style of this border part.
*/
protected $style;
/**
* @var string The name of this border part.
*/
protected $name;
/**
* @var string The color of this border part.
*/
protected $color;
/**
* @var string The width of this border part.
*/
protected $width;
/**
* @var array Allowed style constants for parts.
*/
protected static $allowedStyles = [
'none',
'solid',
'dashed',
'dotted',
'double'
];
/**
* @var array Allowed names constants for border parts.
*/
protected static $allowedNames = [
'left',
'right',
'top',
'bottom',
];
/**
* @var array Allowed width constants for border parts.
*/
protected static $allowedWidths = [
'thin',
'medium',
'thick',
];
/**
* @param string $name @see BorderPart::$allowedNames
* @param string $color A RGB color code
* @param string $width @see BorderPart::$allowedWidths
* @param string $style @see BorderPart::$allowedStyles
* @throws InvalidNameException
* @throws InvalidStyleException
* @throws InvalidWidthException
*/
public function __construct($name, $color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
{
$this->setName($name);
$this->setColor($color);
$this->setWidth($width);
$this->setStyle($style);
}
/**
* @return string
*/
public function getName()
{
return $this->name;
}
/**
* @param string $name The name of the border part @see BorderPart::$allowedNames
* @throws InvalidNameException
* @return void
*/
public function setName($name)
{
if (!in_array($name, self::$allowedNames)) {
throw new InvalidNameException($name);
}
$this->name = $name;
}
/**
* @return string
*/
public function getStyle()
{
return $this->style;
}
/**
* @param string $style The style of the border part @see BorderPart::$allowedStyles
* @throws InvalidStyleException
* @return void
*/
public function setStyle($style)
{
if (!in_array($style, self::$allowedStyles)) {
throw new InvalidStyleException($style);
}
$this->style = $style;
}
/**
* @return string
*/
public function getColor()
{
return $this->color;
}
/**
* @param string $color The color of the border part @see Color::rgb()
* @return void
*/
public function setColor($color)
{
$this->color = $color;
}
/**
* @return string
*/
public function getWidth()
{
return $this->width;
}
/**
* @param string $width The width of the border part @see BorderPart::$allowedWidths
* @throws InvalidWidthException
* @return void
*/
public function setWidth($width)
{
if (!in_array($width, self::$allowedWidths)) {
throw new InvalidWidthException($width);
}
$this->width = $width;
}
/**
* @return array
*/
public static function getAllowedStyles()
{
return self::$allowedStyles;
}
/**
* @return array
*/
public static function getAllowedNames()
{
return self::$allowedNames;
}
/**
* @return array
*/
public static function getAllowedWidths()
{
return self::$allowedWidths;
}
}
+28 -17
View File
@@ -13,27 +13,27 @@ use Box\Spout\Writer\Exception\InvalidColorException;
class Color
{
/** Standard colors - based on Office Online */
const BLACK = 'FF000000';
const WHITE = 'FFFFFFFF';
const RED = 'FFFF0000';
const DARK_RED = 'FFC00000';
const ORANGE = 'FFFFC000';
const YELLOW = 'FFFFFF00';
const LIGHT_GREEN = 'FF92D040';
const GREEN = 'FF00B050';
const LIGHT_BLUE = 'FF00B0E0';
const BLUE = 'FF0070C0';
const DARK_BLUE = 'FF002060';
const PURPLE = 'FF7030A0';
const BLACK = '000000';
const WHITE = 'FFFFFF';
const RED = 'FF0000';
const DARK_RED = 'C00000';
const ORANGE = 'FFC000';
const YELLOW = 'FFFF00';
const LIGHT_GREEN = '92D040';
const GREEN = '00B050';
const LIGHT_BLUE = '00B0E0';
const BLUE = '0070C0';
const DARK_BLUE = '002060';
const PURPLE = '7030A0';
/**
* Returns an ARGB color from R, G and B values
* Alpha is assumed to always be 1
* Returns an RGB color from R, G and B values
*
* @api
* @param int $red Red component, 0 - 255
* @param int $green Green component, 0 - 255
* @param int $blue Blue component, 0 - 255
* @return string ARGB color
* @return string RGB color
*/
public static function rgb($red, $green, $blue)
{
@@ -42,7 +42,6 @@ class Color
self::throwIfInvalidColorComponentValue($blue);
return strtoupper(
'FF' .
self::convertColorComponentToHex($red) .
self::convertColorComponentToHex($green) .
self::convertColorComponentToHex($blue)
@@ -71,6 +70,18 @@ class Color
*/
protected static function convertColorComponentToHex($colorComponent)
{
return str_pad(dechex($colorComponent), 2, '0', 0);
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;
}
}
+149 -31
View File
@@ -61,6 +61,23 @@ class Style
/** @var bool Whether the wrap text property was set */
protected $hasSetWrapText = false;
/**
* @var Border
*/
protected $border = null;
/**
* @var bool Whether border properties should be applied
*/
protected $shouldApplyBorder = false;
/** @var string Background color */
protected $backgroundColor = null;
/** @var bool */
protected $hasSetBackgroundColor = false;
/**
* @return int|null
*/
@@ -80,7 +97,34 @@ class Style
}
/**
* @return boolean
* @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()
{
@@ -99,7 +143,7 @@ class Style
}
/**
* @return boolean
* @return bool
*/
public function isFontItalic()
{
@@ -118,7 +162,7 @@ class Style
}
/**
* @return boolean
* @return bool
*/
public function isFontUnderline()
{
@@ -137,7 +181,7 @@ class Style
}
/**
* @return boolean
* @return bool
*/
public function isFontStrikethrough()
{
@@ -218,7 +262,7 @@ class Style
}
/**
* @return boolean
* @return bool
*/
public function shouldWrapText()
{
@@ -226,15 +270,24 @@ class Style
}
/**
* @param bool|void $shouldWrap Should the text be wrapped
* @return Style
*/
public function setShouldWrapText()
public function setShouldWrapText($shouldWrap = true)
{
$this->shouldWrapText = 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
*/
@@ -243,6 +296,35 @@ class Style
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
@@ -278,31 +360,67 @@ class Style
{
$mergedStyle = clone $this;
if (!$this->hasSetFontBold && $baseStyle->isFontBold()) {
$mergedStyle->setFontBold();
}
if (!$this->hasSetFontItalic && $baseStyle->isFontItalic()) {
$mergedStyle->setFontItalic();
}
if (!$this->hasSetFontUnderline && $baseStyle->isFontUnderline()) {
$mergedStyle->setFontUnderline();
}
if (!$this->hasSetFontStrikethrough && $baseStyle->isFontStrikethrough()) {
$mergedStyle->setFontStrikethrough();
}
if (!$this->hasSetFontSize && $baseStyle->getFontSize() !== self::DEFAULT_FONT_SIZE) {
$mergedStyle->setFontSize($baseStyle->getFontSize());
}
if (!$this->hasSetFontSize && $baseStyle->getFontColor() !== self::DEFAULT_FONT_COLOR) {
$mergedStyle->setFontColor($baseStyle->getFontColor());
}
if (!$this->hasSetFontName && $baseStyle->getFontName() !== self::DEFAULT_FONT_NAME) {
$mergedStyle->setFontName($baseStyle->getFontName());
}
if (!$this->hasSetWrapText && $baseStyle->shouldWrapText()) {
$mergedStyle->setShouldWrapText();
}
$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());
}
}
}
+38 -4
View File
@@ -24,6 +24,7 @@ class StyleBuilder
/**
* Makes the font bold.
*
* @api
* @return StyleBuilder
*/
public function setFontBold()
@@ -35,6 +36,7 @@ class StyleBuilder
/**
* Makes the font italic.
*
* @api
* @return StyleBuilder
*/
public function setFontItalic()
@@ -46,6 +48,7 @@ class StyleBuilder
/**
* Makes the font underlined.
*
* @api
* @return StyleBuilder
*/
public function setFontUnderline()
@@ -57,6 +60,7 @@ class StyleBuilder
/**
* Makes the font struck through.
*
* @api
* @return StyleBuilder
*/
public function setFontStrikethrough()
@@ -68,6 +72,7 @@ class StyleBuilder
/**
* Sets the font size.
*
* @api
* @param int $fontSize Font size, in pixels
* @return StyleBuilder
*/
@@ -80,6 +85,7 @@ class StyleBuilder
/**
* Sets the font color.
*
* @api
* @param string $fontColor ARGB color (@see Color)
* @return StyleBuilder
*/
@@ -92,6 +98,7 @@ class StyleBuilder
/**
* Sets the font name.
*
* @api
* @param string $fontName Name of the font to use
* @return StyleBuilder
*/
@@ -102,20 +109,47 @@ class StyleBuilder
}
/**
* Makes the text wrap in the cell if it's too long or
* on multiple lines.
* Makes the text wrap in the cell if requested
*
* @api
* @param bool $shouldWrap Should the text be wrapped
* @return StyleBuilder
*/
public function setShouldWrapText()
public function setShouldWrapText($shouldWrap = true)
{
$this->style->setShouldWrapText();
$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()
+5 -1
View File
@@ -9,7 +9,7 @@ use Box\Spout\Common\Type;
/**
* Class WriterFactory
* This factory is used to create writers, based on the type of the file to be read.
* It supports CSV and XLSX formats.
* It supports CSV, XLSX and ODS formats.
*
* @package Box\Spout\Writer
*/
@@ -18,6 +18,7 @@ class WriterFactory
/**
* This creates an instance of the appropriate writer, given the type of the file to be read
*
* @api
* @param string $writerType Type of the writer to instantiate
* @return WriterInterface
* @throws \Box\Spout\Common\Exception\UnsupportedTypeException
@@ -33,6 +34,9 @@ class WriterFactory
case Type::XLSX:
$writer = new XLSX\Writer();
break;
case Type::ODS:
$writer = new ODS\Writer();
break;
default:
throw new UnsupportedTypeException('No writers supporting the given type: ' . $writerType);
}
@@ -0,0 +1,68 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Style\Border;
use Box\Spout\Writer\Style\BorderPart;
class BorderHelper
{
public static $xlsxStyleMap = [
Border::STYLE_SOLID => [
Border::WIDTH_THIN => 'thin',
Border::WIDTH_MEDIUM => 'medium',
Border::WIDTH_THICK => 'thick'
],
Border::STYLE_DOTTED => [
Border::WIDTH_THIN => 'dotted',
Border::WIDTH_MEDIUM => 'dotted',
Border::WIDTH_THICK => 'dotted',
],
Border::STYLE_DASHED => [
Border::WIDTH_THIN => 'dashed',
Border::WIDTH_MEDIUM => 'mediumDashed',
Border::WIDTH_THICK => 'mediumDashed',
],
Border::STYLE_DOUBLE => [
Border::WIDTH_THIN => 'double',
Border::WIDTH_MEDIUM => 'double',
Border::WIDTH_THICK => 'double',
],
Border::STYLE_NONE => [
Border::WIDTH_THIN => 'none',
Border::WIDTH_MEDIUM => 'none',
Border::WIDTH_THICK => 'none',
],
];
/**
* @param BorderPart $borderPart
* @return string
*/
public static function serializeBorderPart(BorderPart $borderPart)
{
$borderStyle = self::getBorderStyle($borderPart);
$colorEl = $borderPart->getColor() ? sprintf('<color rgb="%s"/>', $borderPart->getColor()) : '';
$partEl = sprintf(
'<%s style="%s">%s</%s>',
$borderPart->getName(),
$borderStyle,
$colorEl,
$borderPart->getName()
);
return $partEl . PHP_EOL;
}
/**
* Get the style definition from the style map
*
* @param BorderPart $borderPart
* @return string
*/
protected static function getBorderStyle(BorderPart $borderPart)
{
return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()];
}
}
@@ -2,6 +2,7 @@
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Common\Helper\ZipHelper;
use Box\Spout\Writer\XLSX\Internal\Worksheet;
/**
@@ -93,7 +94,7 @@ class FileSystemHelper extends \Box\Spout\Common\Helper\FileSystemHelper
*/
protected function createRootFolder()
{
$this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('xlsx'));
$this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
return $this;
}
@@ -248,12 +249,11 @@ EOD;
<Default ContentType="application/xml" Extension="xml"/>
<Default ContentType="application/vnd.openxmlformats-package.relationships+xml" Extension="rels"/>
<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml" PartName="/xl/workbook.xml"/>
EOD;
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$contentTypesXmlFileContents .= ' <Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" PartName="/xl/worksheets/sheet' . $worksheet->getId() . '.xml"/>' . PHP_EOL;
$contentTypesXmlFileContents .= '<Override ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml" PartName="/xl/worksheets/sheet' . $worksheet->getId() . '.xml"/>';
}
$contentTypesXmlFileContents .= <<<EOD
@@ -281,16 +281,16 @@ EOD;
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
<sheets>
EOD;
$escaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$worksheetName = $worksheet->getExternalSheet()->getName();
$worksheetId = $worksheet->getId();
$workbookXmlFileContents .= ' <sheet name="' . $escaper->escape($worksheetName) . '" sheetId="' . $worksheetId . '" r:id="rIdSheet' . $worksheetId . '"/>' . PHP_EOL;
$workbookXmlFileContents .= '<sheet name="' . $escaper->escape($worksheetName) . '" sheetId="' . $worksheetId . '" r:id="rIdSheet' . $worksheetId . '"/>';
}
$workbookXmlFileContents .= <<<EOD
@@ -316,13 +316,12 @@ EOD;
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rIdStyles" Target="styles.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles"/>
<Relationship Id="rIdSharedStrings" Target="sharedStrings.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings"/>
EOD;
/** @var Worksheet $worksheet */
foreach ($worksheets as $worksheet) {
$worksheetId = $worksheet->getId();
$workbookRelsXmlFileContents .= ' <Relationship Id="rIdSheet' . $worksheetId . '" Target="worksheets/sheet' . $worksheetId . '.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"/>' . PHP_EOL;
$workbookRelsXmlFileContents .= '<Relationship Id="rIdSheet' . $worksheetId . '" Target="worksheets/sheet' . $worksheetId . '.xml" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet"/>';
}
$workbookRelsXmlFileContents .= '</Relationships>';
@@ -354,42 +353,19 @@ EOD;
*/
public function zipRootFolderAndCopyToStream($streamPointer)
{
$this->zipRootFolder();
$this->copyZipToStream($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($this->getZipFilePath());
}
/**
* Zips the root folder
*
* @return void
*/
protected function zipRootFolder()
{
$zipHelper = new ZipHelper();
$zipHelper->zipFolder($this->rootFolder, $this->getZipFilePath());
}
/**
* @return string Path of the zip file created from the root folder
*/
protected function getZipFilePath()
{
return $this->rootFolder . '.zip';
}
/**
* Streams the contents of the zip 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);
$this->deleteFile($zipHelper->getZipFilePath());
}
}
@@ -45,10 +45,11 @@ EOD;
$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 . '>' . PHP_EOL;
$header = self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER . ' ' . self::DEFAULT_STRINGS_COUNT_PART . '>';
fwrite($this->sharedStringsFilePointer, $header);
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
}
/**
@@ -73,7 +74,7 @@ EOD;
*/
public function writeString($string)
{
fwrite($this->sharedStringsFilePointer, ' <si><t xml:space="preserve">' . $this->stringsEscaper->escape($string) . '</t></si>' . PHP_EOL);
fwrite($this->sharedStringsFilePointer, '<si><t xml:space="preserve">' . $this->stringsEscaper->escape($string) . '</t></si>');
$this->numSharedStrings++;
// Shared string ID is zero-based
@@ -87,6 +88,10 @@ EOD;
*/
public function close()
{
if (!is_resource($this->sharedStringsFilePointer)) {
return;
}
fwrite($this->sharedStringsFilePointer, '</sst>');
// Replace the default strings count with the actual number of shared strings in the file header
+198 -132
View File
@@ -2,122 +2,146 @@
namespace Box\Spout\Writer\XLSX\Helper;
use Box\Spout\Writer\Common\Helper\AbstractStyleHelper;
use Box\Spout\Writer\Style\Color;
use Box\Spout\Writer\Style\Style;
/**
* Class StyleHelper
* This class provides helper functions to manage styles
*
* @package Box\Spout\Writer\XLSX\Helper
*/
class StyleHelper
class StyleHelper extends AbstractStyleHelper
{
/** @var array [SERIALIZED_STYLE] => [STYLE_ID] mapping table, keeping track of the registered styles */
protected $serializedStyleToStyleIdMappingTable = [];
/** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */
protected $styleIdToStyleMappingTable = [];
/**
* @param \Box\Spout\Writer\Style\Style $defaultStyle
* @var array
*/
public function __construct($defaultStyle)
{
// This ensures that the default style is the first one to be registered
$this->registerStyle($defaultStyle);
}
protected $registeredFills = [];
/**
* Registers the given style as a used style.
* Duplicate styles won't be registered more than once.
* @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration
*/
protected $styleIdToFillMappingTable = [];
/**
* Excel preserves two default fills with index 0 and 1
* Since Excel is the dominant vendor - we play along here
*
* @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.
* @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)
{
$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);
$registeredStyle = parent::registerStyle($style);
$this->registerFill($registeredStyle);
$this->registerBorder($registeredStyle);
return $registeredStyle;
}
/**
* Returns whether the given style has already been registered.
* Register a fill definition
*
* @param \Box\Spout\Writer\Style\Style $style
* @return bool
*/
protected function hasStyleAlreadyBeenRegistered($style)
protected function registerFill($style)
{
$serializedStyle = $style->serialize();
return array_key_exists($serializedStyle, $this->serializedStyleToStyleIdMappingTable);
}
$styleId = $style->getId();
/**
* 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];
}
// Currently - only solid backgrounds are supported
// so $backgroundColor is a scalar value (RGB Color)
$backgroundColor = $style->getBackgroundColor();
/**
* 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);
if ($backgroundColor) {
$isBackgroundColorRegistered = isset($this->registeredFills[$backgroundColor]);
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->shouldWrapText()) {
return $style;
}
foreach ($dataRow as $cell) {
if (is_string($cell) && strpos($cell, "\n") !== false) {
$style->setShouldWrapText();
break;
// 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++;
}
}
return $style;
} 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()
@@ -125,7 +149,6 @@ class StyleHelper
$content = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
EOD;
$content .= $this->getFontsSectionContent();
@@ -144,37 +167,38 @@ EOD;
/**
* Returns the content of the "<fonts>" section.
*
* @return string
*/
protected function getFontsSectionContent()
{
$content = ' <fonts count="' . count($this->styleIdToStyleMappingTable) . '">' . PHP_EOL;
$content = '<fonts count="' . count($this->styleIdToStyleMappingTable) . '">';
/** @var \Box\Spout\Writer\Style\Style $style */
foreach ($this->styleIdToStyleMappingTable as $style) {
$content .= ' <font>' . PHP_EOL;
foreach ($this->getRegisteredStyles() as $style) {
$content .= '<font>';
$content .= ' <sz val="' . $style->getFontSize() . '"/>' . PHP_EOL;
$content .= ' <color rgb="' . $style->getFontColor() . '"/>' . PHP_EOL;
$content .= ' <name val="' . $style->getFontName() . '"/>' . PHP_EOL;
$content .= '<sz val="' . $style->getFontSize() . '"/>';
$content .= '<color rgb="' . Color::toARGB($style->getFontColor()) . '"/>';
$content .= '<name val="' . $style->getFontName() . '"/>';
if ($style->isFontBold()) {
$content .= ' <b/>' . PHP_EOL;
$content .= '<b/>';
}
if ($style->isFontItalic()) {
$content .= ' <i/>' . PHP_EOL;
$content .= '<i/>';
}
if ($style->isFontUnderline()) {
$content .= ' <u/>' . PHP_EOL;
$content .= '<u/>';
}
if ($style->isFontStrikethrough()) {
$content .= ' <strike/>' . PHP_EOL;
$content .= '<strike/>';
}
$content .= ' </font>' . PHP_EOL;
$content .= '</font>';
}
$content .= ' </fonts>' . PHP_EOL;
$content .= '</fonts>';
return $content;
}
@@ -186,14 +210,28 @@ EOD;
*/
protected function getFillsSectionContent()
{
return <<<EOD
<fills count="1">
<fill>
<patternFill patternType="none"/>
</fill>
</fills>
// Excel reserves two default fills
$fillsCount = count($this->registeredFills) + 2;
$content = sprintf('<fills count="%d">', $fillsCount);
EOD;
$content .= '<fill><patternFill patternType="none"/></fill>';
$content .= '<fill><patternFill patternType="gray125"/></fill>';
// The other fills are actually registered by setting a background color
foreach ($this->registeredFills as $styleId) {
/** @var Style $style */
$style = $this->styleIdToStyleMappingTable[$styleId];
$backgroundColor = $style->getBackgroundColor();
$content .= sprintf(
'<fill><patternFill patternType="solid"><fgColor rgb="%s"/></patternFill></fill>',
$backgroundColor
);
}
$content .= '</fills>';
return $content;
}
/**
@@ -203,18 +241,39 @@ EOD;
*/
protected function getBordersSectionContent()
{
return <<<EOD
<borders count="1">
<border>
<left/>
<right/>
<top/>
<bottom/>
<diagonal/>
</border>
</borders>
EOD;
// There is one default border with index 0
$borderCount = count($this->registeredBorders) + 1;
$content = '<borders count="' . $borderCount . '">';
// Default border starting at index 0
$content .= '<border><left/><right/><top/><bottom/></border>';
foreach ($this->registeredBorders as $styleId) {
/** @var \Box\Spout\Writer\Style\Style $style */
$style = $this->styleIdToStyleMappingTable[$styleId];
$border = $style->getBorder();
$content .= '<border>';
// @link https://github.com/box/spout/issues/271
$sortOrder = ['left', 'right', 'top', 'bottom'];
foreach ($sortOrder as $partName) {
if ($border->hasPart($partName)) {
/** @var $part \Box\Spout\Writer\Style\BorderPart */
$part = $border->getPart($partName);
$content .= BorderHelper::serializeBorderPart($part);
}
}
$content .= '</border>';
}
$content .= '</borders>';
return $content;
}
/**
@@ -225,38 +284,46 @@ EOD;
protected function getCellStyleXfsSectionContent()
{
return <<<EOD
<cellStyleXfs count="1">
<xf borderId="0" fillId="0" fontId="0" numFmtId="0"/>
</cellStyleXfs>
<cellStyleXfs count="1">
<xf borderId="0" fillId="0" fontId="0" numFmtId="0"/>
</cellStyleXfs>
EOD;
}
/**
* Returns the content of the "<cellXfs>" section.
*
* @return string
*/
protected function getCellXfsSectionContent()
{
$content = ' <cellXfs count="' . count($this->styleIdToStyleMappingTable) . '">' . PHP_EOL;
$registeredStyles = $this->getRegisteredStyles();
foreach ($this->styleIdToStyleMappingTable as $styleId => $style) {
$content .= ' <xf numFmtId="0" fontId="' . $styleId . '" fillId="0" borderId="0" xfId="0"';
$content = '<cellXfs count="' . count($registeredStyles) . '">';
foreach ($registeredStyles as $style) {
$styleId = $style->getId();
$fillId = $this->styleIdToFillMappingTable[$styleId];
$borderId = $this->styleIdToBorderMappingTable[$styleId];
$content .= '<xf numFmtId="0" fontId="' . $styleId . '" fillId="' . $fillId . '" borderId="' . $borderId . '" xfId="0"';
if ($style->shouldApplyFont()) {
$content .= ' applyFont="1"';
}
$content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0);
if ($style->shouldWrapText()) {
$content .= ' applyAlignment="1">' . PHP_EOL;
$content .= ' <alignment wrapText="1"/>' . PHP_EOL;
$content .= ' </xf>' . PHP_EOL;
$content .= ' applyAlignment="1">';
$content .= '<alignment wrapText="1"/>';
$content .= '</xf>';
} else {
$content .= '/>' . PHP_EOL;
$content .= '/>';
}
}
$content .= ' </cellXfs>' . PHP_EOL;
$content .= '</cellXfs>';
return $content;
}
@@ -269,10 +336,9 @@ EOD;
protected function getCellStylesSectionContent()
{
return <<<EOD
<cellStyles count="1">
<cellStyle builtinId="0" name="Normal" xfId="0"/>
</cellStyles>
<cellStyles count="1">
<cellStyle builtinId="0" name="Normal" xfId="0"/>
</cellStyles>
EOD;
}
}
@@ -1,62 +0,0 @@
<?php
namespace Box\Spout\Writer\XLSX\Helper;
/**
* Class ZipHelper
* This class provides helper functions to create zip files
*
* @package Box\Spout\Writer\XLSX\Helper
*/
class ZipHelper
{
/**
* Zips the given folder
*
* @param string $folderPath Path of the folder to be zipped
* @param string $destinationPath Path where the zip file will be created
* @return void
*/
public function zipFolder($folderPath, $destinationPath)
{
$zip = new \ZipArchive();
if ($zip->open($destinationPath, \ZipArchive::CREATE)) {
$this->addFolderToZip($zip, $folderPath);
$zip->close();
}
}
/**
* @param \ZipArchive $zip
* @param string $folderPath Path of the folder to add to the zip
* @return void
*/
protected function addFolderToZip($zip, $folderPath)
{
$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()) {
$zip->addFile($itemRealPath, $itemLocalPath);
} else if ($itemInfo->isDir()) {
$zip->addEmptyDir($itemLocalPath);
}
}
}
/**
* 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);
}
}
+31 -145
View File
@@ -2,20 +2,20 @@
namespace Box\Spout\Writer\XLSX\Internal;
use Box\Spout\Writer\Exception\SheetNotFoundException;
use Box\Spout\Writer\Common\Internal\AbstractWorkbook;
use Box\Spout\Writer\XLSX\Helper\FileSystemHelper;
use Box\Spout\Writer\XLSX\Helper\SharedStringsHelper;
use Box\Spout\Writer\XLSX\Helper\StyleHelper;
use Box\Spout\Writer\XLSX\Sheet;
use Box\Spout\Writer\Common\Sheet;
/**
* Class Book
* Class Workbook
* Represents a workbook within a XLSX file.
* It provides the functions to work with worksheets.
*
* @package Box\Spout\Writer\XLSX\Internal
*/
class Workbook
class Workbook extends AbstractWorkbook
{
/**
* Maximum number of rows a XLSX sheet can contain
@@ -26,9 +26,6 @@ class Workbook
/** @var bool Whether inline or shared strings should be used */
protected $shouldUseInlineStrings;
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically;
/** @var \Box\Spout\Writer\XLSX\Helper\FileSystemHelper Helper to perform file system operations */
protected $fileSystemHelper;
@@ -38,12 +35,6 @@ class Workbook
/** @var \Box\Spout\Writer\XLSX\Helper\StyleHelper Helper to apply styles */
protected $styleHelper;
/** @var Worksheet[] Array containing the workbook's sheets */
protected $worksheets = [];
/** @var Worksheet The worksheet where data will be written to */
protected $currentWorksheet;
/**
* @param string $tempFolder
* @param bool $shouldUseInlineStrings
@@ -53,8 +44,9 @@ class Workbook
*/
public function __construct($tempFolder, $shouldUseInlineStrings, $shouldCreateNewSheetsAutomatically, $defaultRowStyle)
{
parent::__construct($shouldCreateNewSheetsAutomatically, $defaultRowStyle);
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
$this->fileSystemHelper = new FileSystemHelper($tempFolder);
$this->fileSystemHelper->createBaseFilesAndFolders();
@@ -66,6 +58,22 @@ class Workbook
$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.
*
@@ -75,140 +83,15 @@ class Workbook
public function addNewSheet()
{
$newSheetIndex = count($this->worksheets);
$sheet = new Sheet($newSheetIndex);
$sheet = new Sheet($newSheetIndex, $this->internalId);
$worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder();
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->shouldUseInlineStrings);
$worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $this->styleHelper, $this->shouldUseInlineStrings);
$this->worksheets[] = $worksheet;
return $worksheet;
}
/**
* 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 Worksheet 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 Worksheet[] All the workbook's sheets
*/
public function getWorksheets()
{
return $this->worksheets;
}
/**
* Returns the current sheet
*
* @return Worksheet 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\XLSX\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 Worksheet $worksheet
* @return void
*/
protected function setCurrentWorksheet($worksheet)
{
$this->currentWorksheet = $worksheet;
}
/**
* Returns the worksheet associated to the given external sheet.
*
* @param \Box\Spout\Writer\XLSX\Sheet $sheet
* @return Worksheet|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.
* 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();
// 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 = $this->styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $this->styleHelper->registerStyle($updatedStyle);
$currentWorksheet->addRow($dataRow, $registeredStyle);
} else {
// otherwise, do nothing as the data won't be read anyways
}
} else {
$updatedStyle = $this->styleHelper->applyExtraStylesIfNeeded($style, $dataRow);
$registeredStyle = $this->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() >= self::$maxRowsPerWorksheet);
}
/**
* Closes the workbook and all its associated sheets.
* All the necessary files are written to disk and zipped together to create the XLSX file.
@@ -219,7 +102,10 @@ class Workbook
*/
public function close($finalFilePointer)
{
foreach ($this->worksheets as $worksheet) {
/** @var Worksheet[] $worksheets */
$worksheets = $this->worksheets;
foreach ($worksheets as $worksheet) {
$worksheet->close();
}
@@ -227,9 +113,9 @@ class Workbook
// Finish creating all the necessary files before zipping everything together
$this->fileSystemHelper
->createContentTypesFile($this->worksheets)
->createWorkbookFile($this->worksheets)
->createWorkbookRelsFile($this->worksheets)
->createContentTypesFile($worksheets)
->createWorkbookFile($worksheets)
->createWorkbookRelsFile($worksheets)
->createStylesFile($this->styleHelper)
->zipRootFolderAndCopyToStream($finalFilePointer);
+144 -48
View File
@@ -4,7 +4,9 @@ namespace Box\Spout\Writer\XLSX\Internal;
use Box\Spout\Common\Exception\InvalidArgumentException;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Writer\XLSX\Helper\CellHelper;
use Box\Spout\Common\Helper\StringHelper;
use Box\Spout\Writer\Common\Helper\CellHelper;
use Box\Spout\Writer\Common\Internal\WorksheetInterface;
/**
* Class Worksheet
@@ -13,14 +15,22 @@ use Box\Spout\Writer\XLSX\Helper\CellHelper;
*
* @package Box\Spout\Writer\XLSX\Internal
*/
class Worksheet
class Worksheet implements WorksheetInterface
{
/**
* Maximum number of characters a cell can contain
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-16c69c74-3d6a-4aaf-ba35-e6eb276e8eaa [Excel 2007]
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-1672b34d-7043-467e-8e27-269d656771c3 [Excel 2010]
* @see https://support.office.com/en-us/article/Excel-specifications-and-limits-ca36e2dc-1f09-4620-b726-67c00b05040f [Excel 2013/2016]
*/
const MAX_CHARACTERS_PER_CELL = 32767;
const SHEET_XML_FILE_HEADER = <<<EOD
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
EOD;
/** @var \Box\Spout\Writer\XLSX\Sheet The "external" sheet */
/** @var \Box\Spout\Writer\Common\Sheet The "external" sheet */
protected $externalSheet;
/** @var string Path to the XML file that will contain the sheet data */
@@ -29,12 +39,18 @@ EOD;
/** @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;
@@ -42,19 +58,23 @@ EOD;
protected $lastWrittenRowIndex = 0;
/**
* @param \Box\Spout\Writer\XLSX\Sheet $externalSheet The associated "external" sheet
* @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, $shouldUseInlineStrings)
public function __construct($externalSheet, $worksheetFilesFolder, $sharedStringsHelper, $styleHelper, $shouldUseInlineStrings)
{
$this->externalSheet = $externalSheet;
$this->sharedStringsHelper = $sharedStringsHelper;
$this->styleHelper = $styleHelper;
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
$this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$this->stringsEscaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$this->stringHelper = new StringHelper();
$this->worksheetFilePath = $worksheetFilesFolder . '/' . strtolower($this->externalSheet->getName()) . '.xml';
$this->startSheet();
@@ -71,12 +91,25 @@ EOD;
$this->sheetFilePointer = fopen($this->worksheetFilePath, 'w');
$this->throwIfSheetFilePointerIsNotAvailable();
fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER . PHP_EOL);
fwrite($this->sheetFilePointer, ' <sheetData>' . PHP_EOL);
fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER);
fwrite($this->sheetFilePointer, '<sheetData>');
}
/**
* @return \Box\Spout\Writer\XLSX\Sheet The "external" sheet
* 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()
{
@@ -100,23 +133,10 @@ EOD;
return $this->externalSheet->getIndex() + 1;
}
/**
* 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.');
}
}
/**
* Adds data to the worksheet.
*
* @param array $dataRow Array containing data to be written.
* @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
@@ -124,45 +144,117 @@ EOD;
* @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
*/
private 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
*/
private function addNonEmptyRow($dataRow, $style)
{
$cellNumber = 0;
$rowIndex = $this->lastWrittenRowIndex + 1;
$numCells = count($dataRow);
$data = ' <row r="' . $rowIndex . '" spans="1:' . $numCells . '">' . PHP_EOL;
$rowXML = '<row r="' . $rowIndex . '" spans="1:' . $numCells . '">';
foreach($dataRow as $cellValue) {
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
$data .= ' <c r="' . $columnIndex . $rowIndex . '"';
$data .= ' s="' . $style->getId() . '"';
if (CellHelper::isNonEmptyString($cellValue)) {
if ($this->shouldUseInlineStrings) {
$data .= ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>' . PHP_EOL;
} else {
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
$data .= ' t="s"><v>' . $sharedStringId . '</v></c>' . PHP_EOL;
}
} else if (CellHelper::isNumeric($cellValue) || CellHelper::isBoolean($cellValue)) {
$data .= '><v>' . $cellValue . '</v></c>' . PHP_EOL;
} else if (empty($cellValue)) {
$data .= '/>' . PHP_EOL;
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
$rowXML .= $this->getCellXML($rowIndex, $cellNumber, $cellValue, $style->getId());
$cellNumber++;
}
$data .= ' </row>' . PHP_EOL;
$rowXML .= '</row>';
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $data);
$wasWriteSuccessful = fwrite($this->sheetFilePointer, $rowXML);
if ($wasWriteSuccessful === false) {
throw new IOException("Unable to write data in {$this->worksheetFilePath}");
}
}
// only update the count if the write worked
$this->lastWrittenRowIndex++;
/**
* 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
*/
private function getCellXML($rowIndex, $cellNumber, $cellValue, $styleId)
{
$columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber);
$cellXML = '<c r="' . $columnIndex . $rowIndex . '"';
$cellXML .= ' s="' . $styleId . '"';
if (CellHelper::isNonEmptyString($cellValue)) {
$cellXML .= $this->getCellXMLFragmentForNonEmptyString($cellValue);
} else if (CellHelper::isBoolean($cellValue)) {
$cellXML .= ' t="b"><v>' . intval($cellValue) . '</v></c>';
} else if (CellHelper::isNumeric($cellValue)) {
$cellXML .= '><v>' . $cellValue . '</v></c>';
} else if (empty($cellValue)) {
if ($this->styleHelper->shouldApplyStyleOnEmptyCell($styleId)) {
$cellXML .= '/>';
} else {
// don't write empty cells that do no need styling
// NOTE: not appending to $cellXML is the right behavior!!
$cellXML = '';
}
} else {
throw new InvalidArgumentException('Trying to add a value with an unsupported type: ' . gettype($cellValue));
}
return $cellXML;
}
/**
* Returns the XML fragment for a cell containing a non empty string
*
* @param string $cellValue The cell value
* @return string The XML fragment representing the cell
* @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
*/
private function getCellXMLFragmentForNonEmptyString($cellValue)
{
if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
}
if ($this->shouldUseInlineStrings) {
$cellXMLFragment = ' t="inlineStr"><is><t>' . $this->stringsEscaper->escape($cellValue) . '</t></is></c>';
} else {
$sharedStringId = $this->sharedStringsHelper->writeString($cellValue);
$cellXMLFragment = ' t="s"><v>' . $sharedStringId . '</v></c>';
}
return $cellXMLFragment;
}
/**
@@ -172,7 +264,11 @@ EOD;
*/
public function close()
{
fwrite($this->sheetFilePointer, ' </sheetData>' . PHP_EOL);
if (!is_resource($this->sheetFilePointer)) {
return;
}
fwrite($this->sheetFilePointer, '</sheetData>');
fwrite($this->sheetFilePointer, '</worksheet>');
fclose($this->sheetFilePointer);
}
+17 -89
View File
@@ -2,8 +2,7 @@
namespace Box\Spout\Writer\XLSX;
use Box\Spout\Writer\AbstractWriter;
use Box\Spout\Writer\Exception\WriterNotOpenedException;
use Box\Spout\Writer\AbstractMultiSheetsWriter;
use Box\Spout\Writer\Style\StyleBuilder;
use Box\Spout\Writer\XLSX\Internal\Workbook;
@@ -13,7 +12,7 @@ use Box\Spout\Writer\XLSX\Internal\Workbook;
*
* @package Box\Spout\Writer\XLSX
*/
class Writer extends AbstractWriter
class Writer extends AbstractMultiSheetsWriter
{
/** Default style font values */
const DEFAULT_FONT_SIZE = 12;
@@ -28,44 +27,40 @@ class Writer extends AbstractWriter
/** @var bool Whether inline or shared strings should be used - inline string is more memory efficient */
protected $shouldUseInlineStrings = true;
/** @var bool Whether new sheets should be automatically created when the max rows limit per sheet is reached */
protected $shouldCreateNewSheetsAutomatically = true;
/** @var Internal\Workbook The workbook for the XLSX file */
protected $book;
/** @var int */
protected $highestRowIndex = 0;
/**
* Sets a custom temporary folder for creating intermediate files/folders.
* This must be set before opening the writer.
*
* @api
* @param string $tempFolder Temporary folder where the files to create the XLSX will be stored
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setTempFolder($tempFolder)
{
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
$this->tempFolder = $tempFolder;
return $this;
}
/**
* Use inline string to be more memory efficient. If set to false, it will use shared strings.
* This must be set before opening the writer.
*
* @api
* @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
* @return Writer
* @throws \Box\Spout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
*/
public function setShouldUseInlineStrings($shouldUseInlineStrings)
{
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
return $this;
}
$this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
/**
* @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached
* @return Writer
*/
public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically)
{
$this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically;
$this->shouldUseInlineStrings = $shouldUseInlineStrings;
return $this;
}
@@ -85,78 +80,11 @@ class Writer extends AbstractWriter
}
/**
* Returns all the workbook's sheets
*
* @return Sheet[] All the workbook's sheets
* @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If the writer has not been opened yet
* @return Internal\Workbook The workbook representing the file to be written
*/
public function getSheets()
protected function getWorkbook()
{
$this->throwIfBookIsNotAvailable();
$externalSheets = [];
$worksheets = $this->book->getWorksheets();
/** @var Internal\Worksheet $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.
*
* @return 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->book->addNewSheetAndMakeItCurrent();
return $worksheet->getExternalSheet();
}
/**
* Returns the current sheet
*
* @return 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->book->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).
*
* @param 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->book->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->book) {
throw new WriterNotOpenedException('The writer must be opened before performing this action.');
}
return $this->book;
}
/**
+42
View File
@@ -0,0 +1,42 @@
<?php
namespace Box\Spout\Common\Escaper;
/**
* Class ODSTest
*
* @package Box\Spout\Common\Escaper
*/
class ODSTest extends \PHPUnit_Framework_TestCase
{
/**
* @return array
*/
public function dataProviderForTestEscape()
{
return [
['test', 'test'],
['carl\'s "pokemon"', 'carl&#039;s &quot;pokemon&quot;'],
["\n", "\n"],
["\r", "\r"],
["\t", "\t"],
["\v", "�"],
["\f", "�"],
];
}
/**
* @dataProvider dataProviderForTestEscape
*
* @param string $stringToEscape
* @param string $expectedEscapedString
* @return void
*/
public function testEscape($stringToEscape, $expectedEscapedString)
{
$escaper = \Box\Spout\Common\Escaper\ODS::getInstance();
$escapedString = $escaper->escape($stringToEscape);
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
}
}
+12 -2
View File
@@ -17,7 +17,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
return [
['test', 'test'],
['adam\'s "car"', 'adam&#039;s &quot;car&quot;'],
["\n", "\n"],
["\r", "\r"],
["\t", "\t"],
[chr(0), '_x0000_'],
[chr(4), '_x0004_'],
['_x0000_', '_x005F_x0000_'],
[chr(21), '_x0015_'],
['control '.chr(21).' character', 'control _x0015_ character'],
@@ -34,7 +38,8 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
*/
public function testEscape($stringToEscape, $expectedEscapedString)
{
$escaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$escapedString = $escaper->escape($stringToEscape);
$this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string');
@@ -48,7 +53,11 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
return [
['test', 'test'],
['adam&#039;s &quot;car&quot;', 'adam\'s "car"'],
["\n", "\n"],
["\r", "\r"],
["\t", "\t"],
['_x0000_', chr(0)],
['_x0004_', chr(4)],
['_x005F_x0000_', '_x0000_'],
['_x0015_', chr(21)],
['control _x0015_ character', 'control '.chr(21).' character'],
@@ -65,7 +74,8 @@ class XLSXTest extends \PHPUnit_Framework_TestCase
*/
public function testUnescape($stringToUnescape, $expectedUnescapedString)
{
$escaper = new \Box\Spout\Common\Escaper\XLSX();
/** @noinspection PhpUnnecessaryFullyQualifiedNameInspection */
$escaper = \Box\Spout\Common\Escaper\XLSX::getInstance();
$unescapedString = $escaper->unescape($stringToUnescape);
$this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string');
+174 -13
View File
@@ -114,32 +114,66 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSkipEmptyLinesIfShouldPreserveEmptyRowsNotSet()
{
$allRows = $this->getAllRowsForFile('csv_with_multiple_empty_lines.csv');
$expectedRows = [
// skipped row here
['csv--21', 'csv--22', 'csv--23'],
// skipped row here
['csv--41', 'csv--42', 'csv--43'],
// skipped row here
// last row empty
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
{
$allRows = $this->getAllRowsForFile(
'csv_with_multiple_empty_lines.csv',
',', '"', "\n", EncodingHelper::ENCODING_UTF8,
$shouldPreserveEmptyRows = true
);
$expectedRows = [
[''],
['csv--21', 'csv--22', 'csv--23'],
[''],
['csv--41', 'csv--42', 'csv--43'],
[''],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return array
*/
public function dataProviderForTestReadShouldSkipEmptyLines()
public function dataProviderForTestReadShouldReadEmptyFile()
{
return [
['csv_with_empty_line.csv'],
['csv_with_empty_last_line.csv'],
['csv_empty.csv'],
['csv_all_lines_empty.csv'],
];
}
/**
* @dataProvider dataProviderForTestReadShouldSkipEmptyLines
* @dataProvider dataProviderForTestReadShouldReadEmptyFile
*
* @param string $fileName
* @return void
*/
public function testReadShouldSkipEmptyLines($fileName)
public function testReadShouldReadEmptyFile($fileName)
{
$allRows = $this->getAllRowsForFile($fileName);
$expectedRows = [
['csv--11', 'csv--12', 'csv--13'],
['csv--31', 'csv--32', 'csv--33'],
];
$this->assertEquals($expectedRows, $allRows);
$this->assertEquals([], $allRows);
}
/**
@@ -181,6 +215,30 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals('This is, a comma', $allRows[0][0]);
}
/**
* @return void
*/
public function testReadCustomEOLs()
{
$allRows = $this->getAllRowsForFile('csv_with_CR_EOL.csv', ',', '"', "\r");
$expectedRows = [
['csv--11', 'csv--12', 'csv--13'],
['csv--21', 'csv--22', 'csv--23'],
['csv--31', 'csv--32', 'csv--33'],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldNotTruncateLineBreak()
{
$allRows = $this->getAllRowsForFile('csv_with_line_breaks.csv', ',');
$this->assertEquals("This is,\na comma", $allRows[0][0]);
}
/**
* @return array
*/
@@ -204,7 +262,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
*/
public function testReadShouldSkipBom($fileName, $fileEncoding)
{
$allRows = $this->getAllRowsForFile($fileName, ',', '"', $fileEncoding);
$allRows = $this->getAllRowsForFile($fileName, ',', '"', "\n", $fileEncoding);
$expectedRows = [
['csv--11', 'csv--12', 'csv--13'],
@@ -243,6 +301,7 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$allRows = [];
$resourcePath = $this->getResourcePath($fileName);
/** @var \Box\Spout\Common\Helper\GlobalFunctionsHelper|\PHPUnit_Framework_MockObject_MockObject $helperStub */
$helperStub = $this->getMockBuilder('\Box\Spout\Common\Helper\GlobalFunctionsHelper')
->setMethods(['function_exists'])
->getMock();
@@ -320,18 +379,71 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedRows, $allRows);
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldInludeRowsWithZerosOnly()
{
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.csv');
$expectedRows = [
['A', 'B', 'C'],
['1', '2', '3'],
['0', '0', '0']
];
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldCreateOutputEmptyCellPreserved()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.csv');
$expectedRows = [
['A', 'B', 'C'],
['0', '', ''],
['1', '1', '']
];
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
}
/**
* https://github.com/box/spout/issues/195
* @return void
*/
public function testReaderShouldNotTrimCellValues()
{
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.csv');
$expectedRows = [
['A'],
[' A '],
["\n\tA\n\t"],
];
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
}
/**
* @param string $fileName
* @param string|void $fieldDelimiter
* @param string|void $fieldEnclosure
* @param string|void $endOfLineCharacter
* @param string|void $encoding
* @param bool|void $shouldPreserveEmptyRows
* @return array All the read rows the given file
*/
private function getAllRowsForFile(
$fileName,
$fieldDelimiter = ',',
$fieldEnclosure = '"',
$encoding = EncodingHelper::ENCODING_UTF8)
$endOfLineCharacter = "\n",
$encoding = EncodingHelper::ENCODING_UTF8,
$shouldPreserveEmptyRows = false)
{
$allRows = [];
$resourcePath = $this->getResourcePath($fileName);
@@ -341,7 +453,9 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
$reader
->setFieldDelimiter($fieldDelimiter)
->setFieldEnclosure($fieldEnclosure)
->setEndOfLineCharacter($endOfLineCharacter)
->setEncoding($encoding)
->setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
@@ -354,4 +468,51 @@ class ReaderTest extends \PHPUnit_Framework_TestCase
return $allRows;
}
/**
* @return void
*/
public function testReadCustomStreamWrapper()
{
$allRows = [];
$resourcePath = 'spout://csv_standard';
// register stream wrapper
stream_wrapper_register('spout', SpoutTestStream::CLASS_NAME);
/** @var \Box\Spout\Reader\CSV\Reader $reader */
$reader = ReaderFactory::create(Type::CSV);
$reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$allRows[] = $row;
}
}
$reader->close();
$expectedRows = [
['csv--11', 'csv--12', 'csv--13'],
['csv--21', 'csv--22', 'csv--23'],
['csv--31', 'csv--32', 'csv--33'],
];
$this->assertEquals($expectedRows, $allRows);
// cleanup
stream_wrapper_unregister('spout');
}
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithUnsupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\CSV\Reader $reader */
$reader = ReaderFactory::create(Type::CSV);
$reader->open('unsupported://foobar');
}
}
+120
View File
@@ -0,0 +1,120 @@
<?php
namespace Box\Spout\Reader\CSV;
/**
* Class SpoutTestStream
* Custom stream that reads CSV files located in the tests/resources/csv folder.
* For example: spout://foobar will point to tests/resources/csv/foobar.csv
*
* @package Box\Spout\Reader\CSV
*/
class SpoutTestStream
{
const CLASS_NAME = __CLASS__;
const PATH_TO_CSV_RESOURCES = 'tests/resources/csv/';
const CSV_EXTENSION = '.csv';
/** @var int $position */
private $position;
/** @var resource $fileHandle */
private $fileHandle;
/**
* @param string $path
* @param int $flag
* @return array
*/
public function url_stat($path, $flag)
{
$filePath = $this->getFilePathFromStreamPath($path);
return stat($filePath);
}
/**
* @param string $streamPath
* @return string
*/
private function getFilePathFromStreamPath($streamPath)
{
$fileName = parse_url($streamPath, PHP_URL_HOST);
return self::PATH_TO_CSV_RESOURCES . $fileName . self::CSV_EXTENSION;
}
/**
* @param string $path
* @param string $mode
* @param int $options
* @param string $opened_path
* @return bool
*/
public function stream_open($path, $mode, $options, &$opened_path)
{
$this->position = 0;
// the path is like "spout://csv_name" so the actual file name correspond the name of the host.
$filePath = $this->getFilePathFromStreamPath($path);
$this->fileHandle = fopen($filePath, $mode);
return true;
}
/**
* @param int $numBytes
* @return string
*/
public function stream_read($numBytes)
{
$this->position += $numBytes;
return fread($this->fileHandle, $numBytes);
}
/**
* @return int
*/
public function stream_tell()
{
return $this->position;
}
/**
* @param int $offset
* @param int|void $whence
* @return bool
*/
public function stream_seek($offset, $whence = SEEK_SET)
{
$result = fseek($this->fileHandle, $offset, $whence);
if ($result === -1) {
return false;
}
if ($whence === SEEK_SET) {
$this->position = $offset;
} else if ($whence === SEEK_CUR) {
$this->position += $offset;
} else {
// not implemented
}
return true;
}
/**
* @return bool
*/
public function stream_close()
{
return fclose($this->fileHandle);
}
/**
* @return bool
*/
public function stream_eof()
{
return feof($this->fileHandle);
}
}
+549
View File
@@ -0,0 +1,549 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Exception\IOException;
use Box\Spout\Common\Type;
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\TestUsingResource;
/**
* Class ReaderTest
*
* @package Box\Spout\Reader\ODS
*/
class ReaderTest extends \PHPUnit_Framework_TestCase
{
use TestUsingResource;
/**
* @return array
*/
public function dataProviderForTestReadShouldThrowException()
{
return [
['/path/to/fake/file.ods'],
['file_corrupted.ods'],
];
}
/**
* @dataProvider dataProviderForTestReadShouldThrowException
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @param string $filePath
* @return void
*/
public function testReadShouldThrowException($filePath)
{
// using @ to prevent warnings/errors from being displayed
@$this->getAllRowsForFile($filePath);
}
/**
* @return array
*/
public function dataProviderForTestReadForAllWorksheets()
{
return [
['one_sheet_with_strings.ods', 2, 3],
['two_sheets_with_strings.ods', 4, 3],
];
}
/**
* @dataProvider dataProviderForTestReadForAllWorksheets
*
* @param string $resourceName
* @param int $expectedNumOfRows
* @param int $expectedNumOfCellsPerRow
* @return void
*/
public function testReadForAllWorksheets($resourceName, $expectedNumOfRows, $expectedNumOfCellsPerRow)
{
$allRows = $this->getAllRowsForFile($resourceName);
$this->assertEquals($expectedNumOfRows, count($allRows), "There should be $expectedNumOfRows rows");
foreach ($allRows as $row) {
$this->assertEquals($expectedNumOfCellsPerRow, count($row), "There should be $expectedNumOfCellsPerRow cells for every row");
}
}
/**
* @return void
*/
public function testReadShouldSupportRowWithOnlyOneCell()
{
$allRows = $this->getAllRowsForFile('sheet_with_only_one_cell.ods');
$this->assertEquals([['foo']], $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportNumberRowsRepeated()
{
$allRows = $this->getAllRowsForFile('sheet_with_number_rows_repeated.ods');
$expectedRows = [
['foo', 10.43],
['foo', 10.43],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportNumberColumnsRepeated()
{
$allRows = $this->getAllRowsForFile('sheet_with_number_columns_repeated.ods');
$expectedRows = [
[
'foo', 'foo', 'foo',
'', '',
true, true,
10.43, 10.43, 10.43, 10.43,
],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return array
*/
public function dataProviderForTestReadWithFilesGeneratedByExternalSoftwares()
{
return [
['file_generated_by_libre_office.ods', true],
['file_generated_by_excel_2010_windows.ods', false],
['file_generated_by_excel_office_online.ods', false],
];
}
/**
* @dataProvider dataProviderForTestReadWithFilesGeneratedByExternalSoftwares
* The files contain styles, different value types, gaps between cells,
* repeated values, empty row, different number of cells per row.
*
* @param bool $skipLastEmptyValues
* @param string $fileName
* @return void
*/
public function testReadWithFilesGeneratedByExternalSoftwares($fileName, $skipLastEmptyValues)
{
$allRows = $this->getAllRowsForFile($fileName);
$expectedRows = [
['header1','header2','header3','header4'],
['val11','val12','val13','val14'],
['val21','','val23','val23'],
['', 10.43, 29.11],
];
// In the description of the last cell, Excel specifies that the empty value needs to be repeated
// a lot of times (16384 - number of cells used in the row). To avoid creating 16384 cells all the time,
// this cell is skipped alltogether.
if ($skipLastEmptyValues) {
$expectedRows[3][] = '';
}
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportAllCellTypes()
{
$utcTz = new \DateTimeZone('UTC');
$honoluluTz = new \DateTimeZone('Pacific/Honolulu'); // UTC-10
$allRows = $this->getAllRowsForFile('sheet_with_all_cell_types.ods');
$expectedRows = [
[
'ods--11', 'ods--12',
true, false,
0, 10.43,
new \DateTime('1987-11-29T00:00:00', $utcTz), new \DateTime('1987-11-29T13:37:00', $utcTz),
new \DateTime('1987-11-29T13:37:00', $utcTz), new \DateTime('1987-11-29T13:37:00', $honoluluTz),
new \DateInterval('PT13H37M00S'),
0, 0.42,
'42 USD', '9.99 EUR',
'',
],
];
$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
*/
public function testReadShouldReturnEmptyStringOnUndefinedCellType()
{
$allRows = $this->getAllRowsForFile('sheet_with_undefined_value_type.ods');
$this->assertEquals([['ods--11', '', 'ods--13']], $allRows);
}
/**
* @return void
*/
public function testReadShouldReturnNullOnInvalidDateOrTime()
{
$allRows = $this->getAllRowsForFile('sheet_with_invalid_date_time.ods');
$this->assertEquals([[null, null]], $allRows);
}
/**
* @return void
*/
public function testReadShouldSupportMultilineStrings()
{
$allRows = $this->getAllRowsForFile('sheet_with_multiline_string.ods');
$expectedRows = [["string\non multiple\nlines!"]];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldSkipEmptyRowsIfShouldPreserveEmptyRowsNotSet()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods');
$this->assertEquals(3, count($allRows), 'There should be only 3 rows, because the empty rows are skipped');
$expectedRows = [
// skipped row here
['ods--21', 'ods--22', 'ods--23'],
// skipped row here
// skipped row here
['ods--51', 'ods--52', 'ods--53'],
['ods--61', 'ods--62', 'ods--63'],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldReturnEmptyLinesIfShouldPreserveEmptyRowsSet()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_rows.ods', false, true);
$this->assertEquals(6, count($allRows), 'There should be 6 rows');
$expectedRows = [
[''],
['ods--21', 'ods--22', 'ods--23'],
[''],
[''],
['ods--51', 'ods--52', 'ods--53'],
['ods--61', 'ods--62', 'ods--63'],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @return void
*/
public function testReadShouldPreserveSpacing()
{
$allRows = $this->getAllRowsForFile('sheet_with_various_spaces.ods');
$expectedRow = [
' 4 spaces before and after ',
' 1 space before and after ',
'2 spaces after ',
' 2 spaces before',
"3 spaces in the middle\nand 2 spaces in the middle",
];
$this->assertEquals([$expectedRow], $allRows);
}
/**
* @NOTE: The LIBXML_NOENT is used to ACTUALLY substitute entities (and should therefore not be used)
*
* @return void
*/
public function testReadShouldBeProtectedAgainstBillionLaughAttack()
{
$startTime = microtime(true);
$fileName = 'attack_billion_laughs.ods';
try {
// using @ to prevent warnings/errors from being displayed
@$this->getAllRowsForFile($fileName);
$this->fail('An exception should have been thrown');
} catch (IOException $exception) {
$duration = microtime(true) - $startTime;
$this->assertLessThan(10, $duration, 'Entities should not be expanded and therefore take more than 10 seconds to be parsed.');
$expectedMaxMemoryUsage = 30 * 1024 * 1024; // 30MB
$this->assertLessThan($expectedMaxMemoryUsage, memory_get_peak_usage(true), 'Entities should not be expanded and therefore consume all the memory.');
}
}
/**
* @NOTE: The LIBXML_NOENT is used to ACTUALLY substitute entities (and should therefore not be used)
*
* @return void
*/
public function testReadShouldBeProtectedAgainstQuadraticBlowupAttack()
{
$startTime = microtime(true);
$fileName = 'attack_quadratic_blowup.ods';
$allRows = $this->getAllRowsForFile($fileName);
$this->assertEquals('', $allRows[0][0], 'Entities should not have been expanded');
$duration = microtime(true) - $startTime;
$this->assertLessThan(10, $duration, 'Entities should not be expanded and therefore take more than 10 seconds to be parsed.');
$expectedMaxMemoryUsage = 30 * 1024 * 1024; // 30MB
$this->assertLessThan($expectedMaxMemoryUsage, memory_get_peak_usage(true), 'Entities should not be expanded and therefore consume all the memory.');
}
/**
* @return void
*/
public function testReadShouldBeAbleToProcessEmptySheets()
{
$allRows = $this->getAllRowsForFile('sheet_with_no_cells.ods');
$this->assertEquals([], $allRows, 'Sheet with no cells should be correctly processed.');
}
/**
* @return void
*/
public function testReadShouldSkipFormulas()
{
$allRows = $this->getAllRowsForFile('sheet_with_formulas.ods');
$expectedRows = [
['val1', 'val2', 'total1', 'total2'],
[10, 20, 30, 21],
[11, 21, 32, 41],
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @expectedException \Box\Spout\Reader\Exception\IteratorNotRewindableException
*
* @return void
*/
public function testReadShouldThrowIfTryingToRewindRowIterator()
{
$resourcePath = $this->getResourcePath('one_sheet_with_strings.ods');
$reader = ReaderFactory::create(Type::ODS);
$reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheet) {
// start looping throw the rows
foreach ($sheet->getRowIterator() as $row) {
break;
}
// this will rewind the row iterator
foreach ($sheet->getRowIterator() as $row) {
break;
}
}
}
/**
* @return void
*/
public function testReadMultipleTimesShouldRewindReader()
{
$allRows = [];
$resourcePath = $this->getResourcePath('two_sheets_with_strings.ods');
$reader = ReaderFactory::create(Type::ODS);
$reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheet) {
// do nothing
}
// this loop should only add the first row of each sheet
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$allRows[] = $row;
break;
}
}
// this loop should only add the first row of the first sheet
foreach ($reader->getSheetIterator() as $sheet) {
foreach ($sheet->getRowIterator() as $row) {
$allRows[] = $row;
break;
}
// stop reading more sheets
break;
}
$reader->close();
$expectedRows = [
['ods--sheet1--11', 'ods--sheet1--12', 'ods--sheet1--13'], // 1st row, 1st sheet
['ods--sheet2--11', 'ods--sheet2--12', 'ods--sheet2--13'], // 1st row, 2nd sheet
['ods--sheet1--11', 'ods--sheet1--12', 'ods--sheet1--13'], // 1st row, 1st sheet
];
$this->assertEquals($expectedRows, $allRows);
}
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithUnsupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\ODS\Reader $reader */
$reader = ReaderFactory::create(Type::ODS);
$reader->open('unsupported://foobar');
}
/**
* @expectedException \Box\Spout\Common\Exception\IOException
*
* @return void
*/
public function testReadWithSupportedCustomStreamWrapper()
{
/** @var \Box\Spout\Reader\ODS\Reader $reader */
$reader = ReaderFactory::create(Type::ODS);
$reader->open('php://memory');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldInludeRowsWithZerosOnly()
{
$allRows = $this->getAllRowsForFile('sheet_with_zeros_in_row.ods');
$expectedRows = [
['A', 'B', 'C'],
['1', '2', '3'],
['0', '0', '0']
];
$this->assertEquals($expectedRows, $allRows, 'There should be only 3 rows, because zeros (0) are valid values');
}
/**
* https://github.com/box/spout/issues/184
* @return void
*/
public function testReadShouldCreateOutputEmptyCellPreserved()
{
$allRows = $this->getAllRowsForFile('sheet_with_empty_cells.ods');
$expectedRows = [
['A', 'B', 'C'],
['0', '', ''],
['1', '1', '']
];
$this->assertEquals($expectedRows, $allRows, 'There should be 3 rows, with equal length');
}
/**
* https://github.com/box/spout/issues/195
* @return void
*/
public function testReaderShouldNotTrimCellValues()
{
$allRows = $this->getAllRowsForFile('sheet_with_untrimmed_strings.ods');
$expectedRows = [
['A'],
[' A '],
["\n\tA\n\t"],
];
$this->assertEquals($expectedRows, $allRows, 'Cell values should not be trimmed');
}
/**
* https://github.com/box/spout/issues/218
* @return void
*/
public function testReaderShouldReadTextInHyperlinks()
{
$allRows = $this->getAllRowsForFile('sheet_with_hyperlinks.ods');
$expectedRows = [
['email', 'text'],
['1@example.com', 'text'],
['2@example.com', 'text and https://github.com/box/spout/issues/218 and text'],
];
$this->assertEquals($expectedRows, $allRows, 'Text in hyperlinks should be read');
}
/**
* @return void
*/
public function testReaderShouldReadInlineFontFormattingAsText()
{
$allRows = $this->getAllRowsForFile('sheet_with_inline_font_formatting.ods');
$expectedRows = [
['I am a yellow bird']
];
$this->assertEquals($expectedRows, $allRows, 'Text formatted inline should be read');
}
/**
* @param string $fileName
* @param bool|void $shouldFormatDates
* @param bool|void $shouldPreserveEmptyRows
* @return array All the read rows the given file
*/
private function getAllRowsForFile($fileName, $shouldFormatDates = false, $shouldPreserveEmptyRows = false)
{
$allRows = [];
$resourcePath = $this->getResourcePath($fileName);
/** @var \Box\Spout\Reader\ODS\Reader $reader */
$reader = ReaderFactory::create(Type::ODS);
$reader->setShouldFormatDates($shouldFormatDates);
$reader->setShouldPreserveEmptyRows($shouldPreserveEmptyRows);
$reader->open($resourcePath);
foreach ($reader->getSheetIterator() as $sheetIndex => $sheet) {
foreach ($sheet->getRowIterator() as $rowIndex => $row) {
$allRows[] = $row;
}
}
$reader->close();
return $allRows;
}
}
+51
View File
@@ -0,0 +1,51 @@
<?php
namespace Box\Spout\Reader\ODS;
use Box\Spout\Common\Type;
use Box\Spout\Reader\ReaderFactory;
use Box\Spout\TestUsingResource;
/**
* Class SheetTest
*
* @package Box\Spout\Reader\ODS
*/
class SheetTest extends \PHPUnit_Framework_TestCase
{
use TestUsingResource;
/**
* @return void
*/
public function testNextSheetShouldReturnCorrectSheetInfos()
{
$sheets = $this->openFileAndReturnSheets('two_sheets_with_custom_names.ods');
$this->assertEquals('Sheet First', $sheets[0]->getName());
$this->assertEquals(0, $sheets[0]->getIndex());
$this->assertEquals('Sheet Last', $sheets[1]->getName());
$this->assertEquals(1, $sheets[1]->getIndex());
}
/**
* @param string $fileName
* @return Sheet[]
*/
private function openFileAndReturnSheets($fileName)
{
$resourcePath = $this->getResourcePath($fileName);
$reader = ReaderFactory::create(Type::ODS);
$reader->open($resourcePath);
$sheets = [];
foreach ($reader->getSheetIterator() as $sheet) {
$sheets[] = $sheet;
}
$reader->close();
return $sheets;
}
}
+78 -35
View File
@@ -20,12 +20,11 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
public function testOpenShouldFailIfFileInsideZipDoesNotExist()
{
$resourcePath = $this->getResourcePath('one_sheet_with_inline_strings.xlsx');
$nonExistingXMLFilePath = 'zip://' . $resourcePath . '#path/to/fake/file.xml';
$xmlReader = new XMLReader();
// using "@" to prevent errors/warning to be displayed
$wasOpenSuccessful = @$xmlReader->open($nonExistingXMLFilePath);
$wasOpenSuccessful = @$xmlReader->openFileInZip($resourcePath, 'path/to/fake/file.xml');
$this->assertTrue($wasOpenSuccessful === false);
}
@@ -72,10 +71,9 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
public function testReadShouldThrowExceptionOnError()
{
$resourcePath = $this->getResourcePath('one_sheet_with_invalid_xml_characters.xlsx');
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/worksheets/sheet1.xml';
$xmlReader = new XMLReader();
if ($xmlReader->open($sheetDataXMLFilePath) === false) {
if ($xmlReader->openFileInZip($resourcePath, 'xl/worksheets/sheet1.xml') === false) {
$this->fail();
}
@@ -95,43 +93,13 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
// The sharedStrings.xml file in "attack_billion_laughs.xlsx" contains
// a doctype element that causes read errors
$resourcePath = $this->getResourcePath('attack_billion_laughs.xlsx');
$sheetDataXMLFilePath = 'zip://' . $resourcePath . '#xl/sharedStrings.xml';
$xmlReader = new XMLReader();
if ($xmlReader->open($sheetDataXMLFilePath) !== false) {
if ($xmlReader->openFileInZip($resourcePath, 'xl/sharedStrings.xml') !== false) {
@$xmlReader->next('sst');
}
}
/**
* @return array
*/
public function dataProviderForTestIsZipStream()
{
return [
['/absolute/path/to/file.xlsx', false],
['relative/path/to/file.xlsx', false],
['php://temp', false],
['zip:///absolute/path/to/file.xlsx', true],
['zip://relative/path/to/file.xlsx', true],
];
}
/**
* @dataProvider dataProviderForTestIsZipStream
*
* @param string $URI
* @param bool $expectedResult
* @return void
*/
public function testIsZipStream($URI, $expectedResult)
{
$xmlReader = new XMLReader();
$isZipStream = \ReflectionHelper::callMethodOnObject($xmlReader, 'isZipStream', $URI);
$this->assertEquals($expectedResult, $isZipStream);
}
/**
* @return array
*/
@@ -163,4 +131,79 @@ class XMLReaderTest extends \PHPUnit_Framework_TestCase
$this->assertEquals($expectedResult, $isZipStream);
}
/**
* @return array
*/
public function dataProviderForTestGetRealPathURIForFileInZip()
{
$tempFolder = realpath(sys_get_temp_dir());
$tempFolderName = basename($tempFolder);
$expectedRealPathURI = 'zip://' . $tempFolder . '/test.xlsx#test.xml';
return [
[$tempFolder, "$tempFolder/test.xlsx", 'test.xml', $expectedRealPathURI],
[$tempFolder, "$tempFolder/../$tempFolderName/test.xlsx", 'test.xml', $expectedRealPathURI],
];
}
/**
* @dataProvider dataProviderForTestGetRealPathURIForFileInZip
*
* @param string $tempFolder
* @param string $zipFilePath
* @param string $fileInsideZipPath
* @param string $expectedRealPathURI
* @return void
*/
public function testGetRealPathURIForFileInZip($tempFolder, $zipFilePath, $fileInsideZipPath, $expectedRealPathURI)
{
touch($tempFolder . '/test.xlsx');
$xmlReader = new XMLReader();
$realPathURI = \ReflectionHelper::callMethodOnObject($xmlReader, 'getRealPathURIForFileInZip', $zipFilePath, $fileInsideZipPath);
// Normalizing path separators for Windows support
$normalizedRealPathURI = str_replace('\\', '/', $realPathURI);
$normalizedExpectedRealPathURI = str_replace('\\', '/', $expectedRealPathURI);
$this->assertEquals($normalizedExpectedRealPathURI, $normalizedRealPathURI);
unlink($tempFolder . '/test.xlsx');
}
/**
* @return array
*/
public function dataProviderForTestIsPositionedOnStartingAndEndingNode()
{
return [
['<test></test>'], // not prefixed
['<x:test xmlns:x="foo"></x:test>'], // prefixed
];
}
/**
* @dataProvider dataProviderForTestIsPositionedOnStartingAndEndingNode
*
* @param string $testXML
* @return void
*/
public function testIsPositionedOnStartingAndEndingNode($testXML)
{
$xmlReader = new XMLReader();
$xmlReader->XML($testXML);
// the first read moves the pointer to "<test>"
$xmlReader->read();
$this->assertTrue($xmlReader->isPositionedOnStartingNode('test'));
$this->assertFalse($xmlReader->isPositionedOnEndingNode('test'));
// the seconds read moves the pointer to "</test>"
$xmlReader->read();
$this->assertFalse($xmlReader->isPositionedOnStartingNode('test'));
$this->assertTrue($xmlReader->isPositionedOnEndingNode('test'));
$xmlReader->close();
}
}
@@ -10,14 +10,25 @@ namespace Box\Spout\Reader\XLSX\Helper;
class CellHelperTest extends \PHPUnit_Framework_TestCase
{
/**
* @return void
* @return array
*/
public function testFillMissingArrayIndexes()
public function dataProviderForTestFillMissingArrayIndexes()
{
$arrayToFill = [1 => 1, 3 => 3];
$filledArray = CellHelper::fillMissingArrayIndexes($arrayToFill, 'FILL');
return [
[ null, [] ],
[ [], [] ],
[ [1 => 1, 3 => 3], ['FILL', 1, 'FILL', 3] ]
];
}
$expectedFilledArray = ['FILL', 1, 'FILL', 3];
/**
* @dataProvider dataProviderForTestFillMissingArrayIndexes
* @param array $arrayToFill
* @param array $expectedFilledArray
*/
public function testFillMissingArrayIndexes($arrayToFill, array $expectedFilledArray)
{
$filledArray = CellHelper::fillMissingArrayIndexes($arrayToFill, 'FILL');
$this->assertEquals($expectedFilledArray, $filledArray);
}
Loaded 100 of 179 files, more files were not shown because too many files have changed in this diff. Show more