From 5e199009e62fe2c09620024c5c85ab643beb04d2 Mon Sep 17 00:00:00 2001 From: Adrien Loison Date: Wed, 14 Jan 2015 16:49:05 -0800 Subject: [PATCH] First external release --- .gitignore | 5 + .travis.yml | 12 + CONTRIBUTING.md | 75 ++++ LICENSE | 166 +++++++ README.md | 254 +++++++++++ composer.json | 29 ++ logo.png | Bin 0 -> 34091 bytes phpunit.xml | 17 + src/Spout/Common/Escaper/CSV.php | 34 ++ src/Spout/Common/Escaper/EscaperInterface.php | 27 ++ src/Spout/Common/Escaper/XLSX.php | 140 ++++++ .../Common/Exception/BadUsageException.php | 12 + src/Spout/Common/Exception/IOException.php | 12 + .../Exception/InvalidArgumentException.php | 12 + src/Spout/Common/Exception/SpoutException.php | 12 + .../Exception/UnsupportedTypeException.php | 12 + src/Spout/Common/Helper/FileSystemHelper.php | 132 ++++++ .../Common/Helper/GlobalFunctionsHelper.php | 163 +++++++ src/Spout/Common/Type.php | 13 + src/Spout/Reader/AbstractReader.php | 196 ++++++++ src/Spout/Reader/CSV.php | 130 ++++++ .../Exception/EndOfFileReachedException.php | 12 + .../EndOfWorksheetsReachedException.php | 12 + .../Exception/NoWorksheetsFoundException.php | 12 + .../Reader/Exception/ReaderException.php | 15 + .../Exception/ReaderNotOpenedException.php | 12 + .../SharedStringNotFoundException.php | 12 + src/Spout/Reader/Helper/XLSX/CellHelper.php | 97 ++++ .../Helper/XLSX/SharedStringsHelper.php | 268 +++++++++++ .../Reader/Helper/XLSX/WorksheetHelper.php | 74 ++++ src/Spout/Reader/Internal/XLSX/Worksheet.php | 44 ++ src/Spout/Reader/ReaderFactory.php | 44 ++ src/Spout/Reader/ReaderInterface.php | 50 +++ src/Spout/Reader/XLSX.php | 251 +++++++++++ src/Spout/Writer/AbstractWriter.php | 198 +++++++++ src/Spout/Writer/CSV.php | 101 +++++ .../Exception/SheetNotFoundException.php | 12 + .../Writer/Exception/WriterException.php | 15 + .../Exception/WriterNotOpenedException.php | 12 + src/Spout/Writer/Helper/XLSX/CellHelper.php | 37 ++ .../Writer/Helper/XLSX/FileSystemHelper.php | 378 ++++++++++++++++ .../Helper/XLSX/SharedStringsHelper.php | 99 +++++ src/Spout/Writer/Helper/XLSX/ZipHelper.php | 50 +++ src/Spout/Writer/Internal/XLSX/Workbook.php | 236 ++++++++++ src/Spout/Writer/Internal/XLSX/Worksheet.php | 174 ++++++++ src/Spout/Writer/Sheet.php | 45 ++ src/Spout/Writer/WriterFactory.php | 44 ++ src/Spout/Writer/WriterInterface.php | 65 +++ src/Spout/Writer/XLSX.php | 184 ++++++++ tests/Spout/Common/Escaper/XLSXTest.php | 73 +++ .../Common/Helper/FileSystemHelperTest.php | 59 +++ tests/Spout/Reader/CSVTest.php | 158 +++++++ .../Helper/XLSX/SharedStringsHelperTest.php | 99 +++++ tests/Spout/Reader/XLSXTest.php | 192 ++++++++ tests/Spout/ReflectionHelper.php | 92 ++++ tests/Spout/TestUsingResource.php | 83 ++++ tests/Spout/Writer/CSVTest.php | 140 ++++++ .../Writer/Helper/XLSX/CellHelperTest.php | 37 ++ tests/Spout/Writer/SheetTest.php | 62 +++ tests/Spout/Writer/XLSXTest.php | 417 ++++++++++++++++++ tests/bootstrap.php | 5 + .../csv/csv_delimited_with_pipes.csv | 3 + tests/resources/csv/csv_standard.csv | 3 + .../csv/csv_text_enclosed_with_pound.csv | 2 + .../resources/csv/csv_with_comma_enclosed.csv | 2 + .../csv/csv_with_different_cells_number.csv | 3 + tests/resources/csv/csv_with_empty_cells.csv | 3 + tests/resources/csv/csv_with_empty_line.csv | 3 + tests/resources/csv/csv_with_utf8_bom.csv | 2 + .../xlsx/billion_laughs_test_file.xlsx | Bin 0 -> 4065 bytes tests/resources/xlsx/file_corrupted.xlsx | 3 + .../file_with_no_sheets_in_content_types.xlsx | Bin 0 -> 3720 bytes .../xlsx/one_sheet_with_inline_strings.xlsx | Bin 0 -> 3737 bytes .../xlsx/one_sheet_with_shared_strings.xlsx | Bin 0 -> 3788 bytes ...sheet_with_dimensions_and_empty_cells.xlsx | Bin 0 -> 3678 bytes .../resources/xlsx/sheet_with_empty_rows.xlsx | Bin 0 -> 3689 bytes .../xlsx/sheet_with_pronunciation.xlsx | Bin 0 -> 3725 bytes ...et_without_dimensions_and_empty_cells.xlsx | Bin 0 -> 3662 bytes ..._dimensions_but_spans_and_empty_cells.xlsx | Bin 0 -> 3669 bytes .../xlsx/two_sheets_with_inline_strings.xlsx | Bin 0 -> 4253 bytes .../xlsx/two_sheets_with_shared_strings.xlsx | Bin 0 -> 4340 bytes 81 files changed, 5447 insertions(+) create mode 100644 .gitignore create mode 100644 .travis.yml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 composer.json create mode 100644 logo.png create mode 100644 phpunit.xml create mode 100644 src/Spout/Common/Escaper/CSV.php create mode 100644 src/Spout/Common/Escaper/EscaperInterface.php create mode 100644 src/Spout/Common/Escaper/XLSX.php create mode 100644 src/Spout/Common/Exception/BadUsageException.php create mode 100644 src/Spout/Common/Exception/IOException.php create mode 100644 src/Spout/Common/Exception/InvalidArgumentException.php create mode 100644 src/Spout/Common/Exception/SpoutException.php create mode 100644 src/Spout/Common/Exception/UnsupportedTypeException.php create mode 100644 src/Spout/Common/Helper/FileSystemHelper.php create mode 100644 src/Spout/Common/Helper/GlobalFunctionsHelper.php create mode 100644 src/Spout/Common/Type.php create mode 100644 src/Spout/Reader/AbstractReader.php create mode 100644 src/Spout/Reader/CSV.php create mode 100644 src/Spout/Reader/Exception/EndOfFileReachedException.php create mode 100644 src/Spout/Reader/Exception/EndOfWorksheetsReachedException.php create mode 100644 src/Spout/Reader/Exception/NoWorksheetsFoundException.php create mode 100644 src/Spout/Reader/Exception/ReaderException.php create mode 100644 src/Spout/Reader/Exception/ReaderNotOpenedException.php create mode 100644 src/Spout/Reader/Exception/SharedStringNotFoundException.php create mode 100644 src/Spout/Reader/Helper/XLSX/CellHelper.php create mode 100644 src/Spout/Reader/Helper/XLSX/SharedStringsHelper.php create mode 100644 src/Spout/Reader/Helper/XLSX/WorksheetHelper.php create mode 100644 src/Spout/Reader/Internal/XLSX/Worksheet.php create mode 100644 src/Spout/Reader/ReaderFactory.php create mode 100644 src/Spout/Reader/ReaderInterface.php create mode 100644 src/Spout/Reader/XLSX.php create mode 100644 src/Spout/Writer/AbstractWriter.php create mode 100644 src/Spout/Writer/CSV.php create mode 100644 src/Spout/Writer/Exception/SheetNotFoundException.php create mode 100644 src/Spout/Writer/Exception/WriterException.php create mode 100644 src/Spout/Writer/Exception/WriterNotOpenedException.php create mode 100644 src/Spout/Writer/Helper/XLSX/CellHelper.php create mode 100644 src/Spout/Writer/Helper/XLSX/FileSystemHelper.php create mode 100644 src/Spout/Writer/Helper/XLSX/SharedStringsHelper.php create mode 100644 src/Spout/Writer/Helper/XLSX/ZipHelper.php create mode 100644 src/Spout/Writer/Internal/XLSX/Workbook.php create mode 100644 src/Spout/Writer/Internal/XLSX/Worksheet.php create mode 100644 src/Spout/Writer/Sheet.php create mode 100644 src/Spout/Writer/WriterFactory.php create mode 100644 src/Spout/Writer/WriterInterface.php create mode 100644 src/Spout/Writer/XLSX.php create mode 100644 tests/Spout/Common/Escaper/XLSXTest.php create mode 100644 tests/Spout/Common/Helper/FileSystemHelperTest.php create mode 100644 tests/Spout/Reader/CSVTest.php create mode 100644 tests/Spout/Reader/Helper/XLSX/SharedStringsHelperTest.php create mode 100644 tests/Spout/Reader/XLSXTest.php create mode 100644 tests/Spout/ReflectionHelper.php create mode 100644 tests/Spout/TestUsingResource.php create mode 100644 tests/Spout/Writer/CSVTest.php create mode 100644 tests/Spout/Writer/Helper/XLSX/CellHelperTest.php create mode 100644 tests/Spout/Writer/SheetTest.php create mode 100644 tests/Spout/Writer/XLSXTest.php create mode 100644 tests/bootstrap.php create mode 100644 tests/resources/csv/csv_delimited_with_pipes.csv create mode 100644 tests/resources/csv/csv_standard.csv create mode 100644 tests/resources/csv/csv_text_enclosed_with_pound.csv create mode 100644 tests/resources/csv/csv_with_comma_enclosed.csv create mode 100644 tests/resources/csv/csv_with_different_cells_number.csv create mode 100644 tests/resources/csv/csv_with_empty_cells.csv create mode 100644 tests/resources/csv/csv_with_empty_line.csv create mode 100644 tests/resources/csv/csv_with_utf8_bom.csv create mode 100644 tests/resources/xlsx/billion_laughs_test_file.xlsx create mode 100644 tests/resources/xlsx/file_corrupted.xlsx create mode 100644 tests/resources/xlsx/file_with_no_sheets_in_content_types.xlsx create mode 100644 tests/resources/xlsx/one_sheet_with_inline_strings.xlsx create mode 100644 tests/resources/xlsx/one_sheet_with_shared_strings.xlsx create mode 100644 tests/resources/xlsx/sheet_with_dimensions_and_empty_cells.xlsx create mode 100644 tests/resources/xlsx/sheet_with_empty_rows.xlsx create mode 100644 tests/resources/xlsx/sheet_with_pronunciation.xlsx create mode 100644 tests/resources/xlsx/sheet_without_dimensions_and_empty_cells.xlsx create mode 100644 tests/resources/xlsx/sheet_without_dimensions_but_spans_and_empty_cells.xlsx create mode 100644 tests/resources/xlsx/two_sheets_with_inline_strings.xlsx create mode 100644 tests/resources/xlsx/two_sheets_with_shared_strings.xlsx diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f8600d9 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/tests/resources/generated +/vendor +composer.lock +/.idea +*.iml diff --git a/.travis.yml b/.travis.yml new file mode 100644 index 0000000..c58311e --- /dev/null +++ b/.travis.yml @@ -0,0 +1,12 @@ +language: php + +php: + - 5.4 + - 5.5 + - 5.6 + +install: + - composer self-update + - composer install --prefer-source + +script: phpunit diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..4cbf577 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing + +All contributions are welcome to this project. + +## Contributor License Agreement + +Before a contribution can be merged into this project, please fill out the Contributor License Agreement (CLA) located at: + +http://opensource.box.com/cla + +To learn more about CLAs and why they are important to open source projects, please see the [Wikipedia entry](http://en.wikipedia.org/wiki/Contributor_License_Agreement). + +## How to contribute + +* **File an issue** - if you found a bug, want to request an enhancement, or want to implement something (bug fix or feature). +* **Send a pull request** - if you want to contribute code. Please be sure to file an issue first. + +## Pull request best practices + +We want to accept your pull requests. Please follow these steps: + +### Step 1: File an issue + +Before writing any code, please file an issue stating the problem you want to solve or the feature you want to implement. This allows us to give you feedback before you spend any time writing code. There may be a known limitation that can't be addressed, or a bug that has already been fixed in a different way. The issue allows us to communicate and figure out if it's worth your time to write a bunch of code for the project. + +### Step 2: Fork this repository in GitHub + +This will create your own copy of our repository. + +### Step 3: Add the upstream source + +The upstream source is the project under the Box organization on GitHub. To add an upstream source for this project, type: + +``` +git remote add upstream git@github.com:box/spout.git +``` + +This will come in useful later. + +### Step 4: Create a feature branch + +Create a branch with a descriptive name, such as `add-search`. + +### Step 5: Push your feature branch to your fork + +As you develop code, continue to push code to your remote feature branch. Please make sure to include the issue number you're addressing in your commit message, such as: + +``` +git commit -m "Adding search (fixes #123)" +``` + +This helps us out by allowing us to track which issue your commit relates to. + +Keep a separate feature branch for each issue you want to address. + +### Step 6: Rebase + +Before sending a pull request, rebase against upstream, such as: + +``` +git fetch upstream +git rebase upstream/master +``` + +This will add your changes on top of what's already in upstream, minimizing merge issues. + +### Step 7: Run the tests + +Make sure that all tests are passing before submitting a pull request. + +### Step 8: Send the pull request + +Send the pull request from your feature branch to us. Be sure to include a description that lets us know what work you did. + +Keep in mind that we like to see one issue addressed per pull request, as this helps keep our git history clean and we can more easily track down issues. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..167ec4d --- /dev/null +++ b/LICENSE @@ -0,0 +1,166 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS diff --git a/README.md b/README.md new file mode 100644 index 0000000..4dfb7c0 --- /dev/null +++ b/README.md @@ -0,0 +1,254 @@ +# 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). + +[![Build Status](https://travis-ci.org/box/spout.png?branch=master)](http://travis-ci.org/box/spout) +[![Project Status](http://opensource.box.com/badges/active.svg)](http://opensource.box.com/badges) + + +## Installation + +The Spout library 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": "*" +} +``` + +Then run the install command from Composer: +``` +php composer.phar install +``` + + +## Requirements + +* PHP version 5.4.0 or higher +* PHP extension `php_zip` enabled +* PHP extension `php_xmlreader` enabled +* PHP extension `php_simplexml` enabled + + +## Basic usage + +### Reader + +#### How to read a CSV file? + +```php +use Box\Spout\Reader\ReaderFactory; +use Box\Spout\Common\Type; + +$reader = ReaderFactory::create(Type::CSV); +$reader->open($filePath); + +while ($reader->hasNextRow()) { + $row = $reader->nextRow(); + // do stuff +} + +$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); + +while ($reader->hasNextSheet()) { + $reader->nextSheet(); + + while ($reader->hasNextRow()) { + $row = $reader->nextRow(); + // do stuff + } +} + +$reader->close(); +``` + +If there are multiple sheets in the file, the reader will read through all of them sequentially. + +### Writer + +### How to create a CSV file? + +```php +use Box\Spout\Writer\WriterFactory; +use Box\Spout\Common\Type; + +$writer = WriterFactory::create(Type::CSV); +$writer->openToFile($filePath); // write data to a file or to a PHP stream +$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. + + +## Advanced usage + +### Configuring the CSV reader and writer + +It is possible to configure the both the CSV reader and writer to specify the field separator as well as the field enclosure: +```php +use Box\Spout\Reader\ReaderFactory; +use Box\Spout\Common\Type; + +$reader = ReaderFactory::create(Type::CSV); +$reader->setFieldDelimiter('|'); +$reader->setFieldEnclosure('@'); +``` + +### Configuring the XLSX writer + +#### Strings storage + +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) +* Inline strings are less optimized (as duplicate strings are all stored) but is faster to process + +In order to keep the memory usage really low, Spout does not optimize strings when using shared strings. It is nevertheless possible to use this mode. +```php +use Box\Spout\Writer\WriterFactory; +use Box\Spout\Common\Type; + +$writer = WriterFactory::create(Type::XLSX); +$writer->setShouldUseInlineStrings(true); // default (and recommended) value +$writer->setShouldUseInlineStrings(false); // will use shared strings +``` + +#### New sheet creation + +It is also possible to change the behavior of the writer when the maximum number of rows (1,048,576) have been written in the current sheet: +```php +use Box\Spout\Writer\WriterFactory; +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 +``` + +### Using custom temporary folder + +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: +```php +$firstSheet = $writer->getCurrentSheet(); +$writer->addRow($rowForSheet1); // writes the row to the first sheet + +$newSheet = $writer->addNewSheetAndMakeItCurrent(); +$writer->addRow($rowForSheet2); // writes the row to the new sheet + +$writer->setCurrentSheet($firstSheet); +$writer->addRow($anotherRowForSheet1); // append the row to the first sheet +``` + +It is also possible to retrieve all the sheets currently created: +```php +$sheets = $writer->getSheets(); +``` + +### Fluent interface + +Because fluent interfaces are great, you can use them with Spout: +```php +use Box\Spout\Writer\WriterFactory; +use Box\Spout\Common\Type; + +$writer = WriterFactory::create(Type::XLSX); +$writer->setTempFolder($customTempFolderPath) + ->setShouldUseInlineStrings(true) + ->openToFile($filePath) + ->addRow($headerRow) + ->addRows($dataRows) + ->close(); +``` + + +## 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. + +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 + +For information, the performance tests take about one hour to run (processing 2 million rows files is not a quick thing). + + +## Frequently Asked Questions + +#### How can Spout handle such large data sets and still use less than 10MB 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. + +Same goes with reading. Only one row at a time is stored in memory. A special technique is used to handle shared strings in XLSX, storing them into several small temporary files that allows fast access. + +#### How long does it take to generate a file with X rows? + +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 | + + +## Support + +Need to contact us directly? Email oss@box.com and be sure to include the name of this project in the subject. + + +## Copyright and License + +Copyright 2015 Box, Inc. All rights reserved. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/composer.json b/composer.json new file mode 100644 index 0000000..a469e9f --- /dev/null +++ b/composer.json @@ -0,0 +1,29 @@ +{ + "name": "box/spout", + "description": "PHP Library to read and write CSV and XLSX files, in a fast and scalable way", + "type": "library", + "version": "1.0.0", + "keywords": ["php","read","write","csv","xlsx","excel","spreadsheet","scale","memory","stream","ooxml"], + "license": "Apache-2.0", + "homepage": "https://www.github.com/box/spout", + "authors": [ + { + "name": "Adrien Loison", + "email": "adrien@box.com" + } + ], + "require": { + "php": ">=5.4.0", + "ext-zip": "*", + "ext-xmlreader" : "*", + "ext-simplexml": "*" + }, + "require-dev": { + "phpunit/phpunit": ">=3.7" + }, + "autoload": { + "psr-4": { + "Box\\Spout\\": "src/Spout" + } + } +} diff --git a/logo.png b/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..1319250177173b1fd90f77dd2b8c0fb867642f13 GIT binary patch literal 34091 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 RgaiKFm46_Yf5+tM{{hUz;0^!) literal 0 HcmV?d00001 diff --git a/phpunit.xml b/phpunit.xml new file mode 100644 index 0000000..975acc6 --- /dev/null +++ b/phpunit.xml @@ -0,0 +1,17 @@ + + + + + tests/ + + + + diff --git a/src/Spout/Common/Escaper/CSV.php b/src/Spout/Common/Escaper/CSV.php new file mode 100644 index 0000000..efaebd6 --- /dev/null +++ b/src/Spout/Common/Escaper/CSV.php @@ -0,0 +1,34 @@ +controlCharactersEscapingMap = $this->getControlCharactersEscapingMap(); + } + + /** + * Escapes the given string to make it compatible with XLSX + * + * @param string $string The string to escape + * @return string The escaped string + */ + public function escape($string) + { + $escapedString = $this->escapeControlCharacters($string); + $escapedString = htmlspecialchars($escapedString, ENT_QUOTES, 'utf-8'); + + return $escapedString; + } + + /** + * Unescapes the given string to make it compatible with XLSX + * + * @param string $string The string to unescape + * @return string The unescaped string + */ + public function unescape($string) + { + $unescapedString = htmlspecialchars_decode($string, ENT_QUOTES); + $unescapedString = $this->unescapeControlCharacters($unescapedString); + + return $unescapedString; + } + + /** + * Builds the map containing control characters to be escaped + * mapped to their escaped values. + * "\t", "\r" and "\n" don't need to be escaped. + * + * NOTE: the logic has been adapted from the XlsxWriter library (BSD License) + * @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89 + * + * @return string[] + */ + protected function getControlCharactersEscapingMap() + { + $controlCharactersEscapingMap = []; + $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)) { + $charHexValue = dechex($charValue); + $escapedChar = '_x' . sprintf('%04s' , strtoupper($charHexValue)) . '_'; + $controlCharactersEscapingMap[$escapedChar] = chr($charValue); + } + } + + return $controlCharactersEscapingMap; + } + + /** + * Converts PHP control characters from the given string to OpenXML escaped control characters + * + * Excel escapes control characters with _xHHHH_ and also escapes any + * literal strings of that type by encoding the leading underscore. + * So "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_. + * + * NOTE: the logic has been adapted from the XlsxWriter library (BSD License) + * @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89 + * + * @param string $string String to escape + * @return string + */ + protected function escapeControlCharacters($string) + { + $escapedString = $this->escapeEscapeCharacter($string); + return str_replace(array_values($this->controlCharactersEscapingMap), array_keys($this->controlCharactersEscapingMap), $escapedString); + } + + /** + * Escapes the escape character: "_x0000_" -> "_x005F_x0000_" + * + * @param string $string String to escape + * @return string The escaped string + */ + protected function escapeEscapeCharacter($string) + { + return preg_replace('/_(x[\dA-F]{4})_/', '_x005F_$1_', $string); + } + + /** + * Converts OpenXML escaped control characters from the given string to PHP control characters + * + * Excel escapes control characters with _xHHHH_ and also escapes any + * literal strings of that type by encoding the leading underscore. + * So "_x0000_" -> "\0" and "_x005F_x0000_" -> "_x0000_" + * + * NOTE: the logic has been adapted from the XlsxWriter library (BSD License) + * @link https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89 + * + * @param string $string String to unescape + * @return string + */ + protected function unescapeControlCharacters($string) + { + $unescapedString = $string; + foreach ($this->controlCharactersEscapingMap as $escapedCharValue => $charValue) { + // only unescape characters that don't contain the escaped escape character for now + $unescapedString = preg_replace("/(?unescapeEscapeCharacter($unescapedString); + } + + /** + * Unecapes the escape character: "_x005F_x0000_" => "_x0000_" + * + * @param string $string String to unescape + * @return string The unescaped string + */ + protected function unescapeEscapeCharacter($string) + { + return preg_replace('/_x005F(_x[\dA-F]{4}_)/', '$1', $string); + } +} diff --git a/src/Spout/Common/Exception/BadUsageException.php b/src/Spout/Common/Exception/BadUsageException.php new file mode 100644 index 0000000..716dfc3 --- /dev/null +++ b/src/Spout/Common/Exception/BadUsageException.php @@ -0,0 +1,12 @@ +baseFolderPath = $baseFolderPath; + } + + /** + * Creates an empty folder with the given name under the given parent folder. + * + * @param string $parentFolderPath The parent folder path under which the folder is going to be created + * @param string $folderName The name of the folder to create + * @return string Path of the created folder + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or if the folder path is not inside of the base folder + */ + public function createFolder($parentFolderPath, $folderName) + { + $this->throwIfOperationNotInBaseFolder($parentFolderPath); + + $folderPath = $parentFolderPath . DIRECTORY_SEPARATOR . $folderName; + + $wasCreationSuccessful = mkdir($folderPath, 0777, true); + if (!$wasCreationSuccessful) { + throw new IOException('Unable to create folder: ' . $folderPath); + } + + return $folderPath; + } + + /** + * Creates a file with the given name and content in the given folder. + * The parent folder must exist. + * + * @param string $parentFolderPath The parent folder path where the file is going to be created + * @param string $fileName The name of the file to create + * @param string $fileContents The contents of the file to create + * @return string Path of the created file + * @throws \Box\Spout\Common\Exception\IOException If unable to create the file or if the file path is not inside of the base folder + */ + public function createFileWithContents($parentFolderPath, $fileName, $fileContents) + { + $this->throwIfOperationNotInBaseFolder($parentFolderPath); + + $filePath = $parentFolderPath . DIRECTORY_SEPARATOR . $fileName; + + $wasCreationSuccessful = file_put_contents($filePath, $fileContents); + if (!$wasCreationSuccessful) { + throw new IOException('Unable to create file: ' . $filePath); + } + + return $filePath; + } + + /** + * Delete the file at the given path + * + * @param string $filePath Path of the file to delete + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the file path is not inside of the base folder + */ + public function deleteFile($filePath) + { + $this->throwIfOperationNotInBaseFolder($filePath); + + if (file_exists($filePath) && is_file($filePath)) { + unlink($filePath); + } + } + + /** + * Delete the folder at the given path as well as all its contents + * + * @param string $folderPath Path of the folder to delete + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the folder path is not inside of the base folder + */ + public function deleteFolderRecursively($folderPath) + { + $this->throwIfOperationNotInBaseFolder($folderPath); + + $itemIterator = new \RecursiveIteratorIterator( + new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), + \RecursiveIteratorIterator::CHILD_FIRST + ); + + foreach ($itemIterator as $item) { + if ($item->isDir()) { + rmdir($item->getPathname()); + } else { + unlink($item->getPathname()); + } + } + + rmdir($folderPath); + } + + /** + * All I/O operations must occur inside the base folder, for security reasons. + * This function will throw an exception if the folder where the I/O operation + * should occur is not inside the base folder. + * + * @param string $operationFolderPath The path of the folder where the I/O operation should occur + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the folder where the I/O operation should occur is not inside the base folder + */ + protected function throwIfOperationNotInBaseFolder($operationFolderPath) + { + $isInBaseFolder = (strpos($operationFolderPath, $this->baseFolderPath) === 0); + if (!$isInBaseFolder) { + throw new IOException('Cannot perform I/O operation outside of the base folder: ' . $this->baseFolderPath); + } + } +} diff --git a/src/Spout/Common/Helper/GlobalFunctionsHelper.php b/src/Spout/Common/Helper/GlobalFunctionsHelper.php new file mode 100644 index 0000000..8ce7ab5 --- /dev/null +++ b/src/Spout/Common/Helper/GlobalFunctionsHelper.php @@ -0,0 +1,163 @@ +globalFunctionsHelper = $globalFunctionsHelper; + return $this; + } + + /** + * Prepares the reader to read the given file. It also makes sure + * that the file exists and is readable. + * + * @param string $filePath Path of the file to be read + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the file at the given path does not exist, is not readable or is corrupted + */ + public function open($filePath) + { + if (!$this->isPhpStream($filePath)) { + // we skip the checks if the provided file path points to a PHP stream + if (!$this->globalFunctionsHelper->file_exists($filePath)) { + throw new IOException('Could not open ' . $filePath . ' for reading! File does not exist.'); + } else if (!$this->globalFunctionsHelper->is_readable($filePath)) { + throw new IOException('Could not open ' . $filePath . ' for reading! File is not readable.'); + } + } + + $this->currentRowIndex = 0; + $this->hasReachedEndOfFile = false; + + try { + $this->openReader($filePath); + $this->isStreamOpened = true; + } catch (\Exception $exception) { + throw new IOException('Could not open ' . $filePath . ' for reading! (' . $exception->getMessage() . ')'); + } + } + + /** + * Checks if a path is a PHP stream (like php://output, php://memory, ...) + * + * @param string $filePath Path of the file to be read + * @return bool Whether the given path maps to a PHP stream + */ + protected function isPhpStream($filePath) + { + return (strpos($filePath, 'php://') === 0); + } + + /** + * Returns whether all rows have been read (i.e. if we are at the end of the file). + * To know if the end of file has been reached, it uses a buffer. If the buffer is + * empty (meaning, nothing has been read or previous read line has been consumed), then + * it reads the next line, store it in the buffer for the next time or flip a variable if + * the end of file has been reached. + * + * @return bool Whether all rows have been read (i.e. if we are at the end of the file) + * @throws \Box\Spout\Reader\Exception\ReaderNotOpenedException If the stream was not opened first + */ + public function hasNextRow() + { + if (!$this->isStreamOpened) { + throw new ReaderNotOpenedException('Stream should be opened first.'); + } + + if ($this->hasReachedEndOfFile) { + return false; + } + + // if the buffer contains unprocessed row + if (!$this->isRowDataBufferEmpty()) { + return true; + } + + // otherwise, try to read the next line line, and store it in the buffer + $this->rowDataBuffer = $this->read(); + + // if the buffer is still empty after reading a row, it means end of file was reached + $this->hasReachedEndOfFile = $this->isRowDataBufferEmpty(); + + return (!$this->hasReachedEndOfFile); + } + + /** + * Returns next row if available. The row is either retrieved from the buffer if it is not empty or fetched by + * actually reading the file. + * + * @return array Array that contains the data for the read row + * @throws \Box\Spout\Common\Exception\IOException If the stream was not opened first + * @throws \Box\Spout\Reader\Exception\EndOfFileReachedException + */ + public function nextRow() + { + if (!$this->hasNextRow()) { + throw new EndOfFileReachedException('End of file was reached. Cannot read more rows.'); + } + + // Get data from buffer (if the buffer was empty, it was filled by the call to hasNextRow()) + $rowData = $this->rowDataBuffer; + + // empty buffer to mark the row as consumed + $this->emptyRowDataBuffer(); + + $this->currentRowIndex++; + + return $rowData; + } + + /** + * Returns whether the buffer where the row data is stored is empty + * + * @return bool + */ + protected function isRowDataBufferEmpty() + { + return ($this->rowDataBuffer === null); + } + + /** + * Empty the buffer that stores row data + * + * @return void + */ + protected function emptyRowDataBuffer() + { + $this->rowDataBuffer = null; + } + + /** + * Closes the reader, preventing any additional reading + * + * @return void + */ + public function close() + { + if ($this->isStreamOpened) { + $this->closeReader(); + $this->isStreamOpened = false; + } + } +} diff --git a/src/Spout/Reader/CSV.php b/src/Spout/Reader/CSV.php new file mode 100644 index 0000000..bd48248 --- /dev/null +++ b/src/Spout/Reader/CSV.php @@ -0,0 +1,130 @@ +fieldDelimiter = $fieldDelimiter; + return $this; + } + + /** + * Sets the field enclosure for the CSV + * + * @param string $fieldEnclosure Character that enclose fields + * @return CSV + */ + public function setFieldEnclosure($fieldEnclosure) + { + $this->fieldEnclosure = $fieldEnclosure; + return $this; + } + + /** + * Opens the file at the given path to make it ready to be read. + * The file must be UTF-8 encoded. + * @TODO add encoding detection/conversion + * + * @param string $filePath Path of the XLSX file to be read + * @return void + * @throws \Box\Spout\Common\Exception\IOException + */ + protected function openReader($filePath) + { + $this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r'); + if (!$this->filePointer) { + throw new IOException('Could not open file ' . $filePath . ' for reading.'); + } + + $this->skipUtf8Bom(); + } + + /** + * This skips the UTF-8 BOM if inserted at the beginning of the file + * by moving the file pointer after it, so that it is not read. + * + * @return void + */ + protected function skipUtf8Bom() + { + $this->globalFunctionsHelper->rewind($this->filePointer); + + $hasUtf8Bom = ($this->globalFunctionsHelper->fgets($this->filePointer, 4) === self::UTF8_BOM); + + if ($hasUtf8Bom) { + // we skip the 2 first bytes (so start from the 3rd byte) + $this->globalFunctionsHelper->fseek($this->filePointer, 3); + } else { + // if no BOM, reset the pointer to read from the beginning + $this->globalFunctionsHelper->fseek($this->filePointer, 0); + } + } + + /** + * Reads and returns next row if available. + * Empty rows are skipped. + * + * @return array|null Array that contains the data for the read row or null at the end of the file + */ + protected function read() + { + $lineData = null; + + if ($this->filePointer) { + do { + $lineData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, 0, $this->fieldDelimiter, $this->fieldEnclosure); + } while ($lineData && $this->isEmptyLine($lineData)); + } + + // When reaching the end of the file, return null instead of false + return ($lineData !== false) ? $lineData : null; + } + + /** + * @param array $lineData Array containing the cells value for the line + * @return bool Whether the given line is empty + */ + protected function isEmptyLine($lineData) + { + return (count($lineData) === 1 && $lineData[0] === null); + } + + /** + * Closes the reader. To be used after reading the file. + * + * @return void + */ + protected function closeReader() + { + if ($this->filePointer) { + $this->globalFunctionsHelper->fclose($this->filePointer); + } + } +} diff --git a/src/Spout/Reader/Exception/EndOfFileReachedException.php b/src/Spout/Reader/Exception/EndOfFileReachedException.php new file mode 100644 index 0000000..6194d49 --- /dev/null +++ b/src/Spout/Reader/Exception/EndOfFileReachedException.php @@ -0,0 +1,12 @@ + 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; + + if ($columnLength == 1) { + $columnIndex += $currentColumnIndex; + } else { + $columnIndex += pow($step, ($columnLength - 1)) * ($currentColumnIndex + 1); + } + + $columnLength--; + } + + return $columnIndex; + } + + /** + * Returns whether a cell index is valid, in an Excel world. + * To be valid, the cell index should start with capital letters and be followed by numbers. + * + * @param string $cellIndex The Excel cell index ('A1', 'BC13', ...) + * @return bool + */ + protected static function isValidCellIndex($cellIndex) + { + return (preg_match('/^[A-Z]+\d+$/', $cellIndex) === 1); + } +} diff --git a/src/Spout/Reader/Helper/XLSX/SharedStringsHelper.php b/src/Spout/Reader/Helper/XLSX/SharedStringsHelper.php new file mode 100644 index 0000000..b1a239c --- /dev/null +++ b/src/Spout/Reader/Helper/XLSX/SharedStringsHelper.php @@ -0,0 +1,268 @@ +filePath = $filePath; + + $rootTempFolder = ($tempFolder) ?: sys_get_temp_dir(); + $this->fileSystemHelper = new FileSystemHelper($rootTempFolder); + $this->tempFolder = $this->fileSystemHelper->createFolder($rootTempFolder, uniqid('sharedstrings')); + } + + /** + * Builds an in-memory array containing all the shared strings of the worksheet. + * All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'. + * It is then accessed by the worksheet data, via the string index in the built table. + * + * More documentation available here: http://msdn.microsoft.com/en-us/library/office/gg278314.aspx + * + * The XML file can be really big with worksheets containing a lot of data. That is why + * we need to use a XML reader that provides streaming like the XMLReader library. + * Please note that SimpleXML does not provide such a functionality but since it is faster + * and more handy to parse few XML nodes, it is used in combination with XMLReader for that purpose. + * + * @param string $filePath + * @return void + * @throws \Box\Spout\Common\Exception\IOException If sharedStrings.xml can't be read + */ + public function extractSharedStrings() + { + $xmlReader = new \XMLReader(); + $sharedStringIndex = 0; + $this->tempFilePointer = null; + $escaper = new \Box\Spout\Common\Escaper\XLSX(); + + $sharedStringsFilePath = 'zip://' . $this->filePath . '#' . self::SHARED_STRINGS_XML_FILE_PATH; + if ($xmlReader->open($sharedStringsFilePath, null, LIBXML_NONET) === false) { + throw new IOException('Could not open "' . self::SHARED_STRINGS_XML_FILE_PATH . '".'); + } + + while ($xmlReader->read() && $xmlReader->name !== 'si') { + // do nothing until a 'si' tag is reached + } + + while ($xmlReader->name === 'si') { + $node = new \SimpleXMLElement($xmlReader->readOuterXml()); + $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->writeSharedStringToTempFile($unescapedTextValue, $sharedStringIndex); + + $sharedStringIndex++; + + // jump to the next 'si' tag + $xmlReader->next('si'); + } + + // close pointer to the last temp file that was written + if ($this->tempFilePointer) { + fclose($this->tempFilePointer); + } + + $xmlReader->close(); + } + + /** + * Removes nodes that should not be read, like the pronunciation of the Kanji characters. + * By keeping them, their text content would be added to the read string. + * + * @param \SimpleXMLElement $parentNode Parent node that may contain nodes to remove + * @return \SimpleXMLElement Cleaned parent node + */ + protected function removeSuperfluousTextNodes($parentNode) + { + $tagsToRemove = [ + 'rPh', // Pronunciation of the text + ]; + + foreach ($tagsToRemove as $tagToRemove) { + $xpath = '//ns:' . $tagToRemove; + $nodesToRemove = $parentNode->xpath($xpath); + + foreach ($nodesToRemove as $nodeToRemove) { + // This is how to remove a node from the XML + unset($nodeToRemove[0]); + } + } + + return $parentNode; + } + + /** + * If the text node has the attribute 'xml:space="preserve"', then preserve whitespace. + * + * @param \SimpleXMLElement $textNode The text node element () whitespace may be preserved + * @return bool Whether whitespace should be preserved + */ + protected function shouldPreserveWhitespace($textNode) + { + $shouldPreserveWhitespace = false; + + $attributes = $textNode->attributes('xml', true); + if ($attributes) { + foreach ($attributes as $attributeName => $attributeValue) { + if ($attributeName === 'space' && $attributeValue->__toString() === 'preserve') { + $shouldPreserveWhitespace = true; + break; + } + } + } + + return $shouldPreserveWhitespace; + } + + /** + * Writes the given string to its associated temp file. + * A new temporary file is created when the previous one has reached its max capacity. + * + * @param string $sharedString Shared string to write to the temp file + * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file + * @return void + */ + protected function writeSharedStringToTempFile($sharedString, $sharedStringIndex) + { + $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex); + + if (!file_exists($tempFilePath)) { + if ($this->tempFilePointer) { + fclose($this->tempFilePointer); + } + $this->tempFilePointer = fopen($tempFilePath, 'w'); + } + + fwrite($this->tempFilePointer, $sharedString . PHP_EOL); + } + + /** + * Returns the path for the temp file that should contain the string for the given index + * + * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file + * @return string The temp file path for the given index + */ + protected function getSharedStringTempFilePath($sharedStringIndex) + { + $numTempFile = intval($sharedStringIndex / self::MAX_NUM_STRINGS_PER_TEMP_FILE); + return $this->tempFolder . DIRECTORY_SEPARATOR . 'sharedstrings' . $numTempFile; + } + + /** + * Returns the shared string at the given index. + * Because the strings have been split into different files, it looks for the value in the correct file. + * + * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file + * @return string The shared string at the given index + * @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index + */ + public function getStringAtIndex($sharedStringIndex) + { + $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex); + $indexInFile = $sharedStringIndex % self::MAX_NUM_STRINGS_PER_TEMP_FILE; + + if (!file_exists($tempFilePath)) { + throw new SharedStringNotFoundException("Shared string temp file not found: $tempFilePath ; for index: $sharedStringIndex"); + } + + if ($this->inMemoryTempFilePath !== $tempFilePath) { + // free memory + unset($this->inMemoryTempFileContents); + + $this->inMemoryTempFileContents = explode(PHP_EOL, file_get_contents($tempFilePath)); + $this->inMemoryTempFilePath = $tempFilePath; + } + + $sharedString = null; + if (array_key_exists($indexInFile, $this->inMemoryTempFileContents)) { + $sharedString = $this->inMemoryTempFileContents[$indexInFile]; + } + + if (!$sharedString) { + throw new SharedStringNotFoundException("Shared string not found for index: $sharedStringIndex"); + } + + return rtrim($sharedString, PHP_EOL); + } + + /** + * Deletes the created temporary folder and all its contents + * + * @return void + */ + public function cleanup() + { + $this->fileSystemHelper->deleteFolderRecursively($this->tempFolder); + } +} diff --git a/src/Spout/Reader/Helper/XLSX/WorksheetHelper.php b/src/Spout/Reader/Helper/XLSX/WorksheetHelper.php new file mode 100644 index 0000000..a105e01 --- /dev/null +++ b/src/Spout/Reader/Helper/XLSX/WorksheetHelper.php @@ -0,0 +1,74 @@ +filePath = $filePath; + } + + /** + * Returns the file paths of the worksheet data XML files within the XLSX file. + * The paths are read from the [Content_Types].xml file. + * + * @return Worksheet[] Worksheets within the XLSX file + */ + public function getWorksheets() + { + $worksheets = []; + + $xmlContents = file_get_contents('zip://' . $this->filePath . '#' . self::CONTENT_TYPES_XML_FILE_PATH); + + $contentTypes = new \SimpleXMLElement($xmlContents); + $contentTypes->registerXPathNamespace('ns', self::MAIN_NAMESPACE_FOR_CONTENT_TYPES_XML); + + // find all nodes defining a worksheet + $sheetNodes = $contentTypes->xpath('//ns:Override[@ContentType="' . self::OVERRIDE_CONTENT_TYPES_ATTRIBUTE . '"]'); + + for ($i = 0; $i < count($sheetNodes); $i++) { + $sheetNode = $sheetNodes[$i]; + $sheetDataXMLFilePath = (string) $sheetNode->attributes()->PartName; + $worksheets[] = new Worksheet($i, $sheetDataXMLFilePath); + } + + return $worksheets; + } + + /** + * Returns whether another worksheet exists after the current worksheet. + * The order is determined by the order of appearance in the [Content_Types].xml file. + * + * @param Worksheet|null $currentWorksheet The worksheet being currently read or null if reading has not started yet + * @param Worksheet[] $allWorksheets A list of all worksheets in the XLSX file. Must contain at least one worksheet + * @return bool Whether another worksheet exists after the current sheet + */ + public function hasNextWorksheet($currentWorksheet, $allWorksheets) + { + return ($currentWorksheet === null || ($currentWorksheet->getWorksheetNumber() + 1 < count($allWorksheets))); + } +} diff --git a/src/Spout/Reader/Internal/XLSX/Worksheet.php b/src/Spout/Reader/Internal/XLSX/Worksheet.php new file mode 100644 index 0000000..6a12357 --- /dev/null +++ b/src/Spout/Reader/Internal/XLSX/Worksheet.php @@ -0,0 +1,44 @@ +worksheetNumber = $worksheetNumber; + $this->dataXmlFilePath = $dataXmlFilePath; + } + + /** + * @return string Path of the XML file containing the worksheet data, without the leading slash + */ + public function getDataXmlFilePath() + { + return ltrim($this->dataXmlFilePath, DIRECTORY_SEPARATOR); + } + + /** + * @return int + */ + public function getWorksheetNumber() + { + return $this->worksheetNumber; + } +} diff --git a/src/Spout/Reader/ReaderFactory.php b/src/Spout/Reader/ReaderFactory.php new file mode 100644 index 0000000..9766978 --- /dev/null +++ b/src/Spout/Reader/ReaderFactory.php @@ -0,0 +1,44 @@ +setGlobalFunctionsHelper(new GlobalFunctionsHelper()); + + return $reader; + } +} diff --git a/src/Spout/Reader/ReaderInterface.php b/src/Spout/Reader/ReaderInterface.php new file mode 100644 index 0000000..7253821 --- /dev/null +++ b/src/Spout/Reader/ReaderInterface.php @@ -0,0 +1,50 @@ +tempFolder = $tempFolder; + return $this; + } + + /** + * Opens the file at the given file path to make it ready to be read. + * It also parses the sharedStrings.xml file to get all the shared strings available in memory + * and fetches all the available worksheets. + * + * @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 Exception\NoWorksheetsFoundException If there are no worksheets in the file + */ + protected function openReader($filePath) + { + $this->filePath = $filePath; + $this->zip = new \ZipArchive(); + + if ($this->zip->open($filePath) === true) { + // Extracts all the strings from the worksheets for easy access in the future + $this->extractSharedStrings($filePath); + + // Fetch all available worksheets + $this->worksheetHelper = new WorksheetHelper($filePath); + $this->worksheets = $this->worksheetHelper->getWorksheets($filePath); + + if (count($this->worksheets) === 0) { + throw new NoWorksheetsFoundException('The file must contain at least one worksheet.'); + } + } else { + throw new IOException('Could not open ' . $filePath . ' for reading.'); + } + } + + /** + * Builds an in-memory array containing all the shared strings of the worksheets. + * + * @param string $filePath Path of the XLSX file to be read + * @return void + * @throws \Box\Spout\Common\Exception\IOException If sharedStrings XML file can't be read + */ + protected function extractSharedStrings($filePath) + { + $this->sharedStringsHelper = new SharedStringsHelper($filePath, $this->tempFolder); + $this->sharedStringsHelper->extractSharedStrings(); + } + + /** + * Returns whether another worksheet exists after the current worksheet. + * + * @return bool Whether another worksheet exists after the current worksheet. + * @throws Exception\ReaderNotOpenedException If the stream was not opened first + */ + public function hasNextSheet() + { + if (!$this->isStreamOpened) { + throw new ReaderNotOpenedException('Stream should be opened first.'); + } + + return $this->worksheetHelper->hasNextWorksheet($this->currentWorksheet, $this->worksheets); + } + + /** + * Moves the pointer to the current worksheet. + * Moving to another worksheet will stop the reading in the current worksheet. + * + * @return void + * @throws Exception\ReaderNotOpenedException If the stream was not opened first + * @throws Exception\EndOfWorksheetsReachedException If there is no more worksheets to read + */ + public function nextSheet() + { + if ($this->hasNextSheet()) { + if ($this->currentWorksheet === null) { + $nextWorksheet = $this->worksheets[0]; + } else { + $currentWorksheetNumber = $this->currentWorksheet->getWorksheetNumber(); + $nextWorksheet = $this->worksheets[$currentWorksheetNumber + 1]; + } + + $this->initXmlReaderForWorksheetData($nextWorksheet); + $this->currentWorksheet = $nextWorksheet; + + // make sure that we are ready to read more rows + $this->hasReachedEndOfFile = false; + $this->emptyRowDataBuffer(); + } else { + throw new EndOfWorksheetsReachedException('End of worksheets was reached. Cannot read more worksheets.'); + } + } + + /** + * Initializes the XMLReader object that reads worksheet data for the given worksheet. + * If another worksheet was being read, it closes the reader before reopening it for the new worksheet. + * The XMLReader is configured to be safe from billion laughs attack. + * + * @param Internal\XLSX\Worksheet $worksheet The worksheet to initialize the XMLReader with + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the worksheet data XML cannot be read + */ + protected function initXmlReaderForWorksheetData($worksheet) + { + // if changing worksheet and the XMLReader was initialized for the current worksheet + if ($worksheet != $this->currentWorksheet && $this->xmlReader) { + $this->xmlReader->close(); + } else if (!$this->xmlReader) { + $this->xmlReader = new \XMLReader(); + } + + $worksheetDataXMLFilePath = $worksheet->getDataXmlFilePath(); + + $worksheetDataFilePath = 'zip://' . $this->filePath . '#' . $worksheetDataXMLFilePath; + if ($this->xmlReader->open($worksheetDataFilePath, null, LIBXML_NONET) === false) { + throw new IOException('Could not open "' . $worksheetDataXMLFilePath . '".'); + } + } + + /** + * Reads and returns data of the line that comes after the last read line, on the current worksheet. + * Empty rows will be skipped. + * + * @return array|null Array that contains the data for the read line or null at the end of the file + * @throws \Box\Spout\Common\Exception\BadUsageException If the pointer to the current worksheet has not been set + * @throws \Box\Spout\Reader\Exception\SharedStringNotFoundException If a shared string was not found + */ + protected function read() + { + if (!$this->currentWorksheet) { + throw new BadUsageException('You must call nextSheet() before calling hasNextRow() or nextRow()'); + } + + $isInsideRowTag = false; + $rowData = []; + + while ($this->xmlReader->read()) { + if ($this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'dimension') { + // Read dimensions of the worksheet + $dimensionRef = $this->xmlReader->getAttribute('ref'); // returns 'A1:M13' for instance + list(, $lastCellIndex) = explode(':', $dimensionRef); + $this->numberOfColumns = CellHelper::getColumnIndexFromCellIndex($lastCellIndex) + 1; + } else if ($this->xmlReader->nodeType == \XMLReader::ELEMENT && $this->xmlReader->name === 'row') { + // Start of the row description + $isInsideRowTag = true; + + // Read spans info if present + $numberOfColumnsForRow = $this->numberOfColumns; + $spans = $this->xmlReader->getAttribute('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 === 'c') { + // Start of a cell description + $currentCellIndex = $this->xmlReader->getAttribute('r'); + $currentColumnIndex = CellHelper::getColumnIndexFromCellIndex($currentCellIndex); + $node = $this->xmlReader->expand(); + + $hasSharedString = ($this->xmlReader->getAttribute('t') === 's'); + if ($hasSharedString) { + $sharedStringIndex = intval($node->nodeValue); + $rowData[$currentColumnIndex] = $this->sharedStringsHelper->getStringAtIndex($sharedStringIndex); + } else { + // for inline strings or numbers, just get the value + $rowData[$currentColumnIndex] = trim($node->nodeValue); + } + } else if ($this->xmlReader->nodeType == \XMLReader::END_ELEMENT && $this->xmlReader->name === 'row') { + // End of the row description + // If needed, we fill the empty cells + $rowData = ($this->numberOfColumns !== 0) ? $rowData : CellHelper::fillMissingArrayIndexes($rowData); + break; + } + } + + // no data means "end of file" + return ($rowData !== []) ? $rowData : null; + } + + /** + * Closes the reader. To be used after reading the file. + * + * @return void + */ + protected function closeReader() + { + if ($this->xmlReader) { + $this->xmlReader->close(); + } + + if ($this->zip) { + $this->zip->close(); + } + + $this->sharedStringsHelper->cleanup(); + } +} diff --git a/src/Spout/Writer/AbstractWriter.php b/src/Spout/Writer/AbstractWriter.php new file mode 100644 index 0000000..0e9e883 --- /dev/null +++ b/src/Spout/Writer/AbstractWriter.php @@ -0,0 +1,198 @@ +globalFunctionsHelper = $globalFunctionsHelper; + return $this; + } + + /** + * Inits the writer and opens it to accept data. + * By using this method, the data will be written to a file. + * + * @param string $outputFilePath Path of the output file that will contain the data + * @return \Box\Spout\Writer\AbstractWriter + * @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened or if the given path is not writable + */ + public function openToFile($outputFilePath) + { + $this->outputFilePath = $outputFilePath; + + $this->filePointer = $this->globalFunctionsHelper->fopen($this->outputFilePath, 'wb+'); + $this->throwIfFilePointerIsNotAvailable(); + + $this->openWriter(); + $this->isWriterOpened = true; + + return $this; + } + + /** + * Inits the writer and opens it to accept data. + * By using this method, the data will be outputted directly to the browser. + * + * @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 \Box\Spout\Writer\AbstractWriter + * @throws \Box\Spout\Common\Exception\IOException If the writer cannot be opened + */ + public function openToBrowser($outputFileName) + { + $this->outputFilePath = $this->globalFunctionsHelper->basename($outputFileName); + + $this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w'); + $this->throwIfFilePointerIsNotAvailable(); + + // Set headers + $this->globalFunctionsHelper->header('Content-Type: ' . static::$headerContentType); + $this->globalFunctionsHelper->header('Content-Disposition: attachment; filename="' . $this->outputFilePath . '"'); + + /* + * When forcing the download of a file over SSL,IE8 and lower browsers fail + * if the Cache-Control and Pragma headers are not set. + * + * @see http://support.microsoft.com/KB/323308 + * @see https://github.com/liuggio/ExcelBundle/issues/45 + */ + $this->globalFunctionsHelper->header('Cache-Control: max-age=0'); + $this->globalFunctionsHelper->header('Pragma: public'); + + $this->openWriter(); + $this->isWriterOpened = true; + + return $this; + } + + /** + * Checks if the pointer to the file/stream to write to is available. + * Will throw an exception if not available. + * + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the pointer is not available + */ + protected function throwIfFilePointerIsNotAvailable() + { + if (!$this->filePointer) { + throw new IOException('File pointer has not be opened'); + } + } + + /** + * Write given data to the output. New data will be appended to end of stream. + * + * @param array $dataRow Array containing data to be streamed. + * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * + * @return \Box\Spout\Writer\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 + */ + public function addRow(array $dataRow) + { + if ($this->isWriterOpened) { + $this->addRowToWriter($dataRow); + } else { + throw new WriterNotOpenedException('The writer needs to be opened before adding row.'); + } + + return $this; + } + + /** + * Write given data to the output. New data will be appended to end of stream. + * + * @param array $dataRows Array of array containing data to be streamed. + * Example $dataRow = [ + * ['data11', 12, , '', 'data13'], + * ['data21', 'data22', null], + * ]; + * + * @return \Box\Spout\Writer\AbstractWriter + * @throws \Box\Spout\Common\Exception\InvalidArgumentException If the input param is not valid + * @throws \Box\Spout\Writer\Exception\WriterNotOpenedException If this function is called before opening the writer + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + public function addRows(array $dataRows) + { + if (!empty($dataRows)) { + if (!is_array($dataRows[0])) { + throw new InvalidArgumentException('The input should be an array of arrays'); + } + + foreach ($dataRows as $dataRow) { + $this->addRow($dataRow); + } + } + + return $this; + } + + /** + * Closes the writer. This will close the streamer as well, preventing new data + * to be written to the file. + * + * @return void + */ + public function close() + { + $this->closeWriter(); + $this->isWriterOpened = false; + } +} + diff --git a/src/Spout/Writer/CSV.php b/src/Spout/Writer/CSV.php new file mode 100644 index 0000000..0ac8d91 --- /dev/null +++ b/src/Spout/Writer/CSV.php @@ -0,0 +1,101 @@ +fieldDelimiter = $fieldDelimiter; + return $this; + } + + /** + * Sets the field enclosure for the CSV + * + * @param string $fieldEnclosure Character that enclose fields + * @return CSV + */ + public function setFieldEnclosure($fieldEnclosure) + { + $this->fieldEnclosure = $fieldEnclosure; + return $this; + } + + /** + * Opens the CSV streamer and makes it ready to accept data. + * + * @return void + */ + protected function openWriter() + { + // Adds UTF-8 BOM for Unicode compatibility + $this->globalFunctionsHelper->fputs($this->filePointer, self::UTF8_BOM); + } + + /** + * Adds data to the currently opened writer. + * + * @param array $dataRow Array containing data to be written. + * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @return void + * @throws \Box\Spout\Common\Exception\IOException If unable to write data + */ + protected function addRowToWriter(array $dataRow) + { + $wasWriteSuccessful = fputcsv($this->filePointer, $dataRow, $this->fieldDelimiter, $this->fieldEnclosure); + if ($wasWriteSuccessful === false) { + throw new IOException('Unable to write data'); + } + + $this->lastWrittenRowIndex++; + if ($this->lastWrittenRowIndex % self::FLUSH_THRESHOLD === 0) { + $this->globalFunctionsHelper->fflush($this->filePointer); + } + } + + /** + * Closes the CSV streamer, preventing any additional writing. + * If set, sets the headers and redirects output to the browser. + * + * @return void + */ + protected function closeWriter() + { + if ($this->filePointer) { + $this->globalFunctionsHelper->fclose($this->filePointer); + } + + $this->lastWrittenRowIndex = 0; + } +} diff --git a/src/Spout/Writer/Exception/SheetNotFoundException.php b/src/Spout/Writer/Exception/SheetNotFoundException.php new file mode 100644 index 0000000..c28ac9e --- /dev/null +++ b/src/Spout/Writer/Exception/SheetNotFoundException.php @@ -0,0 +1,12 @@ += 0); + + return $cellIndex; + } +} diff --git a/src/Spout/Writer/Helper/XLSX/FileSystemHelper.php b/src/Spout/Writer/Helper/XLSX/FileSystemHelper.php new file mode 100644 index 0000000..fddbb25 --- /dev/null +++ b/src/Spout/Writer/Helper/XLSX/FileSystemHelper.php @@ -0,0 +1,378 @@ +rootFolder; + } + + /** + * @return string + */ + public function getXlFolder() + { + return $this->xlFolder; + } + + /** + * @return string + */ + public function getXlWorksheetsFolder() + { + return $this->xlWorksheetsFolder; + } + + /** + * Creates all the folders needed to create a XLSX file, as well as the files that won't change. + * + * @return void + * @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the base folders + */ + public function createBaseFilesAndFolders() + { + $this + ->createRootFolder() + ->createRelsFolderAndFile() + ->createDocPropsFolderAndFiles() + ->createXlFolderAndSubFolders(); + } + + /** + * Creates the folder that will be used as root + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder + */ + protected function createRootFolder() + { + $this->rootFolder = $this->createFolder($this->baseFolderPath, uniqid('xlsx')); + return $this; + } + + /** + * Creates the "_rels" folder under the root folder as well as the ".rels" file in it + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or the ".rels" file + */ + protected function createRelsFolderAndFile() + { + $this->relsFolder = $this->createFolder($this->rootFolder, self::RELS_FOLDER_NAME); + + $this->createRelsFile(); + + return $this; + } + + /** + * Creates the ".rels" file under the "_rels" folder (under root) + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the file + */ + protected function createRelsFile() + { + $relsFileContents = << + + + + + +EOD; + + $this->createFileWithContents($this->relsFolder, self::RELS_FILE_NAME, $relsFileContents); + + return $this; + } + + /** + * Creates the "docProps" folder under the root folder as well as the "app.xml" and "core.xml" files in it + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder or one of the files + */ + protected function createDocPropsFolderAndFiles() + { + $this->docPropsFolder = $this->createFolder($this->rootFolder, self::DOC_PROPS_FOLDER_NAME); + + $this->createAppXmlFile(); + $this->createCoreXmlFile(); + + return $this; + } + + /** + * Creates the "app.xml" file under the "docProps" folder + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the file + */ + protected function createAppXmlFile() + { + $appName = self::APP_NAME; + $appXmlFileContents = << + + $appName + 0 + +EOD; + + $this->createFileWithContents($this->docPropsFolder, self::APP_XML_FILE_NAME, $appXmlFileContents); + + return $this; + } + + /** + * Creates the "core.xml" file under the "docProps" folder + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the file + */ + protected function createCoreXmlFile() + { + $createdDate = (new \DateTime())->format('c'); + $coreXmlFileContents = << + + $createdDate + $createdDate + 0 + +EOD; + + $this->createFileWithContents($this->docPropsFolder, self::CORE_XML_FILE_NAME, $coreXmlFileContents); + + return $this; + } + + /** + * Creates the "xl" folder under the root folder as well as its subfolders + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create at least one of the folders + */ + protected function createXlFolderAndSubFolders() + { + $this->xlFolder = $this->createFolder($this->rootFolder, self::XL_FOLDER_NAME); + $this->createXlRelsFolder(); + $this->createXlWorksheetsFolder(); + + return $this; + } + + /** + * Creates the "_rels" folder under the "xl" folder + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder + */ + protected function createXlRelsFolder() + { + $this->xlRelsFolder = $this->createFolder($this->xlFolder, self::RELS_FOLDER_NAME); + return $this; + } + + /** + * Creates the "worksheets" folder under the "xl" folder + * + * @return FileSystemHelper + * @throws \Box\Spout\Common\Exception\IOException If unable to create the folder + */ + protected function createXlWorksheetsFolder() + { + $this->xlWorksheetsFolder = $this->createFolder($this->xlFolder, self::WORKSHEETS_FOLDER_NAME); + return $this; + } + + /** + * Creates the "[Content_Types].xml" file under the root folder + * + * @param Worksheet[] $worksheets + * @return FileSystemHelper + */ + public function createContentTypesFile($worksheets) + { + $contentTypesXmlFileContents = << + + + + + +EOD; + + /** @var Worksheet $worksheet */ + foreach ($worksheets as $worksheet) { + $contentTypesXmlFileContents .= ' ' . PHP_EOL; + } + + $contentTypesXmlFileContents .= << + + + +EOD; + + $this->createFileWithContents($this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME, $contentTypesXmlFileContents); + + return $this; + } + + /** + * Creates the "workbook.xml" file under the "xl" folder + * + * @param Worksheet[] $worksheets + * @return FileSystemHelper + */ + public function createWorkbookFile($worksheets) + { + $workbookXmlFileContents = << + + + +EOD; + + $escaper = new \Box\Spout\Common\Escaper\XLSX(); + + /** @var Worksheet $worksheet */ + foreach ($worksheets as $worksheet) { + $worksheetName = $worksheet->getExternalSheet()->getName(); + $worksheetId = $worksheet->getId(); + $workbookXmlFileContents .= ' ' . PHP_EOL; + } + + $workbookXmlFileContents .= << + +EOD; + + $this->createFileWithContents($this->xlFolder, self::WORKBOOK_XML_FILE_NAME, $workbookXmlFileContents); + + return $this; + } + + /** + * Creates the "workbook.xml.res" file under the "xl/_res" folder + * + * @param Worksheet[] $worksheets + * @return FileSystemHelper + */ + public function createWorkbookRelsFile($worksheets) + { + $workbookRelsXmlFileContents = << + + + +EOD; + + /** @var Worksheet $worksheet */ + foreach ($worksheets as $worksheet) { + $worksheetId = $worksheet->getId(); + $workbookRelsXmlFileContents .= ' ' . PHP_EOL; + } + + $workbookRelsXmlFileContents .= ''; + + $this->createFileWithContents($this->xlRelsFolder, self::WORKBOOK_RELS_XML_FILE_NAME, $workbookRelsXmlFileContents); + + 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) + { + $this->zipRootFolder(); + $this->copyZipToStream($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); + } +} diff --git a/src/Spout/Writer/Helper/XLSX/SharedStringsHelper.php b/src/Spout/Writer/Helper/XLSX/SharedStringsHelper.php new file mode 100644 index 0000000..c0701e0 --- /dev/null +++ b/src/Spout/Writer/Helper/XLSX/SharedStringsHelper.php @@ -0,0 +1,99 @@ + +sharedStringsFilePointer = fopen($sharedStringsFilePath, 'w'); + + $this->throwIfSharedStringsFilePointerIsNotAvailable(); + + // the headers is split into different parts so that we can fseek and put in the correct count and uniqueCount later + $header = self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER . ' ' . self::DEFAULT_STRINGS_COUNT_PART . '>' . PHP_EOL; + fwrite($this->sharedStringsFilePointer, $header); + + $this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX(); + } + + /** + * Checks if the book has been created. Throws an exception if not created yet. + * + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing + */ + protected function throwIfSharedStringsFilePointerIsNotAvailable() + { + if (!$this->sharedStringsFilePointer) { + throw new IOException('Unable to open shared strings file for writing.'); + } + } + + /** + * Writes the given string into the sharedStrings.xml file. + * Starting and ending whitespaces are preserved. + * + * @param string $string + * @return int ID of the written shared string + */ + public function writeString($string) + { + fwrite($this->sharedStringsFilePointer, ' ' . $this->stringsEscaper->escape($string) . '' . PHP_EOL); + $this->numSharedStrings++; + + // Shared string ID is zero-based + return ($this->numSharedStrings - 1); + } + + /** + * Finishes writing the data in the sharedStrings.xml file and closes the file. + * + * @return void + */ + public function close() + { + fwrite($this->sharedStringsFilePointer, ''); + + // Replace the default strings count with the actual number of shared strings in the file header + $firstPartHeaderLength = strlen(self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER); + $defaultStringsCountPartLength = strlen(self::DEFAULT_STRINGS_COUNT_PART); + + // Adding 1 to take into account the space between the last xml attribute and "count" + fseek($this->sharedStringsFilePointer, $firstPartHeaderLength + 1); + fwrite($this->sharedStringsFilePointer, sprintf("%-{$defaultStringsCountPartLength}s", 'count="' . $this->numSharedStrings . '" uniqueCount="' . $this->numSharedStrings . '"')); + + fclose($this->sharedStringsFilePointer); + } +} diff --git a/src/Spout/Writer/Helper/XLSX/ZipHelper.php b/src/Spout/Writer/Helper/XLSX/ZipHelper.php new file mode 100644 index 0000000..e630d77 --- /dev/null +++ b/src/Spout/Writer/Helper/XLSX/ZipHelper.php @@ -0,0 +1,50 @@ +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 = realpath($folderPath) . DIRECTORY_SEPARATOR; + $itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST); + + foreach ($itemIterator as $itemInfo) { + $itemRealPath = realpath($itemInfo->getPathname()); + $itemLocalPath = str_replace($folderRealPath, '', $itemRealPath); + + if ($itemInfo->isFile()) { + $zip->addFile($itemInfo->getPathname(), $itemLocalPath); + } else if ($itemInfo->isDir()) { + $zip->addEmptyDir($itemLocalPath); + } + } + } +} diff --git a/src/Spout/Writer/Internal/XLSX/Workbook.php b/src/Spout/Writer/Internal/XLSX/Workbook.php new file mode 100644 index 0000000..3f8cca4 --- /dev/null +++ b/src/Spout/Writer/Internal/XLSX/Workbook.php @@ -0,0 +1,236 @@ +shouldUseInlineStrings = $shouldUseInlineStrings; + $this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically; + + $this->fileSystemHelper = new FileSystemHelper($tempFolder); + $this->fileSystemHelper->createBaseFilesAndFolders(); + + // This helper will be shared by all sheets + $xlFolder = $this->fileSystemHelper->getXlFolder(); + $this->sharedStringsHelper = new SharedStringsHelper($xlFolder); + } + + /** + * 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() + { + $newSheetNumber = count($this->worksheets); + $sheet = new Sheet($newSheetNumber); + + $worksheetFilesFolder = $this->fileSystemHelper->getXlWorksheetsFolder(); + $worksheet = new Worksheet($sheet, $worksheetFilesFolder, $this->sharedStringsHelper, $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\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\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']; + * @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) + { + $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(); + $currentWorksheet->addRow($dataRow); + } else { + // otherwise, do nothing as the data won't be read anyways + } + } else { + $currentWorksheet->addRow($dataRow); + } + } + + /** + * @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. + * All the temporary files are then deleted. + * + * @param resource $finalFilePointer Pointer to the XLSX that will be created + * @return void + */ + public function close($finalFilePointer) + { + foreach ($this->worksheets as $worksheet) { + $worksheet->close(); + } + + $this->sharedStringsHelper->close(); + + // Finish creating all the necessary files before zipping everything together + $this->fileSystemHelper + ->createContentTypesFile($this->worksheets) + ->createWorkbookFile($this->worksheets) + ->createWorkbookRelsFile($this->worksheets) + ->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); + } +} diff --git a/src/Spout/Writer/Internal/XLSX/Worksheet.php b/src/Spout/Writer/Internal/XLSX/Worksheet.php new file mode 100644 index 0000000..326c41b --- /dev/null +++ b/src/Spout/Writer/Internal/XLSX/Worksheet.php @@ -0,0 +1,174 @@ + + +EOD; + + /** @var \Box\Spout\Writer\Sheet The "external" sheet */ + protected $externalSheet; + + /** @var string Path to the XML file that will contain the sheet data */ + protected $worksheetFilePath; + + /** @var \Box\Spout\Writer\Helper\XLSX\SharedStringsHelper Helper to write shared strings */ + protected $sharedStringsHelper; + + /** @var bool Whether inline or shared strings should be used */ + protected $shouldUseInlineStrings; + + /** @var \Box\Spout\Common\Escaper\XLSX Strings escaper */ + protected $stringsEscaper; + + /** @var Resource Pointer to the sheet data file (e.g. xl/worksheets/sheet1.xml) */ + protected $sheetFilePointer; + + /** @var int */ + protected $lastWrittenRowIndex = 0; + + /** + * @param \Box\Spout\Writer\Sheet $externalSheet The associated "external" sheet + * @param string $tempFolder Temporary folder where the files to create the XLSX will be stored + * @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) + { + $this->externalSheet = $externalSheet; + $this->sharedStringsHelper = $sharedStringsHelper; + $this->shouldUseInlineStrings = $shouldUseInlineStrings; + + $this->stringsEscaper = new \Box\Spout\Common\Escaper\XLSX(); + + $this->worksheetFilePath = $worksheetFilesFolder . DIRECTORY_SEPARATOR . strtolower($this->externalSheet->getName()) . '.xml'; + $this->startSheet(); + } + + /** + * Prepares the worksheet to accept data + * + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the sheet data file cannot be opened for writing + */ + protected function startSheet() + { + $this->sheetFilePointer = fopen($this->worksheetFilePath, 'w'); + $this->throwIfSheetFilePointerIsNotAvailable(); + + fwrite($this->sheetFilePointer, self::SHEET_XML_FILE_HEADER . PHP_EOL); + fwrite($this->sheetFilePointer, ' ' . PHP_EOL); + } + + /** + * @return \Box\Spout\Writer\Sheet The "external" sheet + */ + public function getExternalSheet() + { + return $this->externalSheet; + } + + /** + * @return int The index of the last written row + */ + public function getLastWrittenRowIndex() + { + return $this->lastWrittenRowIndex; + } + + /** + * @return int The ID of the worksheet + */ + public function getId() + { + // sheet number is zero-based, while ID is 1-based + return $this->externalSheet->getSheetNumber() + 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. + * Example $dataRow = ['data1', 1234, null, '', 'data5']; + * @return void + * @throws \Box\Spout\Common\Exception\IOException If the data cannot be written + */ + public function addRow($dataRow) + { + $cellNumber = 0; + $rowIndex = $this->lastWrittenRowIndex + 1; + $numCells = count($dataRow); + + $data = ' ' . PHP_EOL; + + foreach($dataRow as $cellValue) { + $columnIndex = CellHelper::getCellIndexFromColumnIndex($cellNumber); + $data .= ' ' . PHP_EOL; + } else { + if ($this->shouldUseInlineStrings) { + $data .= ' t="inlineStr">' . $this->stringsEscaper->escape($cellValue) . '' . PHP_EOL; + } else { + $sharedStringId = $this->sharedStringsHelper->writeString($cellValue); + $data .= ' t="s">' . $sharedStringId . '' . PHP_EOL; + } + } + } + + $cellNumber++; + } + + $data .= ' ' . PHP_EOL; + + $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++; + } + + /** + * Closes the worksheet + * + * @return void + */ + public function close() + { + fwrite($this->sheetFilePointer, ' ' . PHP_EOL); + fwrite($this->sheetFilePointer, ''); + fclose($this->sheetFilePointer); + } +} diff --git a/src/Spout/Writer/Sheet.php b/src/Spout/Writer/Sheet.php new file mode 100644 index 0000000..fd86c18 --- /dev/null +++ b/src/Spout/Writer/Sheet.php @@ -0,0 +1,45 @@ +sheetNumber = $sheetNumber; + $this->name = self::DEFAULT_SHEET_NAME_PREFIX . ($sheetNumber + 1); + } + + /** + * @return int Number of the sheet, based on order of creation (zero-based) + */ + public function getSheetNumber() + { + return $this->sheetNumber; + } + + /** + * @return string Name of the sheet + */ + public function getName() + { + return $this->name; + } +} diff --git a/src/Spout/Writer/WriterFactory.php b/src/Spout/Writer/WriterFactory.php new file mode 100644 index 0000000..321f0f1 --- /dev/null +++ b/src/Spout/Writer/WriterFactory.php @@ -0,0 +1,44 @@ +setGlobalFunctionsHelper(new GlobalFunctionsHelper()); + + return $writer; + } +} diff --git a/src/Spout/Writer/WriterInterface.php b/src/Spout/Writer/WriterInterface.php new file mode 100644 index 0000000..1324053 --- /dev/null +++ b/src/Spout/Writer/WriterInterface.php @@ -0,0 +1,65 @@ +tempFolder = $tempFolder; + return $this; + } + + /** + * Use inline string to be more memory efficient. If set to false, it will use shared strings. + * + * @param bool $shouldUseInlineStrings Whether inline or shared strings should be used + * @return XLSX + */ + public function setShouldUseInlineStrings($shouldUseInlineStrings) + { + $this->shouldUseInlineStrings = $shouldUseInlineStrings; + return $this; + } + + /** + * @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached + * @return XLSX + */ + public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically) + { + $this->shouldCreateNewSheetsAutomatically = $shouldCreateNewSheetsAutomatically; + return $this; + } + + /** + * Configures the write and sets the current sheet pointer to a new sheet. + * + * @return void + * @throws \Box\Spout\Common\Exception\IOException If unable to open the file for writing + */ + protected function openWriter() + { + if (!$this->book) { + $tempFolder = ($this->tempFolder) ? : sys_get_temp_dir(); + $this->book = new Workbook($tempFolder, $this->shouldUseInlineStrings, $this->shouldCreateNewSheetsAutomatically); + $this->book->addNewSheetAndMakeItCurrent(); + } + } + + /** + * 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 + */ + public function getSheets() + { + $this->throwIfBookIsNotAvailable(); + + $externalSheets = []; + $worksheets = $this->book->getWorksheets(); + + /** @var Internal\XLSX\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.'); + } + } + + /** + * 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']; + * @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) + { + $this->throwIfBookIsNotAvailable(); + $this->book->addRowToCurrentWorksheet($dataRow); + } + + /** + * Closes the writer, preventing any additional writing. + * + * @return void + */ + protected function closeWriter() + { + if ($this->book) { + $this->book->close($this->filePointer); + } + } +} diff --git a/tests/Spout/Common/Escaper/XLSXTest.php b/tests/Spout/Common/Escaper/XLSXTest.php new file mode 100644 index 0000000..ba3982a --- /dev/null +++ b/tests/Spout/Common/Escaper/XLSXTest.php @@ -0,0 +1,73 @@ +escape($stringToEscape); + + $this->assertEquals($expectedEscapedString, $escapedString, 'Incorrect escaped string'); + } + + /** + * @return array + */ + public function dataProviderForTestUnescape() + { + return [ + ['test', 'test'], + ['adam's "car"', 'adam\'s "car"'], + ['_x0000_', chr(0)], + ['_x005F_x0000_', '_x0000_'], + ['_x0015_', chr(21)], + ['control _x0015_ character', 'control '.chr(21).' character'], + ['control's _x0015_ "character"', 'control\'s '.chr(21).' "character"'], + ]; + } + + /** + * @dataProvider dataProviderForTestUnescape + * + * @param string $stringToUnescape + * @param string $expectedUnescapedString + * @return void + */ + public function testUnescape($stringToUnescape, $expectedUnescapedString) + { + $escaper = new \Box\Spout\Common\Escaper\XLSX(); + $unescapedString = $escaper->unescape($stringToUnescape); + + $this->assertEquals($expectedUnescapedString, $unescapedString, 'Incorrect escaped string'); + } +} diff --git a/tests/Spout/Common/Helper/FileSystemHelperTest.php b/tests/Spout/Common/Helper/FileSystemHelperTest.php new file mode 100644 index 0000000..a73e7f2 --- /dev/null +++ b/tests/Spout/Common/Helper/FileSystemHelperTest.php @@ -0,0 +1,59 @@ +fileSystemHelper = new FileSystemHelper($baseFolder); + } + + /** + * @expectedException \Box\Spout\Common\Exception\IOException + * @return void + */ + public function testCreateFolderShouldThrowExceptionIfOutsideOfBaseFolder() + { + $this->fileSystemHelper->createFolder('/tmp/folder_outside_base_folder', 'folder_name'); + } + + /** + * @expectedException \Box\Spout\Common\Exception\IOException + * @return void + */ + public function testCreateFileWithContentsShouldThrowExceptionIfOutsideOfBaseFolder() + { + $this->fileSystemHelper->createFileWithContents('/tmp/folder_outside_base_folder', 'file_name', 'contents'); + } + + /** + * @expectedException \Box\Spout\Common\Exception\IOException + * @return void + */ + public function testDeleteFileShouldThrowExceptionIfOutsideOfBaseFolder() + { + $this->fileSystemHelper->deleteFile('/tmp/folder_outside_base_folder/file_name'); + } + + /** + * @expectedException \Box\Spout\Common\Exception\IOException + * @return void + */ + public function testDeleteFolderRecursivelyShouldThrowExceptionIfOutsideOfBaseFolder() + { + $this->fileSystemHelper->deleteFolderRecursively('/tmp/folder_outside_base_folder'); + } +} diff --git a/tests/Spout/Reader/CSVTest.php b/tests/Spout/Reader/CSVTest.php new file mode 100644 index 0000000..3f78af2 --- /dev/null +++ b/tests/Spout/Reader/CSVTest.php @@ -0,0 +1,158 @@ +getAllRowsForFile('/path/to/fake/file.csv'); + } + + /** + * @return void + */ + public function testReadStandardCSV() + { + $allRows = $this->getAllRowsForFile('csv_standard.csv'); + + $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 testReadShouldNotStopAtCommaIfEnclosed() + { + $allRows = $this->getAllRowsForFile('csv_with_comma_enclosed.csv'); + $this->assertEquals('This is, a comma', $allRows[0][0]); + } + + /** + * @return void + */ + public function testReadShouldKeepEmptyCells() + { + $allRows = $this->getAllRowsForFile('csv_with_empty_cells.csv'); + + $expectedRows = [ + ['csv--11', 'csv--12', 'csv--13'], + ['csv--21', '', 'csv--23'], + ['csv--31', 'csv--32', ''], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSkipEmptyLines() + { + $allRows = $this->getAllRowsForFile('csv_with_empty_line.csv'); + + $expectedRows = [ + ['csv--11', 'csv--12', 'csv--13'], + ['csv--31', 'csv--32', 'csv--33'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldHaveTheRightNumberOfCells() + { + $allRows = $this->getAllRowsForFile('csv_with_different_cells_number.csv'); + + $expectedRows = [ + ['csv--11', 'csv--12', 'csv--13'], + ['csv--21', 'csv--22'], + ['csv--31'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSupportCustomFieldDelimiter() + { + $allRows = $this->getAllRowsForFile('csv_delimited_with_pipes.csv', '|'); + + $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 testReadShouldSupportCustomFieldEnclosure() + { + $allRows = $this->getAllRowsForFile('csv_text_enclosed_with_pound.csv', ',', '#'); + $this->assertEquals('This is, a comma', $allRows[0][0]); + } + + /** + * @return void + */ + public function testReadShouldSkipUtf8Bom() + { + $allRows = $this->getAllRowsForFile('csv_with_utf8_bom.csv'); + + $expectedRows = [ + ['csv--11', 'csv--12', 'csv--13'], + ['csv--21', 'csv--22', 'csv--23'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @param string $fileName + * @param string|void $fieldDelimiter + * @param string|void $fieldEnclosure + * @return array All the read rows the given file + */ + private function getAllRowsForFile($fileName, $fieldDelimiter = ",", $fieldEnclosure = '"') + { + $allRows = []; + $resourcePath = $this->getResourcePath($fileName); + + $reader = ReaderFactory::create(Type::CSV); + $reader->setFieldDelimiter($fieldDelimiter); + $reader->setFieldEnclosure($fieldEnclosure); + + $reader->open($resourcePath); + + while ($reader->hasNextRow()) { + $allRows[] = $reader->nextRow(); + } + + $reader->close(); + + return $allRows; + } +} diff --git a/tests/Spout/Reader/Helper/XLSX/SharedStringsHelperTest.php b/tests/Spout/Reader/Helper/XLSX/SharedStringsHelperTest.php new file mode 100644 index 0000000..110c04d --- /dev/null +++ b/tests/Spout/Reader/Helper/XLSX/SharedStringsHelperTest.php @@ -0,0 +1,99 @@ +getResourcePath('one_sheet_with_shared_strings.xlsx'); + $this->sharedStringsHelper = new SharedStringsHelper($resourcePath); + } + + /** + * @return void + */ + public function tearDown() + { + $this->sharedStringsHelper->cleanup(); + } + + /** + * @return void + */ + public function testExtractSharedStringsShouldCreateTempFileWithSharedStrings() + { + $this->sharedStringsHelper->extractSharedStrings(); + + $tempFolder = \ReflectionHelper::getValueOnObject($this->sharedStringsHelper, 'tempFolder'); + + $filesInTempFolder = $this->getFilesInFolder($tempFolder); + $this->assertEquals(1, count($filesInTempFolder), 'One temp file should have been created in the temp folder.'); + + $tempFileContents = file_get_contents($filesInTempFolder[0]); + $tempFileContentsPerLine = explode("\n", $tempFileContents); + + $this->assertEquals('s1--A1', $tempFileContentsPerLine[0]); + $this->assertEquals('s1--E5', $tempFileContentsPerLine[24]); + } + + /** + * Returns all files that are in the given folder. + * It does not include "." and ".." and is not recursive. + * + * @param string $folderPath + * @return array + */ + private function getFilesInFolder($folderPath) + { + $files = []; + $directoryIterator = new \DirectoryIterator($folderPath); + + foreach ($directoryIterator as $fileInfo) { + if ($fileInfo->isFile()) { + $files[] = $fileInfo->getPathname(); + } + } + + return $files; + } + + /** + * @expectedException \Box\Spout\Reader\Exception\SharedStringNotFoundException + * @return void + */ + public function testGetStringAtIndexShouldThrowExceptionIfStringNotFound() + { + $this->sharedStringsHelper->extractSharedStrings(); + $this->sharedStringsHelper->getStringAtIndex(PHP_INT_MAX); + } + + /** + * @return void + */ + public function testGetStringAtIndexShouldReturnTheCorrectStringIfFound() + { + $this->sharedStringsHelper->extractSharedStrings(); + + $sharedString = $this->sharedStringsHelper->getStringAtIndex(0); + $this->assertEquals('s1--A1', $sharedString); + + $sharedString = $this->sharedStringsHelper->getStringAtIndex(24); + $this->assertEquals('s1--E5', $sharedString); + } +} diff --git a/tests/Spout/Reader/XLSXTest.php b/tests/Spout/Reader/XLSXTest.php new file mode 100644 index 0000000..e1cf994 --- /dev/null +++ b/tests/Spout/Reader/XLSXTest.php @@ -0,0 +1,192 @@ +getAllRowsForFile($filePath); + } + + /** + * @return array + */ + public function dataProviderForTestReadForAllWorksheets() + { + return [ + ['one_sheet_with_shared_strings.xlsx', 5, 5], + ['one_sheet_with_inline_strings.xlsx', 5, 5], + ['two_sheets_with_shared_strings.xlsx', 10, 5], + ['two_sheets_with_inline_strings.xlsx', 10, 5] + ]; + } + + /** + * @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 testReadShouldKeepEmptyCellsAtTheEndIfDimensionsSpecified() + { + $allRows = $this->getAllRowsForFile('sheet_without_dimensions_but_spans_and_empty_cells.xlsx'); + + $this->assertEquals(2, count($allRows), 'There should be 2 rows'); + foreach ($allRows as $row) { + $this->assertEquals(5, count($row), 'There should be 5 cells for every row, because empty rows should be preserved'); + } + + $expectedRows = [ + ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + ['s1--A2', 's1--B2', 's1--C2', '', ''], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldKeepEmptyCellsAtTheEndIfNoDimensionsButSpansSpecified() + { + $allRows = $this->getAllRowsForFile('sheet_without_dimensions_and_empty_cells.xlsx'); + + $this->assertEquals(2, count($allRows), 'There should be 2 rows'); + $this->assertEquals(5, count($allRows[0]), 'There should be 5 cells in the first row'); + $this->assertEquals(3, count($allRows[1]), 'There should be only 3 cells in the second row, because empty rows at the end should be skip'); + + $expectedRows = [ + ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + ['s1--A2', 's1--B2', 's1--C2'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSkipEmptyCellsAtTheEndIfDimensionsNotSpecified() + { + $allRows = $this->getAllRowsForFile('sheet_without_dimensions_and_empty_cells.xlsx'); + + $this->assertEquals(2, count($allRows), 'There should be 2 rows'); + $this->assertEquals(5, count($allRows[0]), 'There should be 5 cells in the first row'); + $this->assertEquals(3, count($allRows[1]), 'There should be only 3 cells in the second row, because empty rows at the end should be skip'); + + $expectedRows = [ + ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + ['s1--A2', 's1--B2', 's1--C2'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSkipEmptyRows() + { + $allRows = $this->getAllRowsForFile('sheet_with_empty_rows.xlsx'); + + $this->assertEquals(2, count($allRows), 'There should be only 2 rows, because the empty row is skipped'); + + $expectedRows = [ + ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1'], + ['s1--A3', 's1--B3', 's1--C3', 's1--D3', 's1--E3'], + ]; + $this->assertEquals($expectedRows, $allRows); + } + + /** + * @return void + */ + public function testReadShouldSkipPronunciationData() + { + $allRows = $this->getAllRowsForFile('sheet_with_pronunciation.xlsx'); + + $expectedRow = ['名前', '一二三四']; + $this->assertEquals($expectedRow, $allRows[0], 'Pronunciation data should be removed.'); + } + + /** + * @return void + */ + public function testReadShouldBeProtectedAgainstBillionLaughsAttack() + { + $allRows = $this->getAllRowsForFile('billion_laughs_test_file.xlsx'); + + $expectedMaxMemoryUsage = 10 * 1024 * 1024; // 10MB + $this->assertLessThan($expectedMaxMemoryUsage, memory_get_peak_usage(true), 'Entities should not be expanded and therefore consume all the memory.'); + + $expectedFirstRow = ['s1--A1', 's1--B1', 's1--C1', 's1--D1', 's1--E1']; + $this->assertEquals($expectedFirstRow, $allRows[0], 'Entities should be ignored when reading XML files.'); + } + + /** + * @param string $fileName + * @return array All the read rows the given file + */ + private function getAllRowsForFile($fileName) + { + $allRows = []; + $resourcePath = $this->getResourcePath($fileName); + + $reader = ReaderFactory::create(Type::XLSX); + $reader->open($resourcePath); + + while ($reader->hasNextSheet()) { + $reader->nextSheet(); + + while ($reader->hasNextRow()) { + $allRows[] = $reader->nextRow(); + } + } + + $reader->close(); + + return $allRows; + } +} diff --git a/tests/Spout/ReflectionHelper.php b/tests/Spout/ReflectionHelper.php new file mode 100644 index 0000000..3fb78e4 --- /dev/null +++ b/tests/Spout/ReflectionHelper.php @@ -0,0 +1,92 @@ + $valueNames) { + foreach ($valueNames as $valueName => $originalValue) { + self::setStaticValue($class, $valueName, $originalValue, $saveOriginalValue = false); + } + } + self::$privateVarsToReset = array(); + } + + /** + * Get the value of a static private or public class property. + * Used to test internals of class without having to make the property public + * + * @param string $class + * @param string $valueName + * @return mixed|null + */ + public static function getStaticValue($class, $valueName) + { + $reflectionClass = new ReflectionClass($class); + $reflectionProperty = $reflectionClass->getProperty($valueName); + $reflectionProperty->setAccessible(true); + $value = $reflectionProperty->getValue(); + + // clean up + $reflectionProperty->setAccessible(false); + + return $value; + } + + /** + * Set the value of a static private or public class property. + * Used to test internals of class without having to make the property public + * + * @param string $class + * @param string $valueName + * @param mixed|null $value + * @param bool|void $saveOriginalValue + * @return void + */ + public static function setStaticValue($class, $valueName, $value, $saveOriginalValue = true) + { + $reflectionClass = new ReflectionClass($class); + $reflectionProperty = $reflectionClass->getProperty($valueName); + $reflectionProperty->setAccessible(true); + + // to prevent side-effects in later tests, we need to remember the original value and reset it on tear down + // @NOTE: we need to check isset in case the original value was null or array() + if ($saveOriginalValue && (!isset(self::$privateVarsToReset[$class]) || !isset(self::$privateVarsToReset[$class][$name]))) { + self::$privateVarsToReset[$class][$valueName] = $reflectionProperty->getValue(); + } + $reflectionProperty->setValue($value); + + // clean up + $reflectionProperty->setAccessible(false); + } + + /** + * @param object $object + * @param string $valueName + * + * @return mixed|null + */ + public static function getValueOnObject($object, $valueName) + { + $reflectionObject = new ReflectionObject($object); + $reflectionProperty = $reflectionObject->getProperty($valueName); + $reflectionProperty->setAccessible(true); + $value = $reflectionProperty->getValue($object); + + // clean up + $reflectionProperty->setAccessible(false); + + return $value; + } +} diff --git a/tests/Spout/TestUsingResource.php b/tests/Spout/TestUsingResource.php new file mode 100644 index 0000000..d072159 --- /dev/null +++ b/tests/Spout/TestUsingResource.php @@ -0,0 +1,83 @@ +resourcesPath . strtolower($resourceType) . '/' . $resourceName; + + return (file_exists($resourcePath) ? $resourcePath : null); + } + + /** + * @param string $resourceName + * @return string Path of the generated resource for the given name + */ + protected function getGeneratedResourcePath($resourceName) + { + $resourceType = pathinfo($resourceName, PATHINFO_EXTENSION); + $generatedResourcePath = $this->generatedResourcesPath . strtolower($resourceType) . '/' . $resourceName; + + return $generatedResourcePath; + } + + /** + * @param string $resourceName + * @return void + */ + protected function createGeneratedFolderIfNeeded($resourceName) + { + $resourceType = pathinfo($resourceName, PATHINFO_EXTENSION); + $generatedResourcePathForType = $this->generatedResourcesPath . strtolower($resourceType); + + if (!file_exists($generatedResourcePathForType)) { + mkdir($generatedResourcePathForType, 0777, true); + } + } + + /** + * @param string $resourceName + * @return string Path of the generated unwritable (because parent folder is read only) resource for the given name + */ + protected function getGeneratedUnwritableResourcePath($resourceName) + { + return $this->generatedUnwritableResourcesPath . $resourceName; + } + + /** + * @return void + */ + protected function createUnwritableFolderIfNeeded() + { + if (!file_exists($this->generatedUnwritableResourcesPath)) { + // Make sure generated folder exists first + if (!file_exists($this->generatedResourcesPath)) { + mkdir($this->generatedResourcesPath, 0777, true); + } + + // 0444 = read only + mkdir($this->generatedUnwritableResourcesPath, 0444, true); + } + } +} diff --git a/tests/Spout/Writer/CSVTest.php b/tests/Spout/Writer/CSVTest.php new file mode 100644 index 0000000..0d8478b --- /dev/null +++ b/tests/Spout/Writer/CSVTest.php @@ -0,0 +1,140 @@ +createUnwritableFolderIfNeeded($fileName); + $filePath = $this->getGeneratedUnwritableResourcePath($fileName); + + $writer = WriterFactory::create(Type::CSV); + @$writer->openToFile($filePath); + $writer->addRow(['csv--11', 'csv--12']); + $writer->close(); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testWriteShouldThrowExceptionIfCallAddRowBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::CSV); + $writer->addRow(['csv--11', 'csv--12']); + $writer->close(); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testWriteShouldThrowExceptionIfCallAddRowsBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::CSV); + $writer->addRows([['csv--11', 'csv--12']]); + $writer->close(); + } + + /** + * @return void + */ + public function testWriteShouldAddUtf8Bom() + { + $allRows = [ + ['csv--11', 'csv--12'], + ]; + $writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_with_utf8_bom.csv'); + + $this->assertContains(CSV::UTF8_BOM, $writtenContent, 'The CSV file should contain a UTF-8 BOM'); + } + + /** + * @return void + */ + public function testWriteShouldSupportNullValues() + { + $allRows = [ + ['csv--11', null, 'csv--13'], + ]; + $writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_with_null_values.csv'); + $writtenContent = $this->trimWrittenContent($writtenContent); + + $this->assertEquals('csv--11,,csv--13', $writtenContent, 'The null values should be replaced by empty values'); + } + + /** + * @return void + */ + public function testWriteShouldSupportCustomFieldDelimiter() + { + $allRows = [ + ['csv--11', 'csv--12', 'csv--13'], + ['csv--21', 'csv--22', 'csv--23'], + ]; + $writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_with_pipe_delimiters.csv', '|'); + $writtenContent = $this->trimWrittenContent($writtenContent); + + $this->assertEquals("csv--11|csv--12|csv--13\ncsv--21|csv--22|csv--23", $writtenContent, 'The fields should be delimited with |'); + } + + /** + * @return void + */ + public function testWriteShouldSupportCustomFieldEnclosure() + { + $allRows = [ + ['This is, a comma', 'csv--12', 'csv--13'], + ]; + $writtenContent = $this->writeToCsvFileAndReturnWrittenContent($allRows, 'csv_with_pound_enclosures.csv', ',', '#'); + $writtenContent = $this->trimWrittenContent($writtenContent); + + $this->assertEquals('#This is, a comma#,csv--12,csv--13', $writtenContent, 'The fields should be enclosed with #'); + } + + /** + * @param array $allRows + * @param string $fileName + * @param string $fieldDelimiter + * @param string $fieldEnclosure + * @return null|string + */ + private function writeToCsvFileAndReturnWrittenContent($allRows, $fileName, $fieldDelimiter = ',', $fieldEnclosure = '"') + { + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + $writer = WriterFactory::create(Type::CSV); + $writer->setFieldDelimiter($fieldDelimiter); + $writer->setFieldEnclosure($fieldEnclosure); + + $writer->openToFile($resourcePath); + $writer->addRows($allRows); + $writer->close(); + + return file_get_contents($resourcePath); + } + + /** + * @param string $writtenContent + * @return string + */ + private function trimWrittenContent($writtenContent) + { + // remove line feeds and UTF-8 BOM + return trim($writtenContent, "\n" . CSV::UTF8_BOM); + } +} diff --git a/tests/Spout/Writer/Helper/XLSX/CellHelperTest.php b/tests/Spout/Writer/Helper/XLSX/CellHelperTest.php new file mode 100644 index 0000000..b5edf4b --- /dev/null +++ b/tests/Spout/Writer/Helper/XLSX/CellHelperTest.php @@ -0,0 +1,37 @@ +assertEquals($expectedCellIndex, CellHelper::getCellIndexFromColumnIndex($columnIndex)); + } +} diff --git a/tests/Spout/Writer/SheetTest.php b/tests/Spout/Writer/SheetTest.php new file mode 100644 index 0000000..155c885 --- /dev/null +++ b/tests/Spout/Writer/SheetTest.php @@ -0,0 +1,62 @@ +writeDataAndReturnSheets('test_get_sheet_number.xlsx'); + + $this->assertEquals(2, count($sheets), '2 sheets should have been created'); + $this->assertEquals(0, $sheets[0]->getSheetNumber(), 'The first sheet should be number 0'); + $this->assertEquals(1, $sheets[1]->getSheetNumber(), 'The second sheet should be number 1'); + } + + /** + * @return void + */ + public function testGetSheetName() + { + $sheets = $this->writeDataAndReturnSheets('test_get_sheet_name.xlsx'); + + $this->assertEquals(2, count($sheets), '2 sheets should have been created'); + $this->assertEquals('Sheet1', $sheets[0]->getName(), 'Invalid name for the first sheet'); + $this->assertEquals('Sheet2', $sheets[1]->getName(), 'Invalid name for the second sheet'); + } + + /** + * @param string $fileName + * @return Sheet[] + */ + private function writeDataAndReturnSheets($fileName) + { + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->openToFile($resourcePath); + + $writer->addRow(['xlsx--sheet1--11', 'xlsx--sheet1--12']); + $writer->addNewSheetAndMakeItCurrent(); + $writer->addRow(['xlsx--sheet2--11', 'xlsx--sheet2--12', 'xlsx--sheet2--13']); + + $writer->close(); + + return $writer->getSheets(); + } +} diff --git a/tests/Spout/Writer/XLSXTest.php b/tests/Spout/Writer/XLSXTest.php new file mode 100644 index 0000000..b30d7de --- /dev/null +++ b/tests/Spout/Writer/XLSXTest.php @@ -0,0 +1,417 @@ +createUnwritableFolderIfNeeded($fileName); + $filePath = $this->getGeneratedUnwritableResourcePath($fileName); + + $writer = WriterFactory::create(Type::XLSX); + @$writer->openToFile($filePath); + $writer->addRow(['xlsx--11', 'xlsx--12']); + $writer->close(); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testAddRowShouldThrowExceptionIfCallAddRowBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::XLSX); + $writer->addRow(['xlsx--11', 'xlsx--12']); + $writer->close(); + } + + /** + * @expectedException \Box\Spout\Writer\Exception\WriterNotOpenedException + */ + public function testAddRowShouldThrowExceptionIfCallAddRowsBeforeOpeningWriter() + { + $writer = WriterFactory::create(Type::XLSX); + $writer->addRows([['xlsx--11', 'xlsx--12']]); + $writer->close(); + } + + /** + * @return void + */ + public function testAddNewSheetAndMakeItCurrent() + { + $fileName = 'test_add_new_sheet_and_make_it_current.xlsx'; + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + $writer = WriterFactory::create(Type::XLSX); + $writer->openToFile($resourcePath); + $writer->addNewSheetAndMakeItCurrent(); + $writer->close(); + + $sheets = $writer->getSheets(); + $this->assertEquals(2, count($sheets), 'There should be 2 sheets'); + $this->assertEquals($sheets[1], $writer->getCurrentSheet(), 'The current sheet should be the second one.'); + } + + /** + * @return void + */ + public function testSetCurrentSheet() + { + $fileName = 'test_set_current_sheet.xlsx'; + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + $writer = WriterFactory::create(Type::XLSX); + $writer->openToFile($resourcePath); + + $writer->addNewSheetAndMakeItCurrent(); + $writer->addNewSheetAndMakeItCurrent(); + + $firstSheet = $writer->getSheets()[0]; + $writer->setCurrentSheet($firstSheet); + + $writer->close(); + + $this->assertEquals($firstSheet, $writer->getCurrentSheet(), 'The current sheet should be the first one.'); + } + + /** + * @return void + */ + public function testAddRowShouldWriteGivenDataToSheetUsingInlineStrings() + { + $fileName = 'test_add_row_should_write_given_data_to_sheet.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ['xlsx--21', 'xlsx--22', 'xlsx--23'], + ]; + + $this->writeToXLSXFile($dataRows, $fileName, $shouldUseInlineStrings = true); + + foreach ($dataRows as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertInlineStringWasWrittenToSheet($fileName, 1, $cellValue); + } + } + } + + /** + * @return void + */ + public function testAddRowShouldWriteGivenDataToTwoSheetUsingInlineStrings() + { + $fileName = 'test_add_row_should_write_given_data_to_sheet.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ['xlsx--21', 'xlsx--22', 'xlsx--23'], + ]; + + $numSheets = 2; + $this->writeToMultipleSheetsInXLSXFile($dataRows, $numSheets, $fileName, $shouldUseInlineStrings = true); + + for ($i = 1; $i <= $numSheets; $i++) { + foreach ($dataRows as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertInlineStringWasWrittenToSheet($fileName, $numSheets, $cellValue); + } + } + } + } + + /** + * @return void + */ + public function testAddRowShouldWriteGivenDataToSheetUsingSharedStrings() + { + $fileName = 'test_add_row_should_write_given_data_to_sheet.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ['xlsx--21', 'xlsx--22', 'xlsx--23'], + ]; + + $this->writeToXLSXFile($dataRows, $fileName, $shouldUseInlineStrings = false); + + foreach ($dataRows as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertSharedStringWasWritten($fileName, $cellValue); + } + } + } + + /** + * @return void + */ + public function testAddRowShouldWriteGivenDataToTwoSheetUsingSharedStrings() + { + $fileName = 'test_add_row_should_write_given_data_to_two_sheet_using_shared_strings.xlsx'; + $dataRows = [ + ['xlsx--11', 'xlsx--12'], + ['xlsx--21', 'xlsx--22', 'xlsx--23'], + ]; + + $numSheets = 2; + $this->writeToMultipleSheetsInXLSXFile($dataRows, $numSheets, $fileName, $shouldUseInlineStrings = false); + + for ($i = 1; $i <= $numSheets; $i++) { + foreach ($dataRows as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertSharedStringWasWritten($fileName, $cellValue); + } + } + } + } + + /** + * @return void + */ + public function testAddRowShouldWriteGivenDataToTheCorrectSheet() + { + $fileName = 'test_add_row_should_write_given_data_to_the_correct_sheet.xlsx'; + $dataRowsSheet1 = [ + ['xlsx--sheet1--11', 'xlsx--sheet1--12'], + ['xlsx--sheet1--21', 'xlsx--sheet1--22', 'xlsx--sheet1--23'], + ]; + $dataRowsSheet2 = [ + ['xlsx--sheet2--11', 'xlsx--sheet2--12'], + ['xlsx--sheet2--21', 'xlsx--sheet2--22', 'xlsx--sheet2--23'], + ]; + $dataRowsSheet1Again = [ + ['xlsx--sheet1--31', 'xlsx--sheet1--32'], + ['xlsx--sheet1--41', 'xlsx--sheet1--42', 'xlsx--sheet1--43'], + ]; + + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->setShouldUseInlineStrings(true); + + $writer->openToFile($resourcePath); + + $writer->addRows($dataRowsSheet1); + + $writer->addNewSheetAndMakeItCurrent(); + $writer->addRows($dataRowsSheet2); + + $firstSheet = $writer->getSheets()[0]; + $writer->setCurrentSheet($firstSheet); + + $writer->addRows($dataRowsSheet1Again); + + $writer->close(); + + foreach ($dataRowsSheet1 as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertInlineStringWasWrittenToSheet($fileName, 1, $cellValue, 'Data should have been written in Sheet 1'); + } + } + foreach ($dataRowsSheet2 as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertInlineStringWasWrittenToSheet($fileName, 2, $cellValue, 'Data should have been written in Sheet 2'); + } + } + foreach ($dataRowsSheet1Again as $dataRow) { + foreach ($dataRow as $cellValue) { + $this->assertInlineStringWasWrittenToSheet($fileName, 1, $cellValue, 'Data should have been written in Sheet 1'); + } + } + } + + /** + * @return void + */ + public function testAddRowShouldAutomaticallyCreateNewSheetsIfMaxRowsReachedAndOptionTurnedOn() + { + $fileName = 'test_add_row_should_automatically_create_new_sheets_if_max_rows_reached_and_option_turned_on.xlsx'; + $dataRows = [ + ['xlsx--sheet1--11', 'xlsx--sheet1--12'], + ['xlsx--sheet1--21', 'xlsx--sheet1--22', 'xlsx--sheet1--23'], + ['xlsx--sheet2--11', 'xlsx--sheet2--12'], // this should be written in a new sheet + ]; + + // set the maxRowsPerSheet limit to 2 + \ReflectionHelper::setStaticValue('\Box\Spout\Writer\Internal\XLSX\Workbook', 'maxRowsPerWorksheet', 2); + + $writer = $this->writeToXLSXFile($dataRows, $fileName, true, $shouldCreateSheetsAutomatically = true); + $this->assertEquals(2, count($writer->getSheets()), '2 sheets should have been created.'); + + $this->assertInlineStringWasNotWrittenToSheet($fileName, 1, 'xlsx--sheet2--11'); + $this->assertInlineStringWasWrittenToSheet($fileName, 2, 'xlsx--sheet2--11'); + + \ReflectionHelper::reset(); + } + + /** + * @return void + */ + public function testAddRowShouldNotCreateNewSheetsIfMaxRowsReachedAndOptionTurnedOff() + { + $fileName = 'test_add_row_should_not_create_new_sheets_if_max_rows_reached_and_option_turned_off.xlsx'; + $dataRows = [ + ['xlsx--sheet1--11', 'xlsx--sheet1--12'], + ['xlsx--sheet1--21', 'xlsx--sheet1--22', 'xlsx--sheet1--23'], + ['xlsx--sheet1--31', 'xlsx--sheet1--32'], // this should NOT be written in a new sheet + ]; + + // set the maxRowsPerSheet limit to 2 + \ReflectionHelper::setStaticValue('\Box\Spout\Writer\Internal\XLSX\Workbook', 'maxRowsPerWorksheet', 2); + + $writer = $this->writeToXLSXFile($dataRows, $fileName, true, $shouldCreateSheetsAutomatically = false); + $this->assertEquals(1, count($writer->getSheets()), 'Only 1 sheet should have been created.'); + + $this->assertInlineStringWasNotWrittenToSheet($fileName, 1, 'xlsx--sheet1--31'); + + \ReflectionHelper::reset(); + } + + /** + * @return void + */ + public function testAddRowShouldEscapeHtmlSpecialCharacters() + { + $fileName = 'test_add_row_should_escape_html_special_characters.xlsx'; + $dataRows = [ + ['I\'m in "great" mood', 'This be escaped & tested'], + ]; + + $this->writeToXLSXFile($dataRows, $fileName); + + $this->assertInlineStringWasWrittenToSheet($fileName, 1, 'I'm in "great" mood', 'Quotes should be escaped'); + $this->assertInlineStringWasWrittenToSheet($fileName, 1, 'This <must> be escaped & tested', '<, > and & should be escaped'); + } + + /** + * @return void + */ + public function testAddRowShouldEscapeControlCharacters() + { + $fileName = 'test_add_row_should_escape_html_special_characters.xlsx'; + $dataRows = [ + ['control\'s '.chr(21).' "character"'], + ]; + + $this->writeToXLSXFile($dataRows, $fileName); + + $this->assertInlineStringWasWrittenToSheet($fileName, 1, 'control's _x0015_ "character"'); + } + + + /** + * @param array $allRows + * @param string $fileName + * @param bool $shouldUseInlineStrings + * @param bool $shouldCreateSheetsAutomatically + * @return XLSX + */ + private function writeToXLSXFile($allRows, $fileName, $shouldUseInlineStrings = true, $shouldCreateSheetsAutomatically = true) + { + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->setShouldUseInlineStrings($shouldUseInlineStrings); + $writer->setShouldCreateNewSheetsAutomatically($shouldCreateSheetsAutomatically); + + $writer->openToFile($resourcePath); + $writer->addRows($allRows); + $writer->close(); + + return $writer; + } + + /** + * @param array $allRows + * @param int $numSheets + * @param string $fileName + * @param bool $shouldUseInlineStrings + * @param bool $shouldCreateSheetsAutomatically + * @return XLSX + */ + private function writeToMultipleSheetsInXLSXFile($allRows, $numSheets, $fileName, $shouldUseInlineStrings = true, $shouldCreateSheetsAutomatically = true) + { + $this->createGeneratedFolderIfNeeded($fileName); + $resourcePath = $this->getGeneratedResourcePath($fileName); + + /** @var \Box\Spout\Writer\XLSX $writer */ + $writer = WriterFactory::create(Type::XLSX); + $writer->setShouldUseInlineStrings($shouldUseInlineStrings); + $writer->setShouldCreateNewSheetsAutomatically($shouldCreateSheetsAutomatically); + + $writer->openToFile($resourcePath); + $writer->addRows($allRows); + + for ($i = 1; $i < $numSheets; $i++) { + $writer->addNewSheetAndMakeItCurrent(); + $writer->addRows($allRows); + } + + $writer->close(); + + return $writer; + } + + /** + * @param string $fileName + * @param int $sheetNumber + * @param string $inlineString + * @param string $message + * @return void + */ + private function assertInlineStringWasWrittenToSheet($fileName, $sheetNumber, $inlineString, $message = '') + { + $resourcePath = $this->getGeneratedResourcePath($fileName); + $pathToSheetFile = $resourcePath . '#xl/worksheets/sheet' . $sheetNumber . '.xml'; + $xmlContents = file_get_contents('zip://' . $pathToSheetFile); + + $this->assertContains($inlineString, $xmlContents, $message); + } + + /** + * @param string $fileName + * @param int $sheetNumber + * @param string $inlineString + * @param string $message + * @return void + */ + private function assertInlineStringWasNotWrittenToSheet($fileName, $sheetNumber, $inlineString, $message = '') + { + $resourcePath = $this->getGeneratedResourcePath($fileName); + $pathToSheetFile = $resourcePath . '#xl/worksheets/sheet' . $sheetNumber . '.xml'; + $xmlContents = file_get_contents('zip://' . $pathToSheetFile); + + $this->assertNotContains($inlineString, $xmlContents, $message); + } + + /** + * @param string $fileName + * @param string $sharedString + * @param string $message + * @return void + */ + private function assertSharedStringWasWritten($fileName, $sharedString, $message = '') + { + $resourcePath = $this->getGeneratedResourcePath($fileName); + $pathToSharedStringsFile = $resourcePath . '#xl/sharedStrings.xml'; + $xmlContents = file_get_contents('zip://' . $pathToSharedStringsFile); + + $this->assertContains($sharedString, $xmlContents, $message); + } +} diff --git a/tests/bootstrap.php b/tests/bootstrap.php new file mode 100644 index 0000000..75bf44b --- /dev/null +++ b/tests/bootstrap.php @@ -0,0 +1,5 @@ +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 N2*?NsZZP{Q?!O=36<+`V literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/file_corrupted.xlsx b/tests/resources/xlsx/file_corrupted.xlsx new file mode 100644 index 0000000..552edd3 --- /dev/null +++ b/tests/resources/xlsx/file_corrupted.xlsx @@ -0,0 +1,3 @@ +csv--11,csv--12,csv--13 +csv--21,csv--22,csv--23 +csv--31,csv--32,csv--33 \ No newline at end of file diff --git a/tests/resources/xlsx/file_with_no_sheets_in_content_types.xlsx b/tests/resources/xlsx/file_with_no_sheets_in_content_types.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..597b23078fd27a6e807c3eff3dbdfa9f4c2d34d9 GIT binary patch literal 3720 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 F{tH}$XDt8# literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/one_sheet_with_inline_strings.xlsx b/tests/resources/xlsx/one_sheet_with_inline_strings.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a839e825c9804a3b3697c2887fd89e0f4a0e15f7 GIT binary patch literal 3737 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 Y|AqPKD$@^}5D)}p2IPYhwW)yq3-97@xc~qF literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/one_sheet_with_shared_strings.xlsx b/tests/resources/xlsx/one_sheet_with_shared_strings.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..b2797d405eb9e7769b5ebc924a1a8e28ebd308b4 GIT binary patch literal 3788 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 literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/sheet_with_dimensions_and_empty_cells.xlsx b/tests/resources/xlsx/sheet_with_dimensions_and_empty_cells.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..cc0dee874186426022c4cc00eb84322a8fbe1f58 GIT binary patch literal 3678 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 literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/sheet_with_pronunciation.xlsx b/tests/resources/xlsx/sheet_with_pronunciation.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9eb37176f2b3cd894e5a56c1e1b97b52ee6c6299 GIT binary patch literal 3725 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 literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/sheet_without_dimensions_and_empty_cells.xlsx b/tests/resources/xlsx/sheet_without_dimensions_and_empty_cells.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..9ab033d7fd350895fe5152f0433fe85c37a646b4 GIT binary patch literal 3662 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 literal 0 HcmV?d00001 diff --git a/tests/resources/xlsx/two_sheets_with_shared_strings.xlsx b/tests/resources/xlsx/two_sheets_with_shared_strings.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..487e4866bee6f03b928e0af80208975104ef6cd7 GIT binary patch literal 4340 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