diff --git a/upstream-3.x/LICENSE b/upstream-3.x/LICENSE
new file mode 100644
index 0000000..38ce746
--- /dev/null
+++ b/upstream-3.x/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2022 openspout
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/upstream-3.x/LICENSE-for-cc42c1d b/upstream-3.x/LICENSE-for-cc42c1d
new file mode 100644
index 0000000..167ec4d
--- /dev/null
+++ b/upstream-3.x/LICENSE-for-cc42c1d
@@ -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/upstream-3.x/README.md b/upstream-3.x/README.md
new file mode 100644
index 0000000..0ceb637
--- /dev/null
+++ b/upstream-3.x/README.md
@@ -0,0 +1,54 @@
+# OpenSpout
+
+[](https://packagist.org/packages/openspout/openspout)
+[](https://github.com/openspout/openspout/actions/workflows/ci.yml)
+[](https://codecov.io/gh/openspout/openspout?branch=main)
+[](https://packagist.org/packages/openspout/openspout)
+
+OpenSpout is a community driven fork of `box/spout`, a PHP library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way.
+Unlike other file readers or writers, it is capable of processing very large files, while keeping the memory usage really low (less than 3MB).
+
+## Documentation
+
+Documentation can be found at [https://openspout.readthedocs.io/en/latest/](https://openspout.readthedocs.io/en/latest/).
+
+## Requirements
+
+* PHP version 7.3 or higher
+* PHP extension `php_zip` enabled
+* PHP extension `php_xmlreader` enabled
+
+## Upgrade from `box/spout`
+
+1. Replace `box/spout` with `openspout/openspout` in your `composer.json`
+2. Replace `Box\Spout` with `OpenSpout` in your code
+
+## Upgrade guide
+
+Version 3 introduced new functionality but also some breaking changes. If you want to upgrade your Spout codebase from version 2 please consult the [Upgrade guide](UPGRADE-3.0.md).
+
+## Running tests
+
+The `main` branch includes unit, functional and performance tests.
+If you just want to check that everything is working as expected, executing the unit and functional tests is enough.
+
+* `phpunit` - runs unit and functional tests
+* `phpunit --group perf-tests` - only runs the performance tests
+
+For information, the performance tests take about 10 minutes to run (processing 1 million rows files is not a quick thing).
+
+> Performance tests status: [](https://travis-ci.org/box/spout)
+
+## Copyright and License
+
+This is a fork of Box's Spout library: https://github.com/box/spout
+
+Code until and directly descending from commit [`cc42c1d`](https://github.com/openspout/openspout/commit/cc42c1d29fc5d29f07caeace99bd29dbb6d7c2f8)
+is copyright of _Box, Inc._ and licensed under the Apache License, Version 2.0:
+
+https://github.com/openspout/openspout/blob/cc42c1d29fc5d29f07caeace99bd29dbb6d7c2f8/LICENSE
+
+Code created, edited and released after the commit mentioned above
+is copyright of _openspout_ Github organization and licensed under MIT License.
+
+https://github.com/openspout/openspout/blob/main/LICENSE
diff --git a/upstream-3.x/UPGRADE-3.0.md b/upstream-3.x/UPGRADE-3.0.md
new file mode 100644
index 0000000..8f6ae91
--- /dev/null
+++ b/upstream-3.x/UPGRADE-3.0.md
@@ -0,0 +1,89 @@
+Upgrading from 2.x to 3.0
+=========================
+
+Spout 3.0 introduced several backwards-incompatible changes. The upgrade from Spout 2.x to 3.0 must therefore be done with caution.
+This guide is meant to ease this process.
+
+Most notable changes
+--------------------
+In 2.x, styles were applied per row; it was therefore impossible to apply different styles to cells in the same row.
+With the 3.0 version, this is now possible: each cell can have its own style.
+
+Spout 3.0 tries to enforce better typing. For instance, instead of using/returning generic arrays, Spout now makes use of specific `Row` and `Cell` objects that can encapsulate more data such as type, style, value.
+
+Finally, **_Spout 3.2 only supports PHP 7.2 and above_**, as other PHP versions are no longer supported by the community.
+
+Reader changes
+--------------
+Creating a reader should now be done through the Reader `ReaderEntityFactory`, instead of using the `ReaderFactory`.
+Also, the `ReaderFactory::create($type)` method was removed and replaced by methods for each reader:
+```php
+use OpenSpout\Reader\Common\Creator\ReaderEntityFactory; // namespace is no longer "OpenSpout\Reader"
+...
+$reader = ReaderEntityFactory::createXLSXReader(); // replaces ReaderFactory::create(Type::XLSX)
+$reader = ReaderEntityFactory::createCSVReader(); // replaces ReaderFactory::create(Type::CSV)
+$reader = ReaderEntityFactory::createODSReader(); // replaces ReaderFactory::create(Type::ODS)
+```
+
+When iterating over the spreadsheet rows, Spout now returns `Row` objects, instead of an array containing row values. Accessing the row values should now be done this way:
+```php
+...
+foreach ($reader->getSheetIterator() as $sheet) {
+ foreach ($sheet->getRowIterator() as $row) { // $row is a "Row" object, not an array
+ $rowAsArray = $row->toArray(); // this is the 2.x equivalent
+ // OR
+ $cellsArray = $row->getCells(); // this can be used to get access to cells' details
+ ...
+ }
+}
+```
+
+Writer changes
+--------------
+Writer creation follows the same change as the reader. It should now be done through the Writer `WriterEntityFactory`, instead of using the `WriterFactory`.
+Also, the `WriterFactory::create($type)` method was removed and replaced by methods for each writer:
+
+```php
+use OpenSpout\Writer\Common\Creator\WriterEntityFactory; // namespace is no longer "OpenSpout\Writer"
+...
+$writer = WriterEntityFactory::createXLSXWriter(); // replaces WriterFactory::create(Type::XLSX)
+$writer = WriterEntityFactory::createCSVWriter(); // replaces WriterFactory::create(Type::CSV)
+$writer = WriterEntityFactory::createODSWriter(); // replaces WriterFactory::create(Type::ODS)
+```
+
+Adding rows is also done differently: instead of passing an array, the writer now takes in a `Row` object (or an array of `Row`). Creating such objects can easily be done this way:
+```php
+// Adding a row from an array of values (2.x equivalent)
+$cellValues = ['foo', 12345];
+$row1 = WriterEntityFactory::createRowFromArray($cellValues, $rowStyle);
+
+// Adding a row from an array of Cell
+$cell1 = WriterEntityFactory::createCell('foo', $cellStyle1); // this cell has its own style
+$cell2 = WriterEntityFactory::createCell(12345, $cellStyle2); // this cell has its own style
+$row2 = WriterEntityFactory::createRow([$cell1, $cell2]);
+
+$writer->addRows([$row1, $row2]);
+```
+
+Namespace changes for styles
+-----------------
+The namespaces for styles have changed. Styles are still created by using a `builder` class.
+
+For the builder, please update your import statements to use the following namespaces:
+
+ OpenSpout\Writer\Common\Creator\Style\StyleBuilder
+ OpenSpout\Writer\Common\Creator\Style\BorderBuilder
+
+The `Style` base class and style definitions like `Border`, `BorderPart` and `Color` also have a new namespace.
+
+If your are using these classes directly via an import statement in your code, please use the following namespaces:
+
+ OpenSpout\Common\Entity\Style\Border
+ OpenSpout\Common\Entity\Style\BorderPart
+ OpenSpout\Common\Entity\Style\Color
+ OpenSpout\Common\Entity\Style\Style
+
+Handling of empty rows
+----------------------
+In 2.x, empty rows were not added to the spreadsheet.
+In 3.0, `addRow` now always writes a row to the spreadsheet: when the row does not contain any cells, an empty row is created in the sheet.
diff --git a/upstream-3.x/composer.json b/upstream-3.x/composer.json
new file mode 100644
index 0000000..1a2a8ef
--- /dev/null
+++ b/upstream-3.x/composer.json
@@ -0,0 +1,69 @@
+{
+ "name": "openspout/openspout",
+ "description": "PHP Library to read and write spreadsheet files (CSV, XLSX and ODS), in a fast and scalable way",
+ "license": "MIT",
+ "type": "library",
+ "keywords": [
+ "php",
+ "read",
+ "write",
+ "csv",
+ "xlsx",
+ "ods",
+ "odf",
+ "open",
+ "office",
+ "excel",
+ "spreadsheet",
+ "scale",
+ "memory",
+ "stream",
+ "ooxml"
+ ],
+ "authors": [
+ {
+ "name": "Adrien Loison",
+ "email": "adrien@box.com"
+ }
+ ],
+ "homepage": "https://github.com/openspout/openspout",
+ "require": {
+ "php": "~7.3.0 || ~7.4.0 || ~8.0.0 || ~8.1.0",
+ "ext-dom": "*",
+ "ext-filter": "*",
+ "ext-libxml": "*",
+ "ext-xmlreader": "*",
+ "ext-zip": "*"
+ },
+ "require-dev": {
+ "ext-zlib": "*",
+ "friendsofphp/php-cs-fixer": "^3.4",
+ "phpstan/phpstan": "^1.4",
+ "phpstan/phpstan-phpunit": "^1.0",
+ "phpunit/phpunit": "^9.5"
+ },
+ "suggest": {
+ "ext-iconv": "To handle non UTF-8 CSV files (if \"php-intl\" is not already installed or is too limited)",
+ "ext-intl": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)"
+ },
+ "autoload": {
+ "psr-4": {
+ "OpenSpout\\": "src/"
+ }
+ },
+ "autoload-dev": {
+ "classmap": [
+ "tests/"
+ ]
+ },
+ "config": {
+ "platform": {
+ "php": "7.3"
+ }
+ },
+ "extra": {
+ "branch-alias": {
+ "dev-master": "3.3.x-dev"
+ }
+ }
+}
diff --git a/upstream-3.x/src/Autoloader/Psr4Autoloader.php b/upstream-3.x/src/Autoloader/Psr4Autoloader.php
new file mode 100644
index 0000000..59eac31
--- /dev/null
+++ b/upstream-3.x/src/Autoloader/Psr4Autoloader.php
@@ -0,0 +1,147 @@
+prefixes[$prefix])) {
+ $this->prefixes[$prefix] = [];
+ }
+
+ // retain the base directory for the namespace prefix
+ if ($prepend) {
+ array_unshift($this->prefixes[$prefix], $baseDir);
+ } else {
+ $this->prefixes[$prefix][] = $baseDir;
+ }
+ }
+
+ /**
+ * Loads the class file for a given class name.
+ *
+ * @param string $class the fully-qualified class name
+ *
+ * @return mixed the mapped file name on success, or boolean false on
+ * failure
+ */
+ public function loadClass($class)
+ {
+ // the current namespace prefix
+ $prefix = $class;
+
+ // work backwards through the namespace names of the fully-qualified
+ // class name to find a mapped file name
+ while (($pos = strrpos($prefix, '\\')) !== false) {
+ // retain the trailing namespace separator in the prefix
+ $prefix = substr($class, 0, $pos + 1);
+
+ // the rest is the relative class name
+ $relativeClass = substr($class, $pos + 1);
+
+ // try to load a mapped file for the prefix and relative class
+ $mappedFile = $this->loadMappedFile($prefix, $relativeClass);
+ if (false !== $mappedFile) {
+ return $mappedFile;
+ }
+
+ // remove the trailing namespace separator for the next iteration
+ // of strrpos()
+ $prefix = rtrim($prefix, '\\');
+ }
+
+ // never found a mapped file
+ return false;
+ }
+
+ /**
+ * Load the mapped file for a namespace prefix and relative class.
+ *
+ * @param string $prefix the namespace prefix
+ * @param string $relativeClass the relative class name
+ *
+ * @return mixed boolean false if no mapped file can be loaded, or the
+ * name of the mapped file that was loaded
+ */
+ protected function loadMappedFile($prefix, $relativeClass)
+ {
+ // are there any base directories for this namespace prefix?
+ if (false === isset($this->prefixes[$prefix])) {
+ return false;
+ }
+
+ // look through base directories for this namespace prefix
+ foreach ($this->prefixes[$prefix] as $baseDir) {
+ // replace the namespace prefix with the base directory,
+ // replace namespace separators with directory separators
+ // in the relative class name, append with .php
+ $file = $baseDir
+ .str_replace('\\', '/', $relativeClass)
+ .'.php';
+
+ // if the mapped file exists, require it
+ if ($this->requireFile($file)) {
+ // yes, we're done
+ return $file;
+ }
+ }
+
+ // never found it
+ return false;
+ }
+
+ /**
+ * If a file exists, require it from the file system.
+ *
+ * @param string $file the file to require
+ *
+ * @return bool true if the file exists, false if not
+ */
+ protected function requireFile($file)
+ {
+ if (file_exists($file)) {
+ require $file;
+
+ return true;
+ }
+
+ return false;
+ }
+}
diff --git a/upstream-3.x/src/Autoloader/autoload.php b/upstream-3.x/src/Autoloader/autoload.php
new file mode 100644
index 0000000..a6768d4
--- /dev/null
+++ b/upstream-3.x/src/Autoloader/autoload.php
@@ -0,0 +1,15 @@
+register();
+$loader->addNamespace('OpenSpout', $srcBaseDirectory);
diff --git a/upstream-3.x/src/Common/Creator/HelperFactory.php b/upstream-3.x/src/Common/Creator/HelperFactory.php
new file mode 100644
index 0000000..55f1f57
--- /dev/null
+++ b/upstream-3.x/src/Common/Creator/HelperFactory.php
@@ -0,0 +1,48 @@
+setValue($value);
+ $this->setStyle($style);
+ }
+
+ /**
+ * @return string
+ */
+ public function __toString()
+ {
+ return (string) $this->getValue();
+ }
+
+ /**
+ * @param null|mixed $value
+ */
+ public function setValue($value)
+ {
+ $this->value = $value;
+ $this->type = $this->detectType($value);
+ }
+
+ /**
+ * @return null|mixed
+ */
+ public function getValue()
+ {
+ return !$this->isError() ? $this->value : null;
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getValueEvenIfError()
+ {
+ return $this->value;
+ }
+
+ /**
+ * @param null|Style $style
+ */
+ public function setStyle($style)
+ {
+ $this->style = $style ?: new Style();
+ }
+
+ /**
+ * @return Style
+ */
+ public function getStyle()
+ {
+ return $this->style;
+ }
+
+ /**
+ * @return null|int
+ */
+ public function getType()
+ {
+ return $this->type;
+ }
+
+ /**
+ * @param int $type
+ */
+ public function setType($type)
+ {
+ $this->type = $type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isBoolean()
+ {
+ return self::TYPE_BOOLEAN === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isEmpty()
+ {
+ return self::TYPE_EMPTY === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isNumeric()
+ {
+ return self::TYPE_NUMERIC === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isString()
+ {
+ return self::TYPE_STRING === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isDate()
+ {
+ return self::TYPE_DATE === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFormula()
+ {
+ return self::TYPE_FORMULA === $this->type;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isError()
+ {
+ return self::TYPE_ERROR === $this->type;
+ }
+
+ /**
+ * Get the current value type.
+ *
+ * @param null|mixed $value
+ *
+ * @return int
+ */
+ protected function detectType($value)
+ {
+ if (CellTypeHelper::isBoolean($value)) {
+ return self::TYPE_BOOLEAN;
+ }
+ if (CellTypeHelper::isEmpty($value)) {
+ return self::TYPE_EMPTY;
+ }
+ if (CellTypeHelper::isNumeric($value)) {
+ return self::TYPE_NUMERIC;
+ }
+ if (CellTypeHelper::isDateTimeOrDateInterval($value)) {
+ return self::TYPE_DATE;
+ }
+ if (CellTypeHelper::isFormula($value)) {
+ return self::TYPE_FORMULA;
+ }
+ if (CellTypeHelper::isNonEmptyString($value)) {
+ return self::TYPE_STRING;
+ }
+
+ return self::TYPE_ERROR;
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Row.php b/upstream-3.x/src/Common/Entity/Row.php
new file mode 100644
index 0000000..db6481f
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Row.php
@@ -0,0 +1,166 @@
+setCells($cells)
+ ->setStyle($style)
+ ;
+ }
+
+ /**
+ * @return Cell[] $cells
+ */
+ public function getCells()
+ {
+ return $this->cells;
+ }
+
+ /**
+ * @param Cell[] $cells
+ *
+ * @return Row
+ */
+ public function setCells(array $cells)
+ {
+ $this->cells = [];
+ foreach ($cells as $cell) {
+ $this->addCell($cell);
+ }
+
+ return $this;
+ }
+
+ /**
+ * @param int $cellIndex
+ *
+ * @return Row
+ */
+ public function setCellAtIndex(Cell $cell, $cellIndex)
+ {
+ $this->cells[$cellIndex] = $cell;
+
+ return $this;
+ }
+
+ /**
+ * @param int $cellIndex
+ *
+ * @return null|Cell
+ */
+ public function getCellAtIndex($cellIndex)
+ {
+ return $this->cells[$cellIndex] ?? null;
+ }
+
+ /**
+ * @return Row
+ */
+ public function addCell(Cell $cell)
+ {
+ $this->cells[] = $cell;
+
+ return $this;
+ }
+
+ /**
+ * @return int
+ */
+ public function getNumCells()
+ {
+ // When using "setCellAtIndex", it's possible to
+ // have "$this->cells" contain holes.
+ if (empty($this->cells)) {
+ return 0;
+ }
+
+ return max(array_keys($this->cells)) + 1;
+ }
+
+ /**
+ * @return Style
+ */
+ public function getStyle()
+ {
+ return $this->style;
+ }
+
+ /**
+ * @param null|Style $style
+ *
+ * @return Row
+ */
+ public function setStyle($style)
+ {
+ $this->style = $style ?: new Style();
+
+ return $this;
+ }
+
+ /**
+ * @return array The row values, as array
+ */
+ public function toArray()
+ {
+ return array_map(function (Cell $cell) {
+ return $cell->getValue();
+ }, $this->cells);
+ }
+
+ /**
+ * Set row height.
+ *
+ * @param string $height
+ *
+ * @return Row
+ */
+ public function setHeight($height)
+ {
+ $this->height = $height;
+
+ return $this;
+ }
+
+ /**
+ * Returns row height.
+ *
+ * @return string
+ */
+ public function getHeight()
+ {
+ return $this->height;
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Style/Border.php b/upstream-3.x/src/Common/Entity/Style/Border.php
new file mode 100644
index 0000000..53b59da
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Style/Border.php
@@ -0,0 +1,80 @@
+setParts($borderParts);
+ }
+
+ /**
+ * @param string $name The name of the border part
+ *
+ * @return null|BorderPart
+ */
+ public function getPart($name)
+ {
+ return $this->hasPart($name) ? $this->parts[$name] : null;
+ }
+
+ /**
+ * @param string $name The name of the border part
+ *
+ * @return bool
+ */
+ public function hasPart($name)
+ {
+ return isset($this->parts[$name]);
+ }
+
+ /**
+ * @return array
+ */
+ public function getParts()
+ {
+ return $this->parts;
+ }
+
+ /**
+ * Set BorderParts.
+ *
+ * @param array $parts
+ */
+ public function setParts($parts)
+ {
+ $this->parts = [];
+ foreach ($parts as $part) {
+ $this->addPart($part);
+ }
+ }
+
+ /**
+ * @return Border
+ */
+ public function addPart(BorderPart $borderPart)
+ {
+ $this->parts[$borderPart->getName()] = $borderPart;
+
+ return $this;
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Style/BorderPart.php b/upstream-3.x/src/Common/Entity/Style/BorderPart.php
new file mode 100644
index 0000000..afee776
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Style/BorderPart.php
@@ -0,0 +1,181 @@
+setName($name);
+ $this->setColor($color);
+ $this->setWidth($width);
+ $this->setStyle($style);
+ }
+
+ /**
+ * @return string
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @param string $name The name of the border part @see BorderPart::$allowedNames
+ *
+ * @throws InvalidNameException
+ */
+ public function setName($name)
+ {
+ if (!\in_array($name, self::$allowedNames, true)) {
+ throw new InvalidNameException($name);
+ }
+ $this->name = $name;
+ }
+
+ /**
+ * @return string
+ */
+ public function getStyle()
+ {
+ return $this->style;
+ }
+
+ /**
+ * @param string $style The style of the border part @see BorderPart::$allowedStyles
+ *
+ * @throws InvalidStyleException
+ */
+ public function setStyle($style)
+ {
+ if (!\in_array($style, self::$allowedStyles, true)) {
+ throw new InvalidStyleException($style);
+ }
+ $this->style = $style;
+ }
+
+ /**
+ * @return string
+ */
+ public function getColor()
+ {
+ return $this->color;
+ }
+
+ /**
+ * @param string $color The color of the border part @see Color::rgb()
+ */
+ public function setColor($color)
+ {
+ $this->color = $color;
+ }
+
+ /**
+ * @return string
+ */
+ public function getWidth()
+ {
+ return $this->width;
+ }
+
+ /**
+ * @param string $width The width of the border part @see BorderPart::$allowedWidths
+ *
+ * @throws InvalidWidthException
+ */
+ public function setWidth($width)
+ {
+ if (!\in_array($width, self::$allowedWidths, true)) {
+ throw new InvalidWidthException($width);
+ }
+ $this->width = $width;
+ }
+
+ /**
+ * @return array
+ */
+ public static function getAllowedStyles()
+ {
+ return self::$allowedStyles;
+ }
+
+ /**
+ * @return array
+ */
+ public static function getAllowedNames()
+ {
+ return self::$allowedNames;
+ }
+
+ /**
+ * @return array
+ */
+ public static function getAllowedWidths()
+ {
+ return self::$allowedWidths;
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Style/CellAlignment.php b/upstream-3.x/src/Common/Entity/Style/CellAlignment.php
new file mode 100644
index 0000000..ecfa6d7
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Style/CellAlignment.php
@@ -0,0 +1,31 @@
+ 1,
+ self::RIGHT => 1,
+ self::CENTER => 1,
+ self::JUSTIFY => 1,
+ ];
+
+ /**
+ * @param string $cellAlignment
+ *
+ * @return bool Whether the given cell alignment is valid
+ */
+ public static function isValid($cellAlignment)
+ {
+ return isset(self::$VALID_ALIGNMENTS[$cellAlignment]);
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Style/Color.php b/upstream-3.x/src/Common/Entity/Style/Color.php
new file mode 100644
index 0000000..cd9bdfd
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Style/Color.php
@@ -0,0 +1,86 @@
+ 255) {
+ throw new InvalidColorException("The RGB components must be between 0 and 255. Received: {$colorComponent}");
+ }
+ }
+
+ /**
+ * Converts the color component to its corresponding hexadecimal value.
+ *
+ * @param int $colorComponent Color component, 0 - 255
+ *
+ * @return string Corresponding hexadecimal value, with a leading 0 if needed. E.g "0f", "2d"
+ */
+ protected static function convertColorComponentToHex($colorComponent)
+ {
+ return str_pad(dechex($colorComponent), 2, '0', STR_PAD_LEFT);
+ }
+}
diff --git a/upstream-3.x/src/Common/Entity/Style/Style.php b/upstream-3.x/src/Common/Entity/Style/Style.php
new file mode 100644
index 0000000..28f1597
--- /dev/null
+++ b/upstream-3.x/src/Common/Entity/Style/Style.php
@@ -0,0 +1,549 @@
+id;
+ }
+
+ /**
+ * @param int $id
+ *
+ * @return Style
+ */
+ public function setId($id)
+ {
+ $this->id = $id;
+
+ return $this;
+ }
+
+ /**
+ * @return null|Border
+ */
+ public function getBorder()
+ {
+ return $this->border;
+ }
+
+ /**
+ * @return Style
+ */
+ public function setBorder(Border $border)
+ {
+ $this->shouldApplyBorder = true;
+ $this->border = $border;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldApplyBorder()
+ {
+ return $this->shouldApplyBorder;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFontBold()
+ {
+ return $this->fontBold;
+ }
+
+ /**
+ * @return Style
+ */
+ public function setFontBold()
+ {
+ $this->fontBold = true;
+ $this->hasSetFontBold = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontBold()
+ {
+ return $this->hasSetFontBold;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFontItalic()
+ {
+ return $this->fontItalic;
+ }
+
+ /**
+ * @return Style
+ */
+ public function setFontItalic()
+ {
+ $this->fontItalic = true;
+ $this->hasSetFontItalic = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontItalic()
+ {
+ return $this->hasSetFontItalic;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFontUnderline()
+ {
+ return $this->fontUnderline;
+ }
+
+ /**
+ * @return Style
+ */
+ public function setFontUnderline()
+ {
+ $this->fontUnderline = true;
+ $this->hasSetFontUnderline = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontUnderline()
+ {
+ return $this->hasSetFontUnderline;
+ }
+
+ /**
+ * @return bool
+ */
+ public function isFontStrikethrough()
+ {
+ return $this->fontStrikethrough;
+ }
+
+ /**
+ * @return Style
+ */
+ public function setFontStrikethrough()
+ {
+ $this->fontStrikethrough = true;
+ $this->hasSetFontStrikethrough = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontStrikethrough()
+ {
+ return $this->hasSetFontStrikethrough;
+ }
+
+ /**
+ * @return int
+ */
+ public function getFontSize()
+ {
+ return $this->fontSize;
+ }
+
+ /**
+ * @param int $fontSize Font size, in pixels
+ *
+ * @return Style
+ */
+ public function setFontSize($fontSize)
+ {
+ $this->fontSize = $fontSize;
+ $this->hasSetFontSize = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontSize()
+ {
+ return $this->hasSetFontSize;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFontColor()
+ {
+ return $this->fontColor;
+ }
+
+ /**
+ * Sets the font color.
+ *
+ * @param string $fontColor ARGB color (@see Color)
+ *
+ * @return Style
+ */
+ public function setFontColor($fontColor)
+ {
+ $this->fontColor = $fontColor;
+ $this->hasSetFontColor = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontColor()
+ {
+ return $this->hasSetFontColor;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFontName()
+ {
+ return $this->fontName;
+ }
+
+ /**
+ * @param string $fontName Name of the font to use
+ *
+ * @return Style
+ */
+ public function setFontName($fontName)
+ {
+ $this->fontName = $fontName;
+ $this->hasSetFontName = true;
+ $this->shouldApplyFont = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetFontName()
+ {
+ return $this->hasSetFontName;
+ }
+
+ /**
+ * @return string
+ */
+ public function getCellAlignment()
+ {
+ return $this->cellAlignment;
+ }
+
+ /**
+ * @param string $cellAlignment The cell alignment
+ *
+ * @return Style
+ */
+ public function setCellAlignment($cellAlignment)
+ {
+ $this->cellAlignment = $cellAlignment;
+ $this->hasSetCellAlignment = true;
+ $this->shouldApplyCellAlignment = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetCellAlignment()
+ {
+ return $this->hasSetCellAlignment;
+ }
+
+ /**
+ * @return bool Whether specific cell alignment should be applied
+ */
+ public function shouldApplyCellAlignment()
+ {
+ return $this->shouldApplyCellAlignment;
+ }
+
+ /**
+ * @return bool
+ */
+ public function shouldWrapText()
+ {
+ return $this->shouldWrapText;
+ }
+
+ /**
+ * @param bool $shouldWrap Should the text be wrapped
+ *
+ * @return Style
+ */
+ public function setShouldWrapText($shouldWrap = true)
+ {
+ $this->shouldWrapText = $shouldWrap;
+ $this->hasSetWrapText = true;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetWrapText()
+ {
+ return $this->hasSetWrapText;
+ }
+
+ /**
+ * @return bool Whether specific font properties should be applied
+ */
+ public function shouldApplyFont()
+ {
+ return $this->shouldApplyFont;
+ }
+
+ /**
+ * Sets the background color.
+ *
+ * @param string $color ARGB color (@see Color)
+ *
+ * @return Style
+ */
+ public function setBackgroundColor($color)
+ {
+ $this->hasSetBackgroundColor = true;
+ $this->backgroundColor = $color;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getBackgroundColor()
+ {
+ return $this->backgroundColor;
+ }
+
+ /**
+ * @return bool Whether the background color should be applied
+ */
+ public function shouldApplyBackgroundColor()
+ {
+ return $this->hasSetBackgroundColor;
+ }
+
+ /**
+ * Sets format.
+ *
+ * @param string $format
+ *
+ * @return Style
+ */
+ public function setFormat($format)
+ {
+ $this->hasSetFormat = true;
+ $this->format = $format;
+ $this->isEmpty = false;
+
+ return $this;
+ }
+
+ /**
+ * @return null|string
+ */
+ public function getFormat()
+ {
+ return $this->format;
+ }
+
+ /**
+ * @return bool Whether format should be applied
+ */
+ public function shouldApplyFormat()
+ {
+ return $this->hasSetFormat;
+ }
+
+ public function isRegistered(): bool
+ {
+ return $this->isRegistered;
+ }
+
+ public function markAsRegistered(?int $id): void
+ {
+ $this->setId($id);
+ $this->isRegistered = true;
+ }
+
+ public function unmarkAsRegistered(): void
+ {
+ $this->setId(0);
+ $this->isRegistered = false;
+ }
+
+ public function isEmpty(): bool
+ {
+ return $this->isEmpty;
+ }
+
+ /**
+ * Sets should shrink to fit.
+ *
+ * @param bool $shrinkToFit
+ *
+ * @return Style
+ */
+ public function setShouldShrinkToFit($shrinkToFit = true)
+ {
+ $this->hasSetShrinkToFit = true;
+ $this->shouldShrinkToFit = $shrinkToFit;
+
+ return $this;
+ }
+
+ /**
+ * @return bool Whether format should be applied
+ */
+ public function shouldShrinkToFit()
+ {
+ return $this->shouldShrinkToFit;
+ }
+
+ /**
+ * @return bool
+ */
+ public function hasSetShrinkToFit()
+ {
+ return $this->hasSetShrinkToFit;
+ }
+}
diff --git a/upstream-3.x/src/Common/Exception/EncodingConversionException.php b/upstream-3.x/src/Common/Exception/EncodingConversionException.php
new file mode 100644
index 0000000..ef0cdc6
--- /dev/null
+++ b/upstream-3.x/src/Common/Exception/EncodingConversionException.php
@@ -0,0 +1,7 @@
+globalFunctionsHelper = $globalFunctionsHelper;
+
+ $this->supportedEncodingsWithBom = [
+ self::ENCODING_UTF8 => self::BOM_UTF8,
+ self::ENCODING_UTF16_LE => self::BOM_UTF16_LE,
+ self::ENCODING_UTF16_BE => self::BOM_UTF16_BE,
+ self::ENCODING_UTF32_LE => self::BOM_UTF32_LE,
+ self::ENCODING_UTF32_BE => self::BOM_UTF32_BE,
+ ];
+ }
+
+ /**
+ * Returns the number of bytes to use as offset in order to skip the BOM.
+ *
+ * @param resource $filePointer Pointer to the file to check
+ * @param string $encoding Encoding of the file to check
+ *
+ * @return int Bytes offset to apply to skip the BOM (0 means no BOM)
+ */
+ public function getBytesOffsetToSkipBOM($filePointer, $encoding)
+ {
+ $byteOffsetToSkipBom = 0;
+
+ if ($this->hasBOM($filePointer, $encoding)) {
+ $bomUsed = $this->supportedEncodingsWithBom[$encoding];
+
+ // we skip the N first bytes
+ $byteOffsetToSkipBom = \strlen($bomUsed);
+ }
+
+ return $byteOffsetToSkipBom;
+ }
+
+ /**
+ * Attempts to convert a non UTF-8 string into UTF-8.
+ *
+ * @param string $string Non UTF-8 string to be converted
+ * @param string $sourceEncoding The encoding used to encode the source string
+ *
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
+ *
+ * @return string The converted, UTF-8 string
+ */
+ public function attemptConversionToUTF8($string, $sourceEncoding)
+ {
+ return $this->attemptConversion($string, $sourceEncoding, self::ENCODING_UTF8);
+ }
+
+ /**
+ * Attempts to convert a UTF-8 string into the given encoding.
+ *
+ * @param string $string UTF-8 string to be converted
+ * @param string $targetEncoding The encoding the string should be re-encoded into
+ *
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
+ *
+ * @return string The converted string, encoded with the given encoding
+ */
+ public function attemptConversionFromUTF8($string, $targetEncoding)
+ {
+ return $this->attemptConversion($string, self::ENCODING_UTF8, $targetEncoding);
+ }
+
+ /**
+ * Returns whether the file identified by the given pointer has a BOM.
+ *
+ * @param resource $filePointer Pointer to the file to check
+ * @param string $encoding Encoding of the file to check
+ *
+ * @return bool TRUE if the file has a BOM, FALSE otherwise
+ */
+ protected function hasBOM($filePointer, $encoding)
+ {
+ $hasBOM = false;
+
+ $this->globalFunctionsHelper->rewind($filePointer);
+
+ if (\array_key_exists($encoding, $this->supportedEncodingsWithBom)) {
+ $potentialBom = $this->supportedEncodingsWithBom[$encoding];
+ $numBytesInBom = \strlen($potentialBom);
+
+ $hasBOM = ($this->globalFunctionsHelper->fgets($filePointer, $numBytesInBom + 1) === $potentialBom);
+ }
+
+ return $hasBOM;
+ }
+
+ /**
+ * Attempts to convert the given string to the given encoding.
+ * Depending on what is installed on the server, we will try to iconv or mbstring.
+ *
+ * @param string $string string to be converted
+ * @param string $sourceEncoding The encoding used to encode the source string
+ * @param string $targetEncoding The encoding the string should be re-encoded into
+ *
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If conversion is not supported or if the conversion failed
+ *
+ * @return string The converted string, encoded with the given encoding
+ */
+ protected function attemptConversion($string, $sourceEncoding, $targetEncoding)
+ {
+ // if source and target encodings are the same, it's a no-op
+ if ($sourceEncoding === $targetEncoding) {
+ return $string;
+ }
+
+ $convertedString = null;
+
+ if ($this->canUseIconv()) {
+ $convertedString = $this->globalFunctionsHelper->iconv($string, $sourceEncoding, $targetEncoding);
+ } elseif ($this->canUseMbString()) {
+ $convertedString = $this->globalFunctionsHelper->mb_convert_encoding($string, $sourceEncoding, $targetEncoding);
+ } else {
+ throw new EncodingConversionException("The conversion from {$sourceEncoding} to {$targetEncoding} is not supported. Please install \"iconv\" or \"PHP Intl\".");
+ }
+
+ if (false === $convertedString) {
+ throw new EncodingConversionException("The conversion from {$sourceEncoding} to {$targetEncoding} failed.");
+ }
+
+ return $convertedString;
+ }
+
+ /**
+ * Returns whether "iconv" can be used.
+ *
+ * @return bool TRUE if "iconv" is available and can be used, FALSE otherwise
+ */
+ protected function canUseIconv()
+ {
+ return $this->globalFunctionsHelper->function_exists('iconv');
+ }
+
+ /**
+ * Returns whether "mb_string" functions can be used.
+ * These functions come with the PHP Intl package.
+ *
+ * @return bool TRUE if "mb_string" functions are available and can be used, FALSE otherwise
+ */
+ protected function canUseMbString()
+ {
+ return $this->globalFunctionsHelper->function_exists('mb_convert_encoding');
+ }
+}
diff --git a/upstream-3.x/src/Common/Helper/Escaper/CSV.php b/upstream-3.x/src/Common/Helper/Escaper/CSV.php
new file mode 100644
index 0000000..d68199a
--- /dev/null
+++ b/upstream-3.x/src/Common/Helper/Escaper/CSV.php
@@ -0,0 +1,37 @@
+', '&') as well as
+ // single/double quotes (for XML attributes) need to be encoded.
+ if (\defined('ENT_DISALLOWED')) {
+ /**
+ * 'ENT_DISALLOWED' ensures that invalid characters in the given document type are replaced.
+ * Otherwise control characters like a vertical tab "\v" will make the XML document unreadable by the XML processor.
+ *
+ * @see https://github.com/box/spout/issues/329
+ */
+ $replacedString = htmlspecialchars($string, ENT_QUOTES | ENT_DISALLOWED, 'UTF-8');
+ } else {
+ // We are on hhvm or any other engine that does not support ENT_DISALLOWED.
+ $escapedString = htmlspecialchars($string, ENT_QUOTES, 'UTF-8');
+
+ // control characters values are from 0 to 1F (hex values) in the ASCII table
+ // some characters should not be escaped though: "\t", "\r" and "\n".
+ $regexPattern = '[\x00-\x08'.
+ // skipping "\t" (0x9) and "\n" (0xA)
+ '\x0B-\x0C'.
+ // skipping "\r" (0xD)
+ '\x0E-\x1F]';
+ $replacedString = preg_replace("/{$regexPattern}/", '�', $escapedString);
+ }
+
+ return $replacedString;
+ }
+
+ /**
+ * Unescapes the given string to make it compatible with XLSX.
+ *
+ * @param string $string The string to unescape
+ *
+ * @return string The unescaped string
+ */
+ public function unescape($string)
+ {
+ // ==============
+ // = WARNING =
+ // ==============
+ // It is assumed that the given string has already had its XML entities decoded.
+ // This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
+ // Therefore there is no need to call "htmlspecialchars_decode()".
+ return $string;
+ }
+}
diff --git a/upstream-3.x/src/Common/Helper/Escaper/XLSX.php b/upstream-3.x/src/Common/Helper/Escaper/XLSX.php
new file mode 100644
index 0000000..16bb162
--- /dev/null
+++ b/upstream-3.x/src/Common/Helper/Escaper/XLSX.php
@@ -0,0 +1,194 @@
+initIfNeeded();
+
+ $escapedString = $this->escapeControlCharacters($string);
+ // @NOTE: Using ENT_QUOTES as XML entities ('<', '>', '&') as well as
+ // single/double quotes (for XML attributes) need to be encoded.
+ return htmlspecialchars($escapedString, ENT_QUOTES, 'UTF-8');
+ }
+
+ /**
+ * 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)
+ {
+ $this->initIfNeeded();
+
+ // ==============
+ // = WARNING =
+ // ==============
+ // It is assumed that the given string has already had its XML entities decoded.
+ // This is true if the string is coming from a DOMNode (as DOMNode already decode XML entities on creation).
+ // Therefore there is no need to call "htmlspecialchars_decode()".
+ return $this->unescapeControlCharacters($string);
+ }
+
+ /**
+ * Initializes the control characters if not already done.
+ */
+ protected function initIfNeeded()
+ {
+ if (!$this->isAlreadyInitialized) {
+ $this->escapableControlCharactersPattern = $this->getEscapableControlCharactersPattern();
+ $this->controlCharactersEscapingMap = $this->getControlCharactersEscapingMap();
+ $this->controlCharactersEscapingReverseMap = array_flip($this->controlCharactersEscapingMap);
+
+ $this->isAlreadyInitialized = true;
+ }
+ }
+
+ /**
+ * @return string Regex pattern containing all escapable control characters
+ */
+ protected function getEscapableControlCharactersPattern()
+ {
+ // control characters values are from 0 to 1F (hex values) in the ASCII table
+ // some characters should not be escaped though: "\t", "\r" and "\n".
+ return '[\x00-\x08'.
+ // skipping "\t" (0x9) and "\n" (0xA)
+ '\x0B-\x0C'.
+ // skipping "\r" (0xD)
+ '\x0E-\x1F]';
+ }
+
+ /**
+ * Builds the map containing control characters to be escaped
+ * mapped to their escaped values.
+ * "\t", "\r" and "\n" don't need to be escaped.
+ *
+ * NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
+ *
+ * @see https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89
+ *
+ * @return string[]
+ */
+ protected function getControlCharactersEscapingMap()
+ {
+ $controlCharactersEscapingMap = [];
+
+ // control characters values are from 0 to 1F (hex values) in the ASCII table
+ for ($charValue = 0x00; $charValue <= 0x1F; ++$charValue) {
+ $character = \chr($charValue);
+ if (preg_match("/{$this->escapableControlCharactersPattern}/", $character)) {
+ $charHexValue = dechex($charValue);
+ $escapedChar = '_x'.sprintf('%04s', strtoupper($charHexValue)).'_';
+ $controlCharactersEscapingMap[$escapedChar] = $character;
+ }
+ }
+
+ return $controlCharactersEscapingMap;
+ }
+
+ /**
+ * Converts PHP control characters from the given string to OpenXML escaped control characters.
+ *
+ * Excel escapes control characters with _xHHHH_ and also escapes any
+ * literal strings of that type by encoding the leading underscore.
+ * So "\0" -> _x0000_ and "_x0000_" -> _x005F_x0000_.
+ *
+ * NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
+ *
+ * @see https://github.com/jmcnamara/XlsxWriter/blob/f1e610f29/xlsxwriter/sharedstrings.py#L89
+ *
+ * @param string $string String to escape
+ *
+ * @return string
+ */
+ protected function escapeControlCharacters($string)
+ {
+ $escapedString = $this->escapeEscapeCharacter($string);
+
+ // if no control characters
+ if (!preg_match("/{$this->escapableControlCharactersPattern}/", $escapedString)) {
+ return $escapedString;
+ }
+
+ return preg_replace_callback("/({$this->escapableControlCharactersPattern})/", function ($matches) {
+ return $this->controlCharactersEscapingReverseMap[$matches[0]];
+ }, $escapedString);
+ }
+
+ /**
+ * Escapes the escape character: "_x0000_" -> "_x005F_x0000_".
+ *
+ * @param string $string String to escape
+ *
+ * @return string The escaped string
+ */
+ protected function escapeEscapeCharacter($string)
+ {
+ return preg_replace('/_(x[\dA-F]{4})_/', '_x005F_$1_', $string);
+ }
+
+ /**
+ * Converts OpenXML escaped control characters from the given string to PHP control characters.
+ *
+ * Excel escapes control characters with _xHHHH_ and also escapes any
+ * literal strings of that type by encoding the leading underscore.
+ * So "_x0000_" -> "\0" and "_x005F_x0000_" -> "_x0000_"
+ *
+ * NOTE: the logic has been adapted from the XlsxWriter library (BSD License)
+ *
+ * @see 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/upstream-3.x/src/Common/Helper/FileSystemHelper.php b/upstream-3.x/src/Common/Helper/FileSystemHelper.php
new file mode 100644
index 0000000..130f1f8
--- /dev/null
+++ b/upstream-3.x/src/Common/Helper/FileSystemHelper.php
@@ -0,0 +1,138 @@
+baseFolderRealPath = realpath($baseFolderPath);
+ }
+
+ /**
+ * Creates an empty folder with the given name under the given parent folder.
+ *
+ * @param string $parentFolderPath The parent folder path under which the folder is going to be created
+ * @param string $folderName The name of the folder to create
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or if the folder path is not inside of the base folder
+ *
+ * @return string Path of the created folder
+ */
+ public function createFolder($parentFolderPath, $folderName)
+ {
+ $this->throwIfOperationNotInBaseFolder($parentFolderPath);
+
+ $folderPath = $parentFolderPath.'/'.$folderName;
+
+ $wasCreationSuccessful = mkdir($folderPath, 0777, true);
+ if (!$wasCreationSuccessful) {
+ throw new IOException("Unable to create folder: {$folderPath}");
+ }
+
+ return $folderPath;
+ }
+
+ /**
+ * Creates a file with the given name and content in the given folder.
+ * The parent folder must exist.
+ *
+ * @param string $parentFolderPath The parent folder path where the file is going to be created
+ * @param string $fileName The name of the file to create
+ * @param string $fileContents The contents of the file to create
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file or if the file path is not inside of the base folder
+ *
+ * @return string Path of the created file
+ */
+ public function createFileWithContents($parentFolderPath, $fileName, $fileContents)
+ {
+ $this->throwIfOperationNotInBaseFolder($parentFolderPath);
+
+ $filePath = $parentFolderPath.'/'.$fileName;
+
+ $wasCreationSuccessful = file_put_contents($filePath, $fileContents);
+ if (false === $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
+ *
+ * @throws \OpenSpout\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
+ *
+ * @throws \OpenSpout\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
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the folder where the I/O operation should occur
+ * is not inside the base folder or the base folder does not exist
+ */
+ protected function throwIfOperationNotInBaseFolder(string $operationFolderPath)
+ {
+ $operationFolderRealPath = realpath($operationFolderPath);
+ if (!$this->baseFolderRealPath) {
+ throw new IOException("The base folder path is invalid: {$this->baseFolderRealPath}");
+ }
+ $isInBaseFolder = (0 === strpos($operationFolderRealPath, $this->baseFolderRealPath));
+ if (!$isInBaseFolder) {
+ throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderRealPath}");
+ }
+ }
+}
diff --git a/upstream-3.x/src/Common/Helper/FileSystemHelperInterface.php b/upstream-3.x/src/Common/Helper/FileSystemHelperInterface.php
new file mode 100644
index 0000000..90082d2
--- /dev/null
+++ b/upstream-3.x/src/Common/Helper/FileSystemHelperInterface.php
@@ -0,0 +1,54 @@
+= 70400 ? '' : "\0";
+
+ return fgetcsv($handle, $length, $delimiter, $enclosure, $escapeCharacter);
+ }
+
+ /**
+ * Wrapper around global function fputcsv().
+ *
+ * @see fputcsv()
+ *
+ * @param resource $handle
+ * @param null|string $delimiter
+ * @param null|string $enclosure
+ *
+ * @return false|int
+ */
+ public function fputcsv($handle, array $fields, $delimiter = null, $enclosure = null)
+ {
+ /**
+ * PHP uses '\' as the default escape character. This is not RFC-4180 compliant...
+ * To fix that, simply disable the escape character.
+ *
+ * @see https://bugs.php.net/bug.php?id=43225
+ * @see http://tools.ietf.org/html/rfc4180
+ */
+ $escapeCharacter = \PHP_VERSION_ID >= 70400 ? '' : "\0";
+
+ return fputcsv($handle, $fields, $delimiter, $enclosure, $escapeCharacter);
+ }
+
+ /**
+ * Wrapper around global function fwrite().
+ *
+ * @see fwrite()
+ *
+ * @param resource $handle
+ * @param string $string
+ *
+ * @return int
+ */
+ public function fwrite($handle, $string)
+ {
+ return fwrite($handle, $string);
+ }
+
+ /**
+ * Wrapper around global function fclose().
+ *
+ * @see fclose()
+ *
+ * @param resource $handle
+ *
+ * @return bool
+ */
+ public function fclose($handle)
+ {
+ return fclose($handle);
+ }
+
+ /**
+ * Wrapper around global function rewind().
+ *
+ * @see rewind()
+ *
+ * @param resource $handle
+ *
+ * @return bool
+ */
+ public function rewind($handle)
+ {
+ return rewind($handle);
+ }
+
+ /**
+ * Wrapper around global function file_exists().
+ *
+ * @see file_exists()
+ *
+ * @param string $fileName
+ *
+ * @return bool
+ */
+ public function file_exists($fileName)
+ {
+ return file_exists($fileName);
+ }
+
+ /**
+ * Wrapper around global function file_get_contents().
+ *
+ * @see file_get_contents()
+ *
+ * @param string $filePath
+ *
+ * @return string
+ */
+ public function file_get_contents($filePath)
+ {
+ $realFilePath = $this->convertToUseRealPath($filePath);
+
+ return file_get_contents($realFilePath);
+ }
+
+ /**
+ * Wrapper around global function feof().
+ *
+ * @see feof()
+ *
+ * @param resource $handle
+ *
+ * @return bool
+ */
+ public function feof($handle)
+ {
+ return feof($handle);
+ }
+
+ /**
+ * Wrapper around global function is_readable().
+ *
+ * @see is_readable()
+ *
+ * @param string $fileName
+ *
+ * @return bool
+ */
+ public function is_readable($fileName)
+ {
+ return is_readable($fileName);
+ }
+
+ /**
+ * Wrapper around global function basename().
+ *
+ * @see basename()
+ *
+ * @param string $path
+ * @param string $suffix
+ *
+ * @return string
+ */
+ public function basename($path, $suffix = '')
+ {
+ return basename($path, $suffix);
+ }
+
+ /**
+ * Wrapper around global function header().
+ *
+ * @see header()
+ *
+ * @param string $string
+ */
+ public function header($string)
+ {
+ header($string);
+ }
+
+ /**
+ * Wrapper around global function ob_end_clean().
+ *
+ * @see ob_end_clean()
+ */
+ public function ob_end_clean()
+ {
+ if (ob_get_length() > 0) {
+ ob_end_clean();
+ }
+ }
+
+ /**
+ * Wrapper around global function iconv().
+ *
+ * @see iconv()
+ *
+ * @param string $string The string to be converted
+ * @param string $sourceEncoding The encoding of the source string
+ * @param string $targetEncoding The encoding the source string should be converted to
+ *
+ * @return bool|string the converted string or FALSE on failure
+ */
+ public function iconv($string, $sourceEncoding, $targetEncoding)
+ {
+ return iconv($sourceEncoding, $targetEncoding, $string);
+ }
+
+ /**
+ * Wrapper around global function mb_convert_encoding().
+ *
+ * @see mb_convert_encoding()
+ *
+ * @param string $string The string to be converted
+ * @param string $sourceEncoding The encoding of the source string
+ * @param string $targetEncoding The encoding the source string should be converted to
+ *
+ * @return bool|string the converted string or FALSE on failure
+ */
+ public function mb_convert_encoding($string, $sourceEncoding, $targetEncoding)
+ {
+ return mb_convert_encoding($string, $targetEncoding, $sourceEncoding);
+ }
+
+ /**
+ * Wrapper around global function stream_get_wrappers().
+ *
+ * @see stream_get_wrappers()
+ *
+ * @return array
+ */
+ public function stream_get_wrappers()
+ {
+ return stream_get_wrappers();
+ }
+
+ /**
+ * Wrapper around global function function_exists().
+ *
+ * @see function_exists()
+ *
+ * @param string $functionName
+ *
+ * @return bool
+ */
+ public function function_exists($functionName)
+ {
+ return \function_exists($functionName);
+ }
+
+ /**
+ * Updates the given file path to use a real path.
+ * This is to avoid issues on some Windows setup.
+ *
+ * @param string $filePath File path
+ *
+ * @return string The file path using a real path
+ */
+ protected function convertToUseRealPath($filePath)
+ {
+ $realFilePath = $filePath;
+
+ if ($this->isZipStream($filePath)) {
+ if (preg_match('/zip:\/\/(.*)#(.*)/', $filePath, $matches)) {
+ $documentPath = $matches[1];
+ $documentInsideZipPath = $matches[2];
+ $realFilePath = 'zip://'.realpath($documentPath).'#'.$documentInsideZipPath;
+ }
+ } else {
+ $realFilePath = realpath($filePath);
+ }
+
+ return $realFilePath;
+ }
+
+ /**
+ * Returns whether the given path is a zip stream.
+ *
+ * @param string $path Path pointing to a document
+ *
+ * @return bool TRUE if path is a zip stream, FALSE otherwise
+ */
+ protected function isZipStream($path)
+ {
+ return 0 === strpos($path, 'zip://');
+ }
+}
diff --git a/upstream-3.x/src/Common/Helper/StringHelper.php b/upstream-3.x/src/Common/Helper/StringHelper.php
new file mode 100644
index 0000000..96e7f2c
--- /dev/null
+++ b/upstream-3.x/src/Common/Helper/StringHelper.php
@@ -0,0 +1,108 @@
+hasMbstringSupport = \extension_loaded('mbstring');
+ $this->isRunningPhp7OrOlder = version_compare(PHP_VERSION, '8.0.0') < 0;
+ $this->localeInfo = localeconv();
+ }
+
+ /**
+ * Returns the length of the given string.
+ * It uses the multi-bytes function is available.
+ *
+ * @see strlen
+ * @see mb_strlen
+ *
+ * @param string $string
+ *
+ * @return int
+ */
+ public function getStringLength($string)
+ {
+ return $this->hasMbstringSupport ? mb_strlen($string) : \strlen($string);
+ }
+
+ /**
+ * Returns the position of the first occurrence of the given character/substring within the given string.
+ * It uses the multi-bytes function is available.
+ *
+ * @see strpos
+ * @see mb_strpos
+ *
+ * @param string $char Needle
+ * @param string $string Haystack
+ *
+ * @return int Char/substring's first occurrence position within the string if found (starts at 0) or -1 if not found
+ */
+ public function getCharFirstOccurrencePosition($char, $string)
+ {
+ $position = $this->hasMbstringSupport ? mb_strpos($string, $char) : strpos($string, $char);
+
+ return (false !== $position) ? $position : -1;
+ }
+
+ /**
+ * Returns the position of the last occurrence of the given character/substring within the given string.
+ * It uses the multi-bytes function is available.
+ *
+ * @see strrpos
+ * @see mb_strrpos
+ *
+ * @param string $char Needle
+ * @param string $string Haystack
+ *
+ * @return int Char/substring's last occurrence position within the string if found (starts at 0) or -1 if not found
+ */
+ public function getCharLastOccurrencePosition($char, $string)
+ {
+ $position = $this->hasMbstringSupport ? mb_strrpos($string, $char) : strrpos($string, $char);
+
+ return (false !== $position) ? $position : -1;
+ }
+
+ /**
+ * Formats a numeric value (int or float) in a way that's compatible with the expected spreadsheet format.
+ *
+ * Formatting of float values is locale dependent in PHP < 8.
+ * Thousands separators and decimal points vary from locale to locale (en_US: 12.34 vs pl_PL: 12,34).
+ * However, float values must be formatted with no thousands separator and a "." as decimal point
+ * to work properly. This method can be used to convert the value to the correct format before storing it.
+ *
+ * @see https://wiki.php.net/rfc/locale_independent_float_to_string for the changed behavior in PHP8.
+ *
+ * @param float|int $numericValue
+ *
+ * @return float|int|string
+ */
+ public function formatNumericValue($numericValue)
+ {
+ if ($this->isRunningPhp7OrOlder && \is_float($numericValue)) {
+ return str_replace(
+ [$this->localeInfo['thousands_sep'], $this->localeInfo['decimal_point']],
+ ['', '.'],
+ (string) $numericValue
+ );
+ }
+
+ return $numericValue;
+ }
+}
diff --git a/upstream-3.x/src/Common/Manager/OptionsManagerAbstract.php b/upstream-3.x/src/Common/Manager/OptionsManagerAbstract.php
new file mode 100644
index 0000000..3bfce39
--- /dev/null
+++ b/upstream-3.x/src/Common/Manager/OptionsManagerAbstract.php
@@ -0,0 +1,82 @@
+ OPTION_VALUE] */
+ private $options = [];
+
+ /**
+ * OptionsManagerAbstract constructor.
+ */
+ public function __construct()
+ {
+ $this->supportedOptions = $this->getSupportedOptions();
+ $this->setDefaultOptions();
+ }
+
+ /**
+ * Sets the given option, if this option is supported.
+ *
+ * @param string $optionName
+ * @param mixed $optionValue
+ */
+ public function setOption($optionName, $optionValue)
+ {
+ if (\in_array($optionName, $this->supportedOptions, true)) {
+ $this->options[$optionName] = $optionValue;
+ }
+ }
+
+ /**
+ * Add an option to the internal list of options
+ * Used only for mergeCells() for now.
+ *
+ * @param mixed $optionName
+ * @param mixed $optionValue
+ */
+ public function addOption($optionName, $optionValue)
+ {
+ if (\in_array($optionName, $this->supportedOptions, true)) {
+ if (!isset($this->options[$optionName])) {
+ $this->options[$optionName] = [];
+ } elseif (!\is_array($this->options[$optionName])) {
+ $this->options[$optionName] = [$this->options[$optionName]];
+ }
+ $this->options[$optionName][] = $optionValue;
+ }
+ }
+
+ /**
+ * @param string $optionName
+ *
+ * @return null|mixed The set option or NULL if no option with given name found
+ */
+ public function getOption($optionName)
+ {
+ $optionValue = null;
+
+ if (isset($this->options[$optionName])) {
+ $optionValue = $this->options[$optionName];
+ }
+
+ return $optionValue;
+ }
+
+ /**
+ * @return array List of supported options
+ */
+ abstract protected function getSupportedOptions();
+
+ /**
+ * Sets the default options.
+ * To be overriden by child classes.
+ */
+ abstract protected function setDefaultOptions();
+}
diff --git a/upstream-3.x/src/Common/Manager/OptionsManagerInterface.php b/upstream-3.x/src/Common/Manager/OptionsManagerInterface.php
new file mode 100644
index 0000000..7913017
--- /dev/null
+++ b/upstream-3.x/src/Common/Manager/OptionsManagerInterface.php
@@ -0,0 +1,31 @@
+helperFactory = $helperFactory;
+ }
+
+ /**
+ * @param resource $filePointer Pointer to the CSV file to read
+ * @param OptionsManagerInterface $optionsManager
+ * @param GlobalFunctionsHelper $globalFunctionsHelper
+ *
+ * @return SheetIterator
+ */
+ public function createSheetIterator($filePointer, $optionsManager, $globalFunctionsHelper)
+ {
+ $rowIterator = $this->createRowIterator($filePointer, $optionsManager, $globalFunctionsHelper);
+ $sheet = $this->createSheet($rowIterator);
+
+ return new SheetIterator($sheet);
+ }
+
+ /**
+ * @param Cell[] $cells
+ *
+ * @return Row
+ */
+ public function createRow(array $cells = [])
+ {
+ return new Row($cells, null);
+ }
+
+ /**
+ * @param mixed $cellValue
+ *
+ * @return Cell
+ */
+ public function createCell($cellValue)
+ {
+ return new Cell($cellValue);
+ }
+
+ /**
+ * @return Row
+ */
+ public function createRowFromArray(array $cellValues = [])
+ {
+ $cells = array_map(function ($cellValue) {
+ return $this->createCell($cellValue);
+ }, $cellValues);
+
+ return $this->createRow($cells);
+ }
+
+ /**
+ * @param RowIterator $rowIterator
+ *
+ * @return Sheet
+ */
+ private function createSheet($rowIterator)
+ {
+ return new Sheet($rowIterator);
+ }
+
+ /**
+ * @param resource $filePointer Pointer to the CSV file to read
+ * @param OptionsManagerInterface $optionsManager
+ * @param GlobalFunctionsHelper $globalFunctionsHelper
+ *
+ * @return RowIterator
+ */
+ private function createRowIterator($filePointer, $optionsManager, $globalFunctionsHelper)
+ {
+ $encodingHelper = $this->helperFactory->createEncodingHelper($globalFunctionsHelper);
+
+ return new RowIterator($filePointer, $optionsManager, $encodingHelper, $this, $globalFunctionsHelper);
+ }
+}
diff --git a/upstream-3.x/src/Reader/CSV/Manager/OptionsManager.php b/upstream-3.x/src/Reader/CSV/Manager/OptionsManager.php
new file mode 100644
index 0000000..9772a43
--- /dev/null
+++ b/upstream-3.x/src/Reader/CSV/Manager/OptionsManager.php
@@ -0,0 +1,39 @@
+setOption(Options::SHOULD_FORMAT_DATES, false);
+ $this->setOption(Options::SHOULD_PRESERVE_EMPTY_ROWS, false);
+ $this->setOption(Options::FIELD_DELIMITER, ',');
+ $this->setOption(Options::FIELD_ENCLOSURE, '"');
+ $this->setOption(Options::ENCODING, EncodingHelper::ENCODING_UTF8);
+ }
+}
diff --git a/upstream-3.x/src/Reader/CSV/Reader.php b/upstream-3.x/src/Reader/CSV/Reader.php
new file mode 100644
index 0000000..d62b933
--- /dev/null
+++ b/upstream-3.x/src/Reader/CSV/Reader.php
@@ -0,0 +1,149 @@
+= 8.1 */
+ private $isRunningAtLeastPhp81;
+
+ public function __construct(
+ OptionsManagerInterface $optionsManager,
+ GlobalFunctionsHelper $globalFunctionsHelper,
+ InternalEntityFactoryInterface $entityFactory
+ ) {
+ parent::__construct($optionsManager, $globalFunctionsHelper, $entityFactory);
+ $this->isRunningAtLeastPhp81 = version_compare(PHP_VERSION, '8.1.0') >= 0;
+ }
+
+ /**
+ * Sets the field delimiter for the CSV.
+ * Needs to be called before opening the reader.
+ *
+ * @param string $fieldDelimiter Character that delimits fields
+ *
+ * @return Reader
+ */
+ public function setFieldDelimiter($fieldDelimiter)
+ {
+ $this->optionsManager->setOption(Options::FIELD_DELIMITER, $fieldDelimiter);
+
+ return $this;
+ }
+
+ /**
+ * Sets the field enclosure for the CSV.
+ * Needs to be called before opening the reader.
+ *
+ * @param string $fieldEnclosure Character that enclose fields
+ *
+ * @return Reader
+ */
+ public function setFieldEnclosure($fieldEnclosure)
+ {
+ $this->optionsManager->setOption(Options::FIELD_ENCLOSURE, $fieldEnclosure);
+
+ return $this;
+ }
+
+ /**
+ * Sets the encoding of the CSV file to be read.
+ * Needs to be called before opening the reader.
+ *
+ * @param string $encoding Encoding of the CSV file to be read
+ *
+ * @return Reader
+ */
+ public function setEncoding($encoding)
+ {
+ $this->optionsManager->setOption(Options::ENCODING, $encoding);
+
+ return $this;
+ }
+
+ /**
+ * Returns whether stream wrappers are supported.
+ *
+ * @return bool
+ */
+ protected function doesSupportStreamWrapper()
+ {
+ return true;
+ }
+
+ /**
+ * Opens the file at the given path to make it ready to be read.
+ * If setEncoding() was not called, it assumes that the file is encoded in UTF-8.
+ *
+ * @param string $filePath Path of the CSV file to be read
+ *
+ * @throws \OpenSpout\Common\Exception\IOException
+ */
+ protected function openReader($filePath)
+ {
+ // "auto_detect_line_endings" is deprecated in PHP 8.1
+ if (!$this->isRunningAtLeastPhp81) {
+ $this->originalAutoDetectLineEndings = ini_get('auto_detect_line_endings');
+ ini_set('auto_detect_line_endings', '1');
+ }
+
+ $this->filePointer = $this->globalFunctionsHelper->fopen($filePath, 'r');
+ if (!$this->filePointer) {
+ throw new IOException("Could not open file {$filePath} for reading.");
+ }
+
+ /** @var InternalEntityFactory $entityFactory */
+ $entityFactory = $this->entityFactory;
+
+ $this->sheetIterator = $entityFactory->createSheetIterator(
+ $this->filePointer,
+ $this->optionsManager,
+ $this->globalFunctionsHelper
+ );
+ }
+
+ /**
+ * Returns an iterator to iterate over sheets.
+ *
+ * @return SheetIterator To iterate over sheets
+ */
+ protected function getConcreteSheetIterator()
+ {
+ return $this->sheetIterator;
+ }
+
+ /**
+ * Closes the reader. To be used after reading the file.
+ */
+ protected function closeReader()
+ {
+ if (\is_resource($this->filePointer)) {
+ $this->globalFunctionsHelper->fclose($this->filePointer);
+ }
+
+ // "auto_detect_line_endings" is deprecated in PHP 8.1
+ if (!$this->isRunningAtLeastPhp81) {
+ ini_set('auto_detect_line_endings', $this->originalAutoDetectLineEndings);
+ }
+ }
+}
diff --git a/upstream-3.x/src/Reader/CSV/RowIterator.php b/upstream-3.x/src/Reader/CSV/RowIterator.php
new file mode 100644
index 0000000..497d266
--- /dev/null
+++ b/upstream-3.x/src/Reader/CSV/RowIterator.php
@@ -0,0 +1,249 @@
+filePointer = $filePointer;
+ $this->fieldDelimiter = $optionsManager->getOption(Options::FIELD_DELIMITER);
+ $this->fieldEnclosure = $optionsManager->getOption(Options::FIELD_ENCLOSURE);
+ $this->encoding = $optionsManager->getOption(Options::ENCODING);
+ $this->shouldPreserveEmptyRows = $optionsManager->getOption(Options::SHOULD_PRESERVE_EMPTY_ROWS);
+ $this->encodingHelper = $encodingHelper;
+ $this->entityFactory = $entityFactory;
+ $this->globalFunctionsHelper = $globalFunctionsHelper;
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind(): void
+ {
+ $this->rewindAndSkipBom();
+
+ $this->numReadRows = 0;
+ $this->rowBuffer = null;
+
+ $this->next();
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ */
+ #[\ReturnTypeWillChange]
+ public function valid(): bool
+ {
+ return $this->filePointer && !$this->hasReachedEndOfFile;
+ }
+
+ /**
+ * Move forward to next element. Reads data for the next unprocessed row.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ *
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
+ */
+ #[\ReturnTypeWillChange]
+ public function next(): void
+ {
+ $this->hasReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
+
+ if (!$this->hasReachedEndOfFile) {
+ $this->readDataForNextRow();
+ }
+ }
+
+ /**
+ * Return the current element from the buffer.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ */
+ #[\ReturnTypeWillChange]
+ public function current(): ?Row
+ {
+ return $this->rowBuffer;
+ }
+
+ /**
+ * Return the key of the current element.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ */
+ #[\ReturnTypeWillChange]
+ public function key(): int
+ {
+ return $this->numReadRows;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end(): void
+ {
+ // do nothing
+ }
+
+ /**
+ * This rewinds and skips the BOM if inserted at the beginning of the file
+ * by moving the file pointer after it, so that it is not read.
+ */
+ protected function rewindAndSkipBom()
+ {
+ $byteOffsetToSkipBom = $this->encodingHelper->getBytesOffsetToSkipBOM($this->filePointer, $this->encoding);
+
+ // sets the cursor after the BOM (0 means no BOM, so rewind it)
+ $this->globalFunctionsHelper->fseek($this->filePointer, $byteOffsetToSkipBom);
+ }
+
+ /**
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
+ */
+ protected function readDataForNextRow()
+ {
+ do {
+ $rowData = $this->getNextUTF8EncodedRow();
+ } while ($this->shouldReadNextRow($rowData));
+
+ if (false !== $rowData) {
+ // array_map will replace NULL values by empty strings
+ $rowDataBufferAsArray = array_map(function ($value) { return (string) $value; }, $rowData);
+ $this->rowBuffer = $this->entityFactory->createRowFromArray($rowDataBufferAsArray);
+ ++$this->numReadRows;
+ } else {
+ // If we reach this point, it means end of file was reached.
+ // This happens when the last lines are empty lines.
+ $this->hasReachedEndOfFile = true;
+ }
+ }
+
+ /**
+ * @param array|bool $currentRowData
+ *
+ * @return bool Whether the data for the current row can be returned or if we need to keep reading
+ */
+ protected function shouldReadNextRow($currentRowData)
+ {
+ $hasSuccessfullyFetchedRowData = (false !== $currentRowData);
+ $hasNowReachedEndOfFile = $this->globalFunctionsHelper->feof($this->filePointer);
+ $isEmptyLine = $this->isEmptyLine($currentRowData);
+
+ return
+ (!$hasSuccessfullyFetchedRowData && !$hasNowReachedEndOfFile)
+ || (!$this->shouldPreserveEmptyRows && $isEmptyLine)
+ ;
+ }
+
+ /**
+ * Returns the next row, converted if necessary to UTF-8.
+ * As fgetcsv() does not manage correctly encoding for non UTF-8 data,
+ * we remove manually whitespace with ltrim or rtrim (depending on the order of the bytes).
+ *
+ * @throws \OpenSpout\Common\Exception\EncodingConversionException If unable to convert data to UTF-8
+ *
+ * @return array|false The row for the current file pointer, encoded in UTF-8 or FALSE if nothing to read
+ */
+ protected function getNextUTF8EncodedRow()
+ {
+ $encodedRowData = $this->globalFunctionsHelper->fgetcsv($this->filePointer, self::MAX_READ_BYTES_PER_LINE, $this->fieldDelimiter, $this->fieldEnclosure);
+ if (false === $encodedRowData) {
+ return false;
+ }
+
+ foreach ($encodedRowData as $cellIndex => $cellValue) {
+ switch ($this->encoding) {
+ case EncodingHelper::ENCODING_UTF16_LE:
+ case EncodingHelper::ENCODING_UTF32_LE:
+ // remove whitespace from the beginning of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
+ $cellValue = ltrim($cellValue);
+
+ break;
+
+ case EncodingHelper::ENCODING_UTF16_BE:
+ case EncodingHelper::ENCODING_UTF32_BE:
+ // remove whitespace from the end of a string as fgetcsv() add extra whitespace when it try to explode non UTF-8 data
+ $cellValue = rtrim($cellValue);
+
+ break;
+ }
+
+ $encodedRowData[$cellIndex] = $this->encodingHelper->attemptConversionToUTF8($cellValue, $this->encoding);
+ }
+
+ return $encodedRowData;
+ }
+
+ /**
+ * @param array|bool $lineData Array containing the cells value for the line
+ *
+ * @return bool Whether the given line is empty
+ */
+ protected function isEmptyLine($lineData)
+ {
+ return \is_array($lineData) && 1 === \count($lineData) && null === $lineData[0];
+ }
+}
diff --git a/upstream-3.x/src/Reader/CSV/Sheet.php b/upstream-3.x/src/Reader/CSV/Sheet.php
new file mode 100644
index 0000000..f64b2ee
--- /dev/null
+++ b/upstream-3.x/src/Reader/CSV/Sheet.php
@@ -0,0 +1,59 @@
+rowIterator = $rowIterator;
+ }
+
+ /**
+ * @return \OpenSpout\Reader\CSV\RowIterator
+ */
+ public function getRowIterator()
+ {
+ return $this->rowIterator;
+ }
+
+ /**
+ * @return int Index of the sheet
+ */
+ public function getIndex()
+ {
+ return 0;
+ }
+
+ /**
+ * @return string Name of the sheet - empty string since CSV does not support that
+ */
+ public function getName()
+ {
+ return '';
+ }
+
+ /**
+ * @return bool Always TRUE as there is only one sheet
+ */
+ public function isActive()
+ {
+ return true;
+ }
+
+ /**
+ * @return bool Always TRUE as the only sheet is always visible
+ */
+ public function isVisible()
+ {
+ return true;
+ }
+}
diff --git a/upstream-3.x/src/Reader/CSV/SheetIterator.php b/upstream-3.x/src/Reader/CSV/SheetIterator.php
new file mode 100644
index 0000000..cbcf7c1
--- /dev/null
+++ b/upstream-3.x/src/Reader/CSV/SheetIterator.php
@@ -0,0 +1,89 @@
+sheet = $sheet;
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind(): void
+ {
+ $this->hasReadUniqueSheet = false;
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ */
+ #[\ReturnTypeWillChange]
+ public function valid(): bool
+ {
+ return !$this->hasReadUniqueSheet;
+ }
+
+ /**
+ * Move forward to next element.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ */
+ #[\ReturnTypeWillChange]
+ public function next(): void
+ {
+ $this->hasReadUniqueSheet = true;
+ }
+
+ /**
+ * Return the current element.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ */
+ #[\ReturnTypeWillChange]
+ public function current(): Sheet
+ {
+ return $this->sheet;
+ }
+
+ /**
+ * Return the key of the current element.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ */
+ #[\ReturnTypeWillChange]
+ public function key(): int
+ {
+ return 1;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end(): void
+ {
+ // do nothing
+ }
+}
diff --git a/upstream-3.x/src/Reader/Common/Creator/InternalEntityFactoryInterface.php b/upstream-3.x/src/Reader/Common/Creator/InternalEntityFactoryInterface.php
new file mode 100644
index 0000000..e9cee4f
--- /dev/null
+++ b/upstream-3.x/src/Reader/Common/Creator/InternalEntityFactoryInterface.php
@@ -0,0 +1,26 @@
+createGlobalFunctionsHelper();
+
+ return new CSVReader($optionsManager, $globalFunctionsHelper, $entityFactory);
+ }
+
+ /**
+ * @return XLSXReader
+ */
+ private static function createXLSXReader()
+ {
+ $optionsManager = new XLSXOptionsManager();
+ $helperFactory = new XLSXHelperFactory();
+ $managerFactory = new XLSXManagerFactory($helperFactory, new CachingStrategyFactory());
+ $entityFactory = new XLSXInternalEntityFactory($managerFactory, $helperFactory);
+ $globalFunctionsHelper = $helperFactory->createGlobalFunctionsHelper();
+
+ return new XLSXReader($optionsManager, $globalFunctionsHelper, $entityFactory, $managerFactory);
+ }
+
+ /**
+ * @return ODSReader
+ */
+ private static function createODSReader()
+ {
+ $optionsManager = new ODSOptionsManager();
+ $helperFactory = new ODSHelperFactory();
+ $managerFactory = new ODSManagerFactory();
+ $entityFactory = new ODSInternalEntityFactory($helperFactory, $managerFactory);
+ $globalFunctionsHelper = $helperFactory->createGlobalFunctionsHelper();
+
+ return new ODSReader($optionsManager, $globalFunctionsHelper, $entityFactory);
+ }
+}
diff --git a/upstream-3.x/src/Reader/Common/Entity/Options.php b/upstream-3.x/src/Reader/Common/Entity/Options.php
new file mode 100644
index 0000000..48e15a1
--- /dev/null
+++ b/upstream-3.x/src/Reader/Common/Entity/Options.php
@@ -0,0 +1,22 @@
+entityFactory = $entityFactory;
+ }
+
+ /**
+ * Detect whether a row is considered empty.
+ * An empty row has all of its cells empty.
+ *
+ * @return bool
+ */
+ public function isEmpty(Row $row)
+ {
+ foreach ($row->getCells() as $cell) {
+ if (!$cell->isEmpty()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Fills the missing indexes of a row with empty cells.
+ *
+ * @return Row
+ */
+ public function fillMissingIndexesWithEmptyCells(Row $row)
+ {
+ $numCells = $row->getNumCells();
+
+ if (0 === $numCells) {
+ return $row;
+ }
+
+ $rowCells = $row->getCells();
+ $maxCellIndex = $numCells;
+
+ /**
+ * If the row has empty cells, calling "setCellAtIndex" will add the cell
+ * but in the wrong place (the new cell is added at the end of the array).
+ * Therefore, we need to sort the array using keys to have proper order.
+ *
+ * @see https://github.com/box/spout/issues/740
+ */
+ $needsSorting = false;
+
+ for ($cellIndex = 0; $cellIndex < $maxCellIndex; ++$cellIndex) {
+ if (!isset($rowCells[$cellIndex])) {
+ $row->setCellAtIndex($this->entityFactory->createCell(''), $cellIndex);
+ $needsSorting = true;
+ }
+ }
+
+ if ($needsSorting) {
+ $rowCells = $row->getCells();
+ ksort($rowCells);
+ $row->setCells($rowCells);
+ }
+
+ return $row;
+ }
+}
diff --git a/upstream-3.x/src/Reader/Common/XMLProcessor.php b/upstream-3.x/src/Reader/Common/XMLProcessor.php
new file mode 100644
index 0000000..967c0ff
--- /dev/null
+++ b/upstream-3.x/src/Reader/Common/XMLProcessor.php
@@ -0,0 +1,151 @@
+xmlReader = $xmlReader;
+ }
+
+ /**
+ * @param string $nodeName A callback may be triggered when a node with this name is read
+ * @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
+ * @param callable $callback Callback to execute when the read node has the given name and type
+ *
+ * @return XMLProcessor
+ */
+ public function registerCallback($nodeName, $nodeType, $callback)
+ {
+ $callbackKey = $this->getCallbackKey($nodeName, $nodeType);
+ $this->callbacks[$callbackKey] = $this->getInvokableCallbackData($callback);
+
+ return $this;
+ }
+
+ /**
+ * Resumes the reading of the XML file where it was left off.
+ * Stops whenever a callback indicates that reading should stop or at the end of the file.
+ *
+ * @throws \OpenSpout\Reader\Exception\XMLProcessingException
+ */
+ public function readUntilStopped()
+ {
+ while ($this->xmlReader->read()) {
+ $nodeType = $this->xmlReader->nodeType;
+ $nodeNamePossiblyWithPrefix = $this->xmlReader->name;
+ $nodeNameWithoutPrefix = $this->xmlReader->localName;
+
+ $callbackData = $this->getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType);
+
+ if (null !== $callbackData) {
+ $callbackResponse = $this->invokeCallback($callbackData, [$this->xmlReader]);
+
+ if (self::PROCESSING_STOP === $callbackResponse) {
+ // stop reading
+ break;
+ }
+ }
+ }
+ }
+
+ /**
+ * @param string $nodeName Name of the node
+ * @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
+ *
+ * @return string Key used to store the associated callback
+ */
+ private function getCallbackKey($nodeName, $nodeType)
+ {
+ return "{$nodeName}{$nodeType}";
+ }
+
+ /**
+ * Because the callback can be a "protected" function, we don't want to use call_user_func() directly
+ * but instead invoke the callback using Reflection. This allows the invocation of "protected" functions.
+ * Since some functions can be called a lot, we pre-process the callback to only return the elements that
+ * will be needed to invoke the callback later.
+ *
+ * @param callable $callback Array reference to a callback: [OBJECT, METHOD_NAME]
+ *
+ * @return array Associative array containing the elements needed to invoke the callback using Reflection
+ */
+ private function getInvokableCallbackData($callback)
+ {
+ $callbackObject = $callback[0];
+ $callbackMethodName = $callback[1];
+ $reflectionMethod = new \ReflectionMethod(\get_class($callbackObject), $callbackMethodName);
+ $reflectionMethod->setAccessible(true);
+
+ return [
+ self::CALLBACK_REFLECTION_METHOD => $reflectionMethod,
+ self::CALLBACK_REFLECTION_OBJECT => $callbackObject,
+ ];
+ }
+
+ /**
+ * @param string $nodeNamePossiblyWithPrefix Name of the node, possibly prefixed
+ * @param string $nodeNameWithoutPrefix Name of the same node, un-prefixed
+ * @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END]
+ *
+ * @return null|array Callback data to be used for execution when a node of the given name/type is read or NULL if none found
+ */
+ private function getRegisteredCallbackData($nodeNamePossiblyWithPrefix, $nodeNameWithoutPrefix, $nodeType)
+ {
+ // With prefixed nodes, we should match if (by order of preference):
+ // 1. the callback was registered with the prefixed node name (e.g. "x:worksheet")
+ // 2. the callback was registered with the un-prefixed node name (e.g. "worksheet")
+ $callbackKeyForPossiblyPrefixedName = $this->getCallbackKey($nodeNamePossiblyWithPrefix, $nodeType);
+ $callbackKeyForUnPrefixedName = $this->getCallbackKey($nodeNameWithoutPrefix, $nodeType);
+ $hasPrefix = ($nodeNamePossiblyWithPrefix !== $nodeNameWithoutPrefix);
+
+ $callbackKeyToUse = $callbackKeyForUnPrefixedName;
+ if ($hasPrefix && isset($this->callbacks[$callbackKeyForPossiblyPrefixedName])) {
+ $callbackKeyToUse = $callbackKeyForPossiblyPrefixedName;
+ }
+
+ // Using isset here because it is way faster than array_key_exists...
+ return $this->callbacks[$callbackKeyToUse] ?? null;
+ }
+
+ /**
+ * @param array $callbackData Associative array containing data to invoke the callback using Reflection
+ * @param array $args Arguments to pass to the callback
+ *
+ * @return int Callback response
+ */
+ private function invokeCallback($callbackData, $args)
+ {
+ $reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD];
+ $callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT];
+
+ return $reflectionMethod->invokeArgs($callbackObject, $args);
+ }
+}
diff --git a/upstream-3.x/src/Reader/Exception/InvalidValueException.php b/upstream-3.x/src/Reader/Exception/InvalidValueException.php
new file mode 100644
index 0000000..9bbcebd
--- /dev/null
+++ b/upstream-3.x/src/Reader/Exception/InvalidValueException.php
@@ -0,0 +1,30 @@
+invalidValue = $invalidValue;
+ parent::__construct($message, $code, $previous);
+ }
+
+ /**
+ * @return mixed
+ */
+ public function getInvalidValue()
+ {
+ return $this->invalidValue;
+ }
+}
diff --git a/upstream-3.x/src/Reader/Exception/IteratorNotRewindableException.php b/upstream-3.x/src/Reader/Exception/IteratorNotRewindableException.php
new file mode 100644
index 0000000..06aac22
--- /dev/null
+++ b/upstream-3.x/src/Reader/Exception/IteratorNotRewindableException.php
@@ -0,0 +1,7 @@
+createStringsEscaper();
+
+ return new CellValueFormatter($shouldFormatDates, $escaper);
+ }
+
+ /**
+ * @param InternalEntityFactory $entityFactory
+ *
+ * @return SettingsHelper
+ */
+ public function createSettingsHelper($entityFactory)
+ {
+ return new SettingsHelper($entityFactory);
+ }
+
+ /**
+ * @return \OpenSpout\Common\Helper\Escaper\ODS
+ */
+ public function createStringsEscaper()
+ {
+ // @noinspection PhpUnnecessaryFullyQualifiedNameInspection
+ return new \OpenSpout\Common\Helper\Escaper\ODS();
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Creator/InternalEntityFactory.php b/upstream-3.x/src/Reader/ODS/Creator/InternalEntityFactory.php
new file mode 100644
index 0000000..960fec9
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Creator/InternalEntityFactory.php
@@ -0,0 +1,124 @@
+helperFactory = $helperFactory;
+ $this->managerFactory = $managerFactory;
+ }
+
+ /**
+ * @param string $filePath Path of the file to be read
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ *
+ * @return SheetIterator
+ */
+ public function createSheetIterator($filePath, $optionsManager)
+ {
+ $escaper = $this->helperFactory->createStringsEscaper();
+ $settingsHelper = $this->helperFactory->createSettingsHelper($this);
+
+ return new SheetIterator($filePath, $optionsManager, $escaper, $settingsHelper, $this);
+ }
+
+ /**
+ * @param XMLReader $xmlReader XML Reader
+ * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
+ * @param string $sheetName Name of the sheet
+ * @param bool $isSheetActive Whether the sheet was defined as active
+ * @param bool $isSheetVisible Whether the sheet is visible
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ *
+ * @return Sheet
+ */
+ public function createSheet($xmlReader, $sheetIndex, $sheetName, $isSheetActive, $isSheetVisible, $optionsManager)
+ {
+ $rowIterator = $this->createRowIterator($xmlReader, $optionsManager);
+
+ return new Sheet($rowIterator, $sheetIndex, $sheetName, $isSheetActive, $isSheetVisible);
+ }
+
+ /**
+ * @param Cell[] $cells
+ *
+ * @return Row
+ */
+ public function createRow(array $cells = [])
+ {
+ return new Row($cells, null);
+ }
+
+ /**
+ * @param mixed $cellValue
+ *
+ * @return Cell
+ */
+ public function createCell($cellValue)
+ {
+ return new Cell($cellValue);
+ }
+
+ /**
+ * @return XMLReader
+ */
+ public function createXMLReader()
+ {
+ return new XMLReader();
+ }
+
+ /**
+ * @return \ZipArchive
+ */
+ public function createZipArchive()
+ {
+ return new \ZipArchive();
+ }
+
+ /**
+ * @param XMLReader $xmlReader XML Reader
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ *
+ * @return RowIterator
+ */
+ private function createRowIterator($xmlReader, $optionsManager)
+ {
+ $shouldFormatDates = $optionsManager->getOption(Options::SHOULD_FORMAT_DATES);
+ $cellValueFormatter = $this->helperFactory->createCellValueFormatter($shouldFormatDates);
+ $xmlProcessor = $this->createXMLProcessor($xmlReader);
+ $rowManager = $this->managerFactory->createRowManager($this);
+
+ return new RowIterator($xmlReader, $optionsManager, $cellValueFormatter, $xmlProcessor, $rowManager, $this);
+ }
+
+ /**
+ * @param XMLReader $xmlReader
+ *
+ * @return XMLProcessor
+ */
+ private function createXMLProcessor($xmlReader)
+ {
+ return new XMLProcessor($xmlReader);
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Creator/ManagerFactory.php b/upstream-3.x/src/Reader/ODS/Creator/ManagerFactory.php
new file mode 100644
index 0000000..546069e
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Creator/ManagerFactory.php
@@ -0,0 +1,21 @@
+ ' ',
+ self::XML_NODE_TEXT_TAB => "\t",
+ self::XML_NODE_TEXT_LINE_BREAK => "\n",
+ ];
+
+ /**
+ * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings
+ * @param \OpenSpout\Common\Helper\Escaper\ODS $escaper Used to unescape XML data
+ */
+ public function __construct($shouldFormatDates, $escaper)
+ {
+ $this->shouldFormatDates = $shouldFormatDates;
+ $this->escaper = $escaper;
+ }
+
+ /**
+ * Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
+ *
+ * @see http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#refTable13
+ *
+ * @param \DOMElement $node
+ *
+ * @throws InvalidValueException If the node value is not valid
+ *
+ * @return bool|\DateInterval|\DateTime|float|int|string The value associated with the cell, empty string if cell's type is void/undefined
+ */
+ public function extractAndFormatNodeValue($node)
+ {
+ $cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE);
+
+ switch ($cellType) {
+ case self::CELL_TYPE_STRING:
+ return $this->formatStringCellValue($node);
+
+ case self::CELL_TYPE_FLOAT:
+ return $this->formatFloatCellValue($node);
+
+ case self::CELL_TYPE_BOOLEAN:
+ return $this->formatBooleanCellValue($node);
+
+ case self::CELL_TYPE_DATE:
+ return $this->formatDateCellValue($node);
+
+ case self::CELL_TYPE_TIME:
+ return $this->formatTimeCellValue($node);
+
+ case self::CELL_TYPE_CURRENCY:
+ return $this->formatCurrencyCellValue($node);
+
+ case self::CELL_TYPE_PERCENTAGE:
+ return $this->formatPercentageCellValue($node);
+
+ case self::CELL_TYPE_VOID:
+ default:
+ return '';
+ }
+ }
+
+ /**
+ * Returns the cell String value.
+ *
+ * @param \DOMElement $node
+ *
+ * @return string The value associated with the cell
+ */
+ protected function formatStringCellValue($node)
+ {
+ $pNodeValues = [];
+ $pNodes = $node->getElementsByTagName(self::XML_NODE_P);
+
+ foreach ($pNodes as $pNode) {
+ $pNodeValues[] = $this->extractTextValueFromNode($pNode);
+ }
+
+ $escapedCellValue = implode("\n", $pNodeValues);
+
+ return $this->escaper->unescape($escapedCellValue);
+ }
+
+ /**
+ * Returns the cell Numeric value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return float|int The value associated with the cell
+ */
+ protected function formatFloatCellValue($node)
+ {
+ $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
+
+ $nodeIntValue = (int) $nodeValue;
+ $nodeFloatValue = (float) $nodeValue;
+
+ return ((float) $nodeIntValue === $nodeFloatValue) ? $nodeIntValue : $nodeFloatValue;
+ }
+
+ /**
+ * Returns the cell Boolean value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return bool The value associated with the cell
+ */
+ protected function formatBooleanCellValue($node)
+ {
+ $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_BOOLEAN_VALUE);
+
+ return (bool) $nodeValue;
+ }
+
+ /**
+ * Returns the cell Date value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @throws InvalidValueException If the value is not a valid date
+ *
+ * @return \DateTime|string The value associated with the cell
+ */
+ protected function formatDateCellValue($node)
+ {
+ // The XML node looks like this:
+ //
+ // 05/19/16 04:39 PM
+ //
+
+ if ($this->shouldFormatDates) {
+ // The date is already formatted in the "p" tag
+ $nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
+ $cellValue = $nodeWithValueAlreadyFormatted->nodeValue;
+ } else {
+ // otherwise, get it from the "date-value" attribute
+ $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_DATE_VALUE);
+
+ try {
+ $cellValue = new \DateTime($nodeValue);
+ } catch (\Exception $e) {
+ throw new InvalidValueException($nodeValue);
+ }
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns the cell Time value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @throws InvalidValueException If the value is not a valid time
+ *
+ * @return \DateInterval|string The value associated with the cell
+ */
+ protected function formatTimeCellValue($node)
+ {
+ // The XML node looks like this:
+ //
+ // 01:24:00 PM
+ //
+
+ if ($this->shouldFormatDates) {
+ // The date is already formatted in the "p" tag
+ $nodeWithValueAlreadyFormatted = $node->getElementsByTagName(self::XML_NODE_P)->item(0);
+ $cellValue = $nodeWithValueAlreadyFormatted->nodeValue;
+ } else {
+ // otherwise, get it from the "time-value" attribute
+ $nodeValue = $node->getAttribute(self::XML_ATTRIBUTE_TIME_VALUE);
+
+ try {
+ $cellValue = new \DateInterval($nodeValue);
+ } catch (\Exception $e) {
+ throw new InvalidValueException($nodeValue);
+ }
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns the cell Currency value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return string The value associated with the cell (e.g. "100 USD" or "9.99 EUR")
+ */
+ protected function formatCurrencyCellValue($node)
+ {
+ $value = $node->getAttribute(self::XML_ATTRIBUTE_VALUE);
+ $currency = $node->getAttribute(self::XML_ATTRIBUTE_CURRENCY);
+
+ return "{$value} {$currency}";
+ }
+
+ /**
+ * Returns the cell Percentage value from the given node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return float|int The value associated with the cell
+ */
+ protected function formatPercentageCellValue($node)
+ {
+ // percentages are formatted like floats
+ return $this->formatFloatCellValue($node);
+ }
+
+ /**
+ * @param \DOMNode $pNode
+ *
+ * @return string
+ */
+ private function extractTextValueFromNode($pNode)
+ {
+ $textValue = '';
+
+ foreach ($pNode->childNodes as $childNode) {
+ if ($childNode instanceof \DOMText) {
+ $textValue .= $childNode->nodeValue;
+ } elseif ($this->isWhitespaceNode($childNode->nodeName)) {
+ $textValue .= $this->transformWhitespaceNode($childNode);
+ } elseif (self::XML_NODE_TEXT_A === $childNode->nodeName || self::XML_NODE_TEXT_SPAN === $childNode->nodeName) {
+ $textValue .= $this->extractTextValueFromNode($childNode);
+ }
+ }
+
+ return $textValue;
+ }
+
+ /**
+ * Returns whether the given node is a whitespace node. It must be one of these:
+ * -
+ * -
+ * - .
+ *
+ * @param string $nodeName
+ *
+ * @return bool
+ */
+ private function isWhitespaceNode($nodeName)
+ {
+ return isset(self::$WHITESPACE_XML_NODES[$nodeName]);
+ }
+
+ /**
+ * The "" node can contain the string value directly
+ * or contain child elements. In this case, whitespaces contain in
+ * the child elements should be replaced by their XML equivalent:
+ * - space =>
+ * - tab =>
+ * - line break => .
+ *
+ * @see https://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1415200_253892949
+ *
+ * @param \DOMElement $node The XML node representing a whitespace
+ *
+ * @return string The corresponding whitespace value
+ */
+ private function transformWhitespaceNode($node)
+ {
+ $countAttribute = $node->getAttribute(self::XML_ATTRIBUTE_C); // only defined for ""
+ $numWhitespaces = (!empty($countAttribute)) ? (int) $countAttribute : 1;
+
+ return str_repeat(self::$WHITESPACE_XML_NODES[$node->nodeName], $numWhitespaces);
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Helper/SettingsHelper.php b/upstream-3.x/src/Reader/ODS/Helper/SettingsHelper.php
new file mode 100644
index 0000000..4463eeb
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Helper/SettingsHelper.php
@@ -0,0 +1,61 @@
+entityFactory = $entityFactory;
+ }
+
+ /**
+ * @param string $filePath Path of the file to be read
+ *
+ * @return null|string Name of the sheet that was defined as active or NULL if none found
+ */
+ public function getActiveSheetName($filePath)
+ {
+ $xmlReader = $this->entityFactory->createXMLReader();
+ if (false === $xmlReader->openFileInZip($filePath, self::SETTINGS_XML_FILE_PATH)) {
+ return null;
+ }
+
+ $activeSheetName = null;
+
+ try {
+ while ($xmlReader->readUntilNodeFound(self::XML_NODE_CONFIG_ITEM)) {
+ if (self::XML_ATTRIBUTE_VALUE_ACTIVE_TABLE === $xmlReader->getAttribute(self::XML_ATTRIBUTE_CONFIG_NAME)) {
+ $activeSheetName = $xmlReader->readString();
+
+ break;
+ }
+ }
+ } catch (XMLProcessingException $exception) {
+ // do nothing
+ }
+
+ $xmlReader->close();
+
+ return $activeSheetName;
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Manager/OptionsManager.php b/upstream-3.x/src/Reader/ODS/Manager/OptionsManager.php
new file mode 100644
index 0000000..e13c544
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Manager/OptionsManager.php
@@ -0,0 +1,32 @@
+setOption(Options::SHOULD_FORMAT_DATES, false);
+ $this->setOption(Options::SHOULD_PRESERVE_EMPTY_ROWS, false);
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Reader.php b/upstream-3.x/src/Reader/ODS/Reader.php
new file mode 100644
index 0000000..8f3970a
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Reader.php
@@ -0,0 +1,73 @@
+entityFactory;
+
+ $this->zip = $entityFactory->createZipArchive();
+
+ if (true === $this->zip->open($filePath)) {
+ /** @var InternalEntityFactory $entityFactory */
+ $entityFactory = $this->entityFactory;
+ $this->sheetIterator = $entityFactory->createSheetIterator($filePath, $this->optionsManager);
+ } else {
+ throw new IOException("Could not open {$filePath} for reading.");
+ }
+ }
+
+ /**
+ * Returns an iterator to iterate over sheets.
+ *
+ * @return SheetIterator To iterate over sheets
+ */
+ protected function getConcreteSheetIterator()
+ {
+ return $this->sheetIterator;
+ }
+
+ /**
+ * Closes the reader. To be used after reading the file.
+ */
+ protected function closeReader()
+ {
+ if (null !== $this->zip) {
+ $this->zip->close();
+ }
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/RowIterator.php b/upstream-3.x/src/Reader/ODS/RowIterator.php
new file mode 100644
index 0000000..a3ab7aa
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/RowIterator.php
@@ -0,0 +1,388 @@
+" element
+ * @param OptionsManagerInterface $optionsManager Reader's options manager
+ * @param CellValueFormatter $cellValueFormatter Helper to format cell values
+ * @param XMLProcessor $xmlProcessor Helper to process XML files
+ * @param RowManager $rowManager Manages rows
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ */
+ public function __construct(
+ XMLReader $xmlReader,
+ OptionsManagerInterface $optionsManager,
+ CellValueFormatter $cellValueFormatter,
+ XMLProcessor $xmlProcessor,
+ RowManager $rowManager,
+ InternalEntityFactory $entityFactory
+ ) {
+ $this->xmlReader = $xmlReader;
+ $this->shouldPreserveEmptyRows = $optionsManager->getOption(Options::SHOULD_PRESERVE_EMPTY_ROWS);
+ $this->cellValueFormatter = $cellValueFormatter;
+ $this->entityFactory = $entityFactory;
+ $this->rowManager = $rowManager;
+
+ // Register all callbacks to process different nodes when reading the XML file
+ $this->xmlProcessor = $xmlProcessor;
+ $this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_TABLE, XMLProcessor::NODE_TYPE_END, [$this, 'processTableEndingNode']);
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ * NOTE: It can only be done once, as it is not possible to read an XML file backwards.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ *
+ * @throws \OpenSpout\Reader\Exception\IteratorNotRewindableException If the iterator is rewound more than once
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind(): void
+ {
+ // Because sheet and row data is located in the file, we can't rewind both the
+ // sheet iterator and the row iterator, as XML file cannot be read backwards.
+ // Therefore, rewinding the row iterator has been disabled.
+ if ($this->hasAlreadyBeenRewound) {
+ throw new IteratorNotRewindableException();
+ }
+
+ $this->hasAlreadyBeenRewound = true;
+ $this->lastRowIndexProcessed = 0;
+ $this->nextRowIndexToBeProcessed = 1;
+ $this->rowBuffer = null;
+ $this->hasReachedEndOfFile = false;
+
+ $this->next();
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ */
+ #[\ReturnTypeWillChange]
+ public function valid(): bool
+ {
+ return !$this->hasReachedEndOfFile;
+ }
+
+ /**
+ * Move forward to next element. Empty rows will be skipped.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ *
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
+ * @throws \OpenSpout\Common\Exception\IOException If unable to read the sheet data XML
+ */
+ #[\ReturnTypeWillChange]
+ public function next(): void
+ {
+ if ($this->doesNeedDataForNextRowToBeProcessed()) {
+ $this->readDataForNextRow();
+ }
+
+ ++$this->lastRowIndexProcessed;
+ }
+
+ /**
+ * Return the current element, from the buffer.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ */
+ #[\ReturnTypeWillChange]
+ public function current(): Row
+ {
+ return $this->rowBuffer;
+ }
+
+ /**
+ * Return the key of the current element.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ */
+ #[\ReturnTypeWillChange]
+ public function key(): int
+ {
+ return $this->lastRowIndexProcessed;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end(): void
+ {
+ $this->xmlReader->close();
+ }
+
+ /**
+ * Returns whether we need data for the next row to be processed.
+ * We DO need to read data if:
+ * - we have not read any rows yet
+ * OR
+ * - the next row to be processed immediately follows the last read row.
+ *
+ * @return bool whether we need data for the next row to be processed
+ */
+ protected function doesNeedDataForNextRowToBeProcessed()
+ {
+ $hasReadAtLeastOneRow = (0 !== $this->lastRowIndexProcessed);
+
+ return
+ !$hasReadAtLeastOneRow
+ || $this->lastRowIndexProcessed === $this->nextRowIndexToBeProcessed - 1
+ ;
+ }
+
+ /**
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
+ * @throws \OpenSpout\Common\Exception\IOException If unable to read the sheet data XML
+ */
+ protected function readDataForNextRow()
+ {
+ $this->currentlyProcessedRow = $this->entityFactory->createRow();
+
+ try {
+ $this->xmlProcessor->readUntilStopped();
+ } catch (XMLProcessingException $exception) {
+ throw new IOException("The sheet's data cannot be read. [{$exception->getMessage()}]");
+ }
+
+ $this->rowBuffer = $this->currentlyProcessedRow;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processRowStartingNode($xmlReader)
+ {
+ // Reset data from current row
+ $this->hasAlreadyReadOneCellInCurrentRow = false;
+ $this->lastProcessedCell = null;
+ $this->numColumnsRepeated = 1;
+ $this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader);
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processCellStartingNode($xmlReader)
+ {
+ $currentNumColumnsRepeated = $this->getNumColumnsRepeatedForCurrentNode($xmlReader);
+
+ // NOTE: expand() will automatically decode all XML entities of the child nodes
+ /** @var \DOMElement $node */
+ $node = $xmlReader->expand();
+ $currentCell = $this->getCell($node);
+
+ // process cell N only after having read cell N+1 (see below why)
+ if ($this->hasAlreadyReadOneCellInCurrentRow) {
+ for ($i = 0; $i < $this->numColumnsRepeated; ++$i) {
+ $this->currentlyProcessedRow->addCell($this->lastProcessedCell);
+ }
+ }
+
+ $this->hasAlreadyReadOneCellInCurrentRow = true;
+ $this->lastProcessedCell = $currentCell;
+ $this->numColumnsRepeated = $currentNumColumnsRepeated;
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processRowEndingNode()
+ {
+ $isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRow, $this->lastProcessedCell);
+
+ // if the fetched row is empty and we don't want to preserve it...
+ if (!$this->shouldPreserveEmptyRows && $isEmptyRow) {
+ // ... skip it
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ // if the row is empty, we don't want to return more than one cell
+ $actualNumColumnsRepeated = (!$isEmptyRow) ? $this->numColumnsRepeated : 1;
+ $numCellsInCurrentlyProcessedRow = $this->currentlyProcessedRow->getNumCells();
+
+ // Only add the value if the last read cell is not a trailing empty cell repeater in Excel.
+ // The current count of read columns is determined by counting the values in "$this->currentlyProcessedRowData".
+ // This is to avoid creating a lot of empty cells, as Excel adds a last empty ""
+ // with a number-columns-repeated value equals to the number of (supported columns - used columns).
+ // In Excel, the number of supported columns is 16384, but we don't want to returns rows with
+ // always 16384 cells.
+ if (($numCellsInCurrentlyProcessedRow + $actualNumColumnsRepeated) !== self::MAX_COLUMNS_EXCEL) {
+ for ($i = 0; $i < $actualNumColumnsRepeated; ++$i) {
+ $this->currentlyProcessedRow->addCell($this->lastProcessedCell);
+ }
+ }
+
+ // If we are processing row N and the row is repeated M times,
+ // then the next row to be processed will be row (N+M).
+ $this->nextRowIndexToBeProcessed += $this->numRowsRepeated;
+
+ // at this point, we have all the data we need for the row
+ // so that we can populate the buffer
+ return XMLProcessor::PROCESSING_STOP;
+ }
+
+ /**
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processTableEndingNode()
+ {
+ // The closing "" marks the end of the file
+ $this->hasReachedEndOfFile = true;
+
+ return XMLProcessor::PROCESSING_STOP;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int The value of "table:number-rows-repeated" attribute of the current node, or 1 if attribute missing
+ */
+ protected function getNumRowsRepeatedForCurrentNode($xmlReader)
+ {
+ $numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED);
+
+ return (null !== $numRowsRepeated) ? (int) $numRowsRepeated : 1;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int The value of "table:number-columns-repeated" attribute of the current node, or 1 if attribute missing
+ */
+ protected function getNumColumnsRepeatedForCurrentNode($xmlReader)
+ {
+ $numColumnsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_COLUMNS_REPEATED);
+
+ return (null !== $numColumnsRepeated) ? (int) $numColumnsRepeated : 1;
+ }
+
+ /**
+ * Returns the cell with (unescaped) correctly marshalled, cell value associated to the given XML node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return Cell The cell set with the associated with the cell
+ */
+ protected function getCell($node)
+ {
+ try {
+ $cellValue = $this->cellValueFormatter->extractAndFormatNodeValue($node);
+ $cell = $this->entityFactory->createCell($cellValue);
+ } catch (InvalidValueException $exception) {
+ $cell = $this->entityFactory->createCell($exception->getInvalidValue());
+ $cell->setType(Cell::TYPE_ERROR);
+ }
+
+ return $cell;
+ }
+
+ /**
+ * After finishing processing each cell, a row is considered empty if it contains
+ * no cells or if the last read cell is empty.
+ * After finishing processing each cell, the last read cell is not part of the
+ * row data yet (as we still need to apply the "num-columns-repeated" attribute).
+ *
+ * @param Row $currentRow
+ * @param null|Cell $lastReadCell The last read cell
+ *
+ * @return bool Whether the row is empty
+ */
+ protected function isEmptyRow($currentRow, $lastReadCell)
+ {
+ return
+ $this->rowManager->isEmpty($currentRow)
+ && (!isset($lastReadCell) || $lastReadCell->isEmpty())
+ ;
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/Sheet.php b/upstream-3.x/src/Reader/ODS/Sheet.php
new file mode 100644
index 0000000..306f268
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/Sheet.php
@@ -0,0 +1,85 @@
+rowIterator = $rowIterator;
+ $this->index = $sheetIndex;
+ $this->name = $sheetName;
+ $this->isActive = $isSheetActive;
+ $this->isVisible = $isSheetVisible;
+ }
+
+ /**
+ * @return \OpenSpout\Reader\ODS\RowIterator
+ */
+ public function getRowIterator()
+ {
+ return $this->rowIterator;
+ }
+
+ /**
+ * @return int Index of the sheet, based on order in the workbook (zero-based)
+ */
+ public function getIndex()
+ {
+ return $this->index;
+ }
+
+ /**
+ * @return string Name of the sheet
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return bool Whether the sheet was defined as active
+ */
+ public function isActive()
+ {
+ return $this->isActive;
+ }
+
+ /**
+ * @return bool Whether the sheet is visible
+ */
+ public function isVisible()
+ {
+ return $this->isVisible;
+ }
+}
diff --git a/upstream-3.x/src/Reader/ODS/SheetIterator.php b/upstream-3.x/src/Reader/ODS/SheetIterator.php
new file mode 100644
index 0000000..5240e38
--- /dev/null
+++ b/upstream-3.x/src/Reader/ODS/SheetIterator.php
@@ -0,0 +1,239 @@
+ [IS_SHEET_VISIBLE] */
+ protected $sheetsVisibility;
+
+ /**
+ * @param string $filePath Path of the file to be read
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager
+ * @param \OpenSpout\Common\Helper\Escaper\ODS $escaper Used to unescape XML data
+ * @param SettingsHelper $settingsHelper Helper to get data from "settings.xml"
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ */
+ public function __construct($filePath, $optionsManager, $escaper, $settingsHelper, $entityFactory)
+ {
+ $this->filePath = $filePath;
+ $this->optionsManager = $optionsManager;
+ $this->entityFactory = $entityFactory;
+ $this->xmlReader = $entityFactory->createXMLReader();
+ $this->escaper = $escaper;
+ $this->activeSheetName = $settingsHelper->getActiveSheetName($filePath);
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to open the XML file containing sheets' data
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->xmlReader->close();
+
+ if (false === $this->xmlReader->openFileInZip($this->filePath, self::CONTENT_XML_FILE_PATH)) {
+ $contentXmlFilePath = $this->filePath.'#'.self::CONTENT_XML_FILE_PATH;
+
+ throw new IOException("Could not open \"{$contentXmlFilePath}\".");
+ }
+
+ try {
+ $this->sheetsVisibility = $this->readSheetsVisibility();
+ $this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
+ } catch (XMLProcessingException $exception) {
+ throw new IOException("The content.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
+ }
+
+ $this->currentSheetIndex = 0;
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ *
+ * @return bool
+ */
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return $this->hasFoundSheet;
+ }
+
+ /**
+ * Move forward to next element.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ */
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ $this->hasFoundSheet = $this->xmlReader->readUntilNodeFound(self::XML_NODE_TABLE);
+
+ if ($this->hasFoundSheet) {
+ ++$this->currentSheetIndex;
+ }
+ }
+
+ /**
+ * Return the current element.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ *
+ * @return \OpenSpout\Reader\ODS\Sheet
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME);
+ $sheetName = $this->escaper->unescape($escapedSheetName);
+
+ $isSheetActive = $this->isSheetActive($sheetName, $this->currentSheetIndex, $this->activeSheetName);
+
+ $sheetStyleName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_STYLE_NAME);
+ $isSheetVisible = $this->isSheetVisible($sheetStyleName);
+
+ return $this->entityFactory->createSheet(
+ $this->xmlReader,
+ $this->currentSheetIndex,
+ $sheetName,
+ $isSheetActive,
+ $isSheetVisible,
+ $this->optionsManager
+ );
+ }
+
+ /**
+ * Return the key of the current element.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ *
+ * @return int
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->currentSheetIndex + 1;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end()
+ {
+ $this->xmlReader->close();
+ }
+
+ /**
+ * Extracts the visibility of the sheets.
+ *
+ * @return array Associative array [STYLE_NAME] => [IS_SHEET_VISIBLE]
+ */
+ private function readSheetsVisibility()
+ {
+ $sheetsVisibility = [];
+
+ $this->xmlReader->readUntilNodeFound(self::XML_NODE_AUTOMATIC_STYLES);
+ /** @var \DOMElement $automaticStylesNode */
+ $automaticStylesNode = $this->xmlReader->expand();
+
+ $tableStyleNodes = $automaticStylesNode->getElementsByTagNameNS(self::XML_STYLE_NAMESPACE, self::XML_NODE_STYLE_TABLE_PROPERTIES);
+
+ /** @var \DOMElement $tableStyleNode */
+ foreach ($tableStyleNodes as $tableStyleNode) {
+ $isSheetVisible = ('false' !== $tableStyleNode->getAttribute(self::XML_ATTRIBUTE_TABLE_DISPLAY));
+
+ $parentStyleNode = $tableStyleNode->parentNode;
+ $styleName = $parentStyleNode->getAttribute(self::XML_ATTRIBUTE_STYLE_NAME);
+
+ $sheetsVisibility[$styleName] = $isSheetVisible;
+ }
+
+ return $sheetsVisibility;
+ }
+
+ /**
+ * Returns whether the current sheet was defined as the active one.
+ *
+ * @param string $sheetName Name of the current sheet
+ * @param int $sheetIndex Index of the current sheet
+ * @param null|string $activeSheetName Name of the sheet that was defined as active or NULL if none defined
+ *
+ * @return bool Whether the current sheet was defined as the active one
+ */
+ private function isSheetActive($sheetName, $sheetIndex, $activeSheetName)
+ {
+ // The given sheet is active if its name matches the defined active sheet's name
+ // or if no information about the active sheet was found, it defaults to the first sheet.
+ return
+ (null === $activeSheetName && 0 === $sheetIndex)
+ || ($activeSheetName === $sheetName)
+ ;
+ }
+
+ /**
+ * Returns whether the current sheet is visible.
+ *
+ * @param string $sheetStyleName Name of the sheet style
+ *
+ * @return bool Whether the current sheet is visible
+ */
+ private function isSheetVisible($sheetStyleName)
+ {
+ return $this->sheetsVisibility[$sheetStyleName] ??
+ true;
+ }
+}
diff --git a/upstream-3.x/src/Reader/ReaderAbstract.php b/upstream-3.x/src/Reader/ReaderAbstract.php
new file mode 100644
index 0000000..2b7c8b4
--- /dev/null
+++ b/upstream-3.x/src/Reader/ReaderAbstract.php
@@ -0,0 +1,236 @@
+optionsManager = $optionsManager;
+ $this->globalFunctionsHelper = $globalFunctionsHelper;
+ $this->entityFactory = $entityFactory;
+ }
+
+ /**
+ * Sets whether date/time values should be returned as PHP objects or be formatted as strings.
+ *
+ * @param bool $shouldFormatDates
+ *
+ * @return ReaderAbstract
+ */
+ public function setShouldFormatDates($shouldFormatDates)
+ {
+ $this->optionsManager->setOption(Options::SHOULD_FORMAT_DATES, $shouldFormatDates);
+
+ return $this;
+ }
+
+ /**
+ * Sets whether empty rows should be returned or skipped.
+ *
+ * @param bool $shouldPreserveEmptyRows
+ *
+ * @return ReaderAbstract
+ */
+ public function setShouldPreserveEmptyRows($shouldPreserveEmptyRows)
+ {
+ $this->optionsManager->setOption(Options::SHOULD_PRESERVE_EMPTY_ROWS, $shouldPreserveEmptyRows);
+
+ 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
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the file at the given path does not exist, is not readable or is corrupted
+ */
+ public function open($filePath)
+ {
+ if ($this->isStreamWrapper($filePath) && (!$this->doesSupportStreamWrapper() || !$this->isSupportedStreamWrapper($filePath))) {
+ throw new IOException("Could not open {$filePath} for reading! Stream wrapper used is not supported for this type of file.");
+ }
+
+ if (!$this->isPhpStream($filePath)) {
+ // we skip the checks if the provided file path points to a PHP stream
+ if (!$this->globalFunctionsHelper->file_exists($filePath)) {
+ throw new IOException("Could not open {$filePath} for reading! File does not exist.");
+ }
+ if (!$this->globalFunctionsHelper->is_readable($filePath)) {
+ throw new IOException("Could not open {$filePath} for reading! File is not readable.");
+ }
+ }
+
+ try {
+ $fileRealPath = $this->getFileRealPath($filePath);
+ $this->openReader($fileRealPath);
+ $this->isStreamOpened = true;
+ } catch (\Exception $exception) {
+ throw new IOException("Could not open {$filePath} for reading! ({$exception->getMessage()})");
+ }
+ }
+
+ /**
+ * Returns an iterator to iterate over sheets.
+ *
+ * @throws \OpenSpout\Reader\Exception\ReaderNotOpenedException If called before opening the reader
+ *
+ * @return SheetIteratorInterface To iterate over sheets
+ */
+ public function getSheetIterator()
+ {
+ if (!$this->isStreamOpened) {
+ throw new ReaderNotOpenedException('Reader should be opened first.');
+ }
+
+ return $this->getConcreteSheetIterator();
+ }
+
+ /**
+ * Closes the reader, preventing any additional reading.
+ */
+ public function close()
+ {
+ if ($this->isStreamOpened) {
+ $this->closeReader();
+
+ $sheetIterator = $this->getConcreteSheetIterator();
+ if (null !== $sheetIterator) {
+ $sheetIterator->end();
+ }
+
+ $this->isStreamOpened = false;
+ }
+ }
+
+ /**
+ * Returns whether stream wrappers are supported.
+ *
+ * @return bool
+ */
+ abstract protected function doesSupportStreamWrapper();
+
+ /**
+ * Opens the file at the given file path to make it ready to be read.
+ *
+ * @param string $filePath Path of the file to be read
+ */
+ abstract protected function openReader($filePath);
+
+ /**
+ * Returns an iterator to iterate over sheets.
+ *
+ * @return SheetIteratorInterface To iterate over sheets
+ */
+ abstract protected function getConcreteSheetIterator();
+
+ /**
+ * Closes the reader. To be used after reading the file.
+ */
+ abstract protected function closeReader();
+
+ /**
+ * Returns the real path of the given path.
+ * If the given path is a valid stream wrapper, returns the path unchanged.
+ *
+ * @param string $filePath
+ *
+ * @return string
+ */
+ protected function getFileRealPath($filePath)
+ {
+ if ($this->isSupportedStreamWrapper($filePath)) {
+ return $filePath;
+ }
+
+ // Need to use realpath to fix "Can't open file" on some Windows setup
+ return realpath($filePath);
+ }
+
+ /**
+ * Returns the scheme of the custom stream wrapper, if the path indicates a stream wrapper is used.
+ * For example, php://temp => php, s3://path/to/file => s3...
+ *
+ * @param string $filePath Path of the file to be read
+ *
+ * @return null|string The stream wrapper scheme or NULL if not a stream wrapper
+ */
+ protected function getStreamWrapperScheme($filePath)
+ {
+ $streamScheme = null;
+ if (preg_match('/^(\w+):\/\//', $filePath, $matches)) {
+ $streamScheme = $matches[1];
+ }
+
+ return $streamScheme;
+ }
+
+ /**
+ * Checks if the given path is an unsupported stream wrapper
+ * (like local path, php://temp, mystream://foo/bar...).
+ *
+ * @param string $filePath Path of the file to be read
+ *
+ * @return bool Whether the given path is an unsupported stream wrapper
+ */
+ protected function isStreamWrapper($filePath)
+ {
+ return null !== $this->getStreamWrapperScheme($filePath);
+ }
+
+ /**
+ * Checks if the given path is an supported stream wrapper
+ * (like php://temp, mystream://foo/bar...).
+ * If the given path is a local path, returns true.
+ *
+ * @param string $filePath Path of the file to be read
+ *
+ * @return bool Whether the given path is an supported stream wrapper
+ */
+ protected function isSupportedStreamWrapper($filePath)
+ {
+ $streamScheme = $this->getStreamWrapperScheme($filePath);
+
+ return (null !== $streamScheme) ?
+ \in_array($streamScheme, $this->globalFunctionsHelper->stream_get_wrappers(), true) :
+ true;
+ }
+
+ /**
+ * Checks if a path is a PHP stream (like php://output, php://memory, ...).
+ *
+ * @param string $filePath Path of the file to be read
+ *
+ * @return bool Whether the given path maps to a PHP stream
+ */
+ protected function isPhpStream($filePath)
+ {
+ $streamScheme = $this->getStreamWrapperScheme($filePath);
+
+ return 'php' === $streamScheme;
+ }
+}
diff --git a/upstream-3.x/src/Reader/ReaderInterface.php b/upstream-3.x/src/Reader/ReaderInterface.php
new file mode 100644
index 0000000..a1d9f74
--- /dev/null
+++ b/upstream-3.x/src/Reader/ReaderInterface.php
@@ -0,0 +1,33 @@
+initialUseInternalErrorsValue = libxml_use_internal_errors(true);
+ }
+
+ /**
+ * Throws an XMLProcessingException if an error occured.
+ * It also always resets the "libxml_use_internal_errors" setting back to its initial value.
+ *
+ * @throws \OpenSpout\Reader\Exception\XMLProcessingException
+ */
+ protected function resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured()
+ {
+ if ($this->hasXMLErrorOccured()) {
+ $this->resetXMLInternalErrorsSetting();
+
+ throw new XMLProcessingException($this->getLastXMLErrorMessage());
+ }
+
+ $this->resetXMLInternalErrorsSetting();
+ }
+
+ protected function resetXMLInternalErrorsSetting()
+ {
+ libxml_use_internal_errors($this->initialUseInternalErrorsValue);
+ }
+
+ /**
+ * Returns whether the a XML error has occured since the last time errors were cleared.
+ *
+ * @return bool TRUE if an error occured, FALSE otherwise
+ */
+ private function hasXMLErrorOccured()
+ {
+ return false !== libxml_get_last_error();
+ }
+
+ /**
+ * Returns the error message for the last XML error that occured.
+ *
+ * @see libxml_get_last_error
+ *
+ * @return null|string Last XML error message or null if no error
+ */
+ private function getLastXMLErrorMessage()
+ {
+ $errorMessage = null;
+ $error = libxml_get_last_error();
+
+ if (false !== $error) {
+ $errorMessage = trim($error->message);
+ }
+
+ return $errorMessage;
+ }
+}
diff --git a/upstream-3.x/src/Reader/Wrapper/XMLReader.php b/upstream-3.x/src/Reader/Wrapper/XMLReader.php
new file mode 100644
index 0000000..946ca27
--- /dev/null
+++ b/upstream-3.x/src/Reader/Wrapper/XMLReader.php
@@ -0,0 +1,192 @@
+getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath);
+
+ // We need to check first that the file we are trying to read really exist because:
+ // - PHP emits a warning when trying to open a file that does not exist.
+ // - HHVM does not check if file exists within zip file (@link https://github.com/facebook/hhvm/issues/5779)
+ if ($this->fileExistsWithinZip($realPathURI)) {
+ $wasOpenSuccessful = $this->open($realPathURI, null, LIBXML_NONET);
+ }
+
+ return $wasOpenSuccessful;
+ }
+
+ /**
+ * Returns the real path for the given path components.
+ * This is useful to avoid issues on some Windows setup.
+ *
+ * @param string $zipFilePath Path to the ZIP file
+ * @param string $fileInsideZipPath Relative or absolute path of the file inside the zip
+ *
+ * @return string The real path URI
+ */
+ public function getRealPathURIForFileInZip($zipFilePath, $fileInsideZipPath)
+ {
+ // The file path should not start with a '/', otherwise it won't be found
+ $fileInsideZipPathWithoutLeadingSlash = ltrim($fileInsideZipPath, '/');
+
+ return self::ZIP_WRAPPER.realpath($zipFilePath).'#'.$fileInsideZipPathWithoutLeadingSlash;
+ }
+
+ /**
+ * Move to next node in document.
+ *
+ * @see \XMLReader::read
+ *
+ * @throws \OpenSpout\Reader\Exception\XMLProcessingException If an error/warning occurred
+ *
+ * @return bool TRUE on success or FALSE on failure
+ */
+ #[\ReturnTypeWillChange]
+ public function read()
+ {
+ $this->useXMLInternalErrors();
+
+ $wasReadSuccessful = parent::read();
+
+ $this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
+
+ return $wasReadSuccessful;
+ }
+
+ /**
+ * Read until the element with the given name is found, or the end of the file.
+ *
+ * @param string $nodeName Name of the node to find
+ *
+ * @throws \OpenSpout\Reader\Exception\XMLProcessingException If an error/warning occurred
+ *
+ * @return bool TRUE on success or FALSE on failure
+ */
+ public function readUntilNodeFound($nodeName)
+ {
+ do {
+ $wasReadSuccessful = $this->read();
+ $isNotPositionedOnStartingNode = !$this->isPositionedOnStartingNode($nodeName);
+ } while ($wasReadSuccessful && $isNotPositionedOnStartingNode);
+
+ return $wasReadSuccessful;
+ }
+
+ /**
+ * Move cursor to next node skipping all subtrees.
+ *
+ * @see \XMLReader::next
+ *
+ * @param null|string $localName The name of the next node to move to
+ *
+ * @throws \OpenSpout\Reader\Exception\XMLProcessingException If an error/warning occurred
+ *
+ * @return bool TRUE on success or FALSE on failure
+ */
+ #[\ReturnTypeWillChange]
+ public function next($localName = null)
+ {
+ $this->useXMLInternalErrors();
+
+ $wasNextSuccessful = parent::next($localName);
+
+ $this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured();
+
+ return $wasNextSuccessful;
+ }
+
+ /**
+ * @param string $nodeName
+ *
+ * @return bool Whether the XML Reader is currently positioned on the starting node with given name
+ */
+ public function isPositionedOnStartingNode($nodeName)
+ {
+ return $this->isPositionedOnNode($nodeName, self::ELEMENT);
+ }
+
+ /**
+ * @param string $nodeName
+ *
+ * @return bool Whether the XML Reader is currently positioned on the ending node with given name
+ */
+ public function isPositionedOnEndingNode($nodeName)
+ {
+ return $this->isPositionedOnNode($nodeName, self::END_ELEMENT);
+ }
+
+ /**
+ * @return string The name of the current node, un-prefixed
+ */
+ public function getCurrentNodeName()
+ {
+ return $this->localName;
+ }
+
+ /**
+ * Returns whether the file at the given location exists.
+ *
+ * @param string $zipStreamURI URI of a zip stream, e.g. "zip://file.zip#path/inside.xml"
+ *
+ * @return bool TRUE if the file exists, FALSE otherwise
+ */
+ protected function fileExistsWithinZip($zipStreamURI)
+ {
+ $doesFileExists = false;
+
+ $pattern = '/zip:\/\/([^#]+)#(.*)/';
+ if (preg_match($pattern, $zipStreamURI, $matches)) {
+ $zipFilePath = $matches[1];
+ $innerFilePath = $matches[2];
+
+ $zip = new \ZipArchive();
+ if (true === $zip->open($zipFilePath)) {
+ $doesFileExists = (false !== $zip->locateName($innerFilePath));
+ $zip->close();
+ }
+ }
+
+ return $doesFileExists;
+ }
+
+ /**
+ * @param string $nodeName
+ * @param int $nodeType
+ *
+ * @return bool Whether the XML Reader is currently positioned on the node with given name and type
+ */
+ private function isPositionedOnNode($nodeName, $nodeType)
+ {
+ /**
+ * In some cases, the node has a prefix (for instance, "" can also be "").
+ * So if the given node name does not have a prefix, we need to look at the unprefixed name ("localName").
+ *
+ * @see https://github.com/box/spout/issues/233
+ */
+ $hasPrefix = (false !== strpos($nodeName, ':'));
+ $currentNodeName = ($hasPrefix) ? $this->name : $this->localName;
+
+ return $this->nodeType === $nodeType && $currentNodeName === $nodeName;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Creator/HelperFactory.php b/upstream-3.x/src/Reader/XLSX/Creator/HelperFactory.php
new file mode 100644
index 0000000..3528b23
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Creator/HelperFactory.php
@@ -0,0 +1,38 @@
+createStringsEscaper();
+
+ return new CellValueFormatter($sharedStringsManager, $styleManager, $shouldFormatDates, $shouldUse1904Dates, $escaper);
+ }
+
+ /**
+ * @return Escaper\XLSX
+ */
+ public function createStringsEscaper()
+ {
+ // @noinspection PhpUnnecessaryFullyQualifiedNameInspection
+ return new Escaper\XLSX();
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Creator/InternalEntityFactory.php b/upstream-3.x/src/Reader/XLSX/Creator/InternalEntityFactory.php
new file mode 100644
index 0000000..33b18df
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Creator/InternalEntityFactory.php
@@ -0,0 +1,163 @@
+managerFactory = $managerFactory;
+ $this->helperFactory = $helperFactory;
+ }
+
+ /**
+ * @param string $filePath Path of the file to be read
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ * @param SharedStringsManager $sharedStringsManager Manages shared strings
+ *
+ * @return SheetIterator
+ */
+ public function createSheetIterator($filePath, $optionsManager, $sharedStringsManager)
+ {
+ $sheetManager = $this->managerFactory->createSheetManager(
+ $filePath,
+ $optionsManager,
+ $sharedStringsManager,
+ $this
+ );
+
+ return new SheetIterator($sheetManager);
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
+ * @param int $sheetIndex Index of the sheet, based on order in the workbook (zero-based)
+ * @param string $sheetName Name of the sheet
+ * @param bool $isSheetActive Whether the sheet was defined as active
+ * @param bool $isSheetVisible Whether the sheet is visible
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ * @param SharedStringsManager $sharedStringsManager Manages shared strings
+ *
+ * @return Sheet
+ */
+ public function createSheet(
+ $filePath,
+ $sheetDataXMLFilePath,
+ $sheetIndex,
+ $sheetName,
+ $isSheetActive,
+ $isSheetVisible,
+ $optionsManager,
+ $sharedStringsManager
+ ) {
+ $rowIterator = $this->createRowIterator($filePath, $sheetDataXMLFilePath, $optionsManager, $sharedStringsManager);
+
+ return new Sheet($rowIterator, $sheetIndex, $sheetName, $isSheetActive, $isSheetVisible);
+ }
+
+ /**
+ * @param Cell[] $cells
+ *
+ * @return Row
+ */
+ public function createRow(array $cells = [])
+ {
+ return new Row($cells, null);
+ }
+
+ /**
+ * @param mixed $cellValue
+ *
+ * @return Cell
+ */
+ public function createCell($cellValue)
+ {
+ return new Cell($cellValue);
+ }
+
+ /**
+ * @return \ZipArchive
+ */
+ public function createZipArchive()
+ {
+ return new \ZipArchive();
+ }
+
+ /**
+ * @return XMLReader
+ */
+ public function createXMLReader()
+ {
+ return new XMLReader();
+ }
+
+ /**
+ * @param XMLReader $xmlReader
+ *
+ * @return XMLProcessor
+ */
+ public function createXMLProcessor($xmlReader)
+ {
+ return new XMLProcessor($xmlReader);
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ * @param SharedStringsManager $sharedStringsManager Manages shared strings
+ *
+ * @return RowIterator
+ */
+ private function createRowIterator($filePath, $sheetDataXMLFilePath, $optionsManager, $sharedStringsManager)
+ {
+ $xmlReader = $this->createXMLReader();
+ $xmlProcessor = $this->createXMLProcessor($xmlReader);
+
+ $styleManager = $this->managerFactory->createStyleManager($filePath, $this);
+ $rowManager = $this->managerFactory->createRowManager($this);
+ $shouldFormatDates = $optionsManager->getOption(Options::SHOULD_FORMAT_DATES);
+ $shouldUse1904Dates = $optionsManager->getOption(Options::SHOULD_USE_1904_DATES);
+
+ $cellValueFormatter = $this->helperFactory->createCellValueFormatter(
+ $sharedStringsManager,
+ $styleManager,
+ $shouldFormatDates,
+ $shouldUse1904Dates
+ );
+
+ $shouldPreserveEmptyRows = $optionsManager->getOption(Options::SHOULD_PRESERVE_EMPTY_ROWS);
+
+ return new RowIterator(
+ $filePath,
+ $sheetDataXMLFilePath,
+ $shouldPreserveEmptyRows,
+ $xmlReader,
+ $xmlProcessor,
+ $cellValueFormatter,
+ $rowManager,
+ $this
+ );
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Creator/ManagerFactory.php b/upstream-3.x/src/Reader/XLSX/Creator/ManagerFactory.php
new file mode 100644
index 0000000..10a8833
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Creator/ManagerFactory.php
@@ -0,0 +1,109 @@
+helperFactory = $helperFactory;
+ $this->cachingStrategyFactory = $cachingStrategyFactory;
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param string $tempFolder Temporary folder where the temporary files to store shared strings will be stored
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ *
+ * @return SharedStringsManager
+ */
+ public function createSharedStringsManager($filePath, $tempFolder, $entityFactory)
+ {
+ $workbookRelationshipsManager = $this->createWorkbookRelationshipsManager($filePath, $entityFactory);
+
+ return new SharedStringsManager(
+ $filePath,
+ $tempFolder,
+ $workbookRelationshipsManager,
+ $entityFactory,
+ $this->helperFactory,
+ $this->cachingStrategyFactory
+ );
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param \OpenSpout\Common\Manager\OptionsManagerInterface $optionsManager Reader's options manager
+ * @param \OpenSpout\Reader\XLSX\Manager\SharedStringsManager $sharedStringsManager Manages shared strings
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ *
+ * @return SheetManager
+ */
+ public function createSheetManager($filePath, $optionsManager, $sharedStringsManager, $entityFactory)
+ {
+ $escaper = $this->helperFactory->createStringsEscaper();
+
+ return new SheetManager($filePath, $optionsManager, $sharedStringsManager, $escaper, $entityFactory);
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ *
+ * @return StyleManager
+ */
+ public function createStyleManager($filePath, $entityFactory)
+ {
+ $workbookRelationshipsManager = $this->createWorkbookRelationshipsManager($filePath, $entityFactory);
+
+ return new StyleManager($filePath, $workbookRelationshipsManager, $entityFactory);
+ }
+
+ /**
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ *
+ * @return RowManager
+ */
+ public function createRowManager($entityFactory)
+ {
+ return new RowManager($entityFactory);
+ }
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ *
+ * @return WorkbookRelationshipsManager
+ */
+ private function createWorkbookRelationshipsManager($filePath, $entityFactory)
+ {
+ if (!isset($this->cachedWorkbookRelationshipsManager)) {
+ $this->cachedWorkbookRelationshipsManager = new WorkbookRelationshipsManager($filePath, $entityFactory);
+ }
+
+ return $this->cachedWorkbookRelationshipsManager;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Helper/CellHelper.php b/upstream-3.x/src/Reader/XLSX/Helper/CellHelper.php
new file mode 100644
index 0000000..827d728
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Helper/CellHelper.php
@@ -0,0 +1,87 @@
+ 0, 'B' => 1, 'C' => 2, 'D' => 3, 'E' => 4, 'F' => 5, 'G' => 6,
+ 'H' => 7, 'I' => 8, 'J' => 9, 'K' => 10, 'L' => 11, 'M' => 12, 'N' => 13,
+ 'O' => 14, 'P' => 15, 'Q' => 16, 'R' => 17, 'S' => 18, 'T' => 19, 'U' => 20,
+ 'V' => 21, 'W' => 22, 'X' => 23, 'Y' => 24, 'Z' => 25,
+ ];
+
+ /**
+ * Returns the base 10 column index associated to the cell index (base 26).
+ * Excel uses A to Z letters for column indexing, where A is the 1st column,
+ * Z is the 26th and AA is the 27th.
+ * The mapping is zero based, so that A1 maps to 0, B2 maps to 1, Z13 to 25 and AA4 to 26.
+ *
+ * @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
+ *
+ * @throws \OpenSpout\Common\Exception\InvalidArgumentException When the given cell index is invalid
+ *
+ * @return int
+ */
+ public static function getColumnIndexFromCellIndex($cellIndex)
+ {
+ if (!self::isValidCellIndex($cellIndex)) {
+ throw new InvalidArgumentException('Cannot get column index from an invalid cell index.');
+ }
+
+ $columnIndex = 0;
+
+ // Remove row information
+ $columnLetters = preg_replace('/\d/', '', $cellIndex);
+
+ // strlen() is super slow too... Using isset() is way faster and not too unreadable,
+ // since we checked before that there are between 1 and 3 letters.
+ $columnLength = isset($columnLetters[1]) ? (isset($columnLetters[2]) ? 3 : 2) : 1;
+
+ // Looping over the different letters of the column is slower than this method.
+ // Also, not using the pow() function because it's slooooow...
+ switch ($columnLength) {
+ case 1:
+ $columnIndex = (self::$columnLetterToIndexMapping[$columnLetters]);
+
+ break;
+
+ case 2:
+ $firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 26;
+ $secondLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[1]];
+ $columnIndex = $firstLetterIndex + $secondLetterIndex;
+
+ break;
+
+ case 3:
+ $firstLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[0]] + 1) * 676;
+ $secondLetterIndex = (self::$columnLetterToIndexMapping[$columnLetters[1]] + 1) * 26;
+ $thirdLetterIndex = self::$columnLetterToIndexMapping[$columnLetters[2]];
+ $columnIndex = $firstLetterIndex + $secondLetterIndex + $thirdLetterIndex;
+
+ break;
+ }
+
+ return $columnIndex;
+ }
+
+ /**
+ * Returns whether a cell index is valid, in an Excel world.
+ * To be valid, the cell index should start with capital letters and be followed by numbers.
+ * There can only be 3 letters, as there can only be 16,384 rows, which is equivalent to 'XFE'.
+ *
+ * @param string $cellIndex The Excel cell index ('A1', 'BC13', ...)
+ *
+ * @return bool
+ */
+ protected static function isValidCellIndex($cellIndex)
+ {
+ return 1 === preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex);
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Helper/CellValueFormatter.php b/upstream-3.x/src/Reader/XLSX/Helper/CellValueFormatter.php
new file mode 100644
index 0000000..1734fb5
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Helper/CellValueFormatter.php
@@ -0,0 +1,315 @@
+sharedStringsManager = $sharedStringsManager;
+ $this->styleManager = $styleManager;
+ $this->shouldFormatDates = $shouldFormatDates;
+ $this->shouldUse1904Dates = $shouldUse1904Dates;
+ $this->escaper = $escaper;
+ }
+
+ /**
+ * Returns the (unescaped) correctly marshalled, cell value associated to the given XML node.
+ *
+ * @param \DOMElement $node
+ *
+ * @throws InvalidValueException If the value is not valid
+ *
+ * @return bool|\DateTime|float|int|string The value associated with the cell
+ */
+ public function extractAndFormatNodeValue($node)
+ {
+ // Default cell type is "n"
+ $cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE) ?: self::CELL_TYPE_NUMERIC;
+ $cellStyleId = (int) $node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID);
+ $vNodeValue = $this->getVNodeValue($node);
+
+ if (('' === $vNodeValue) && (self::CELL_TYPE_INLINE_STRING !== $cellType)) {
+ return $vNodeValue;
+ }
+
+ switch ($cellType) {
+ case self::CELL_TYPE_INLINE_STRING:
+ return $this->formatInlineStringCellValue($node);
+
+ case self::CELL_TYPE_SHARED_STRING:
+ return $this->formatSharedStringCellValue($vNodeValue);
+
+ case self::CELL_TYPE_STR:
+ return $this->formatStrCellValue($vNodeValue);
+
+ case self::CELL_TYPE_BOOLEAN:
+ return $this->formatBooleanCellValue($vNodeValue);
+
+ case self::CELL_TYPE_NUMERIC:
+ return $this->formatNumericCellValue($vNodeValue, $cellStyleId);
+
+ case self::CELL_TYPE_DATE:
+ return $this->formatDateCellValue($vNodeValue);
+
+ default:
+ throw new InvalidValueException($vNodeValue);
+ }
+ }
+
+ /**
+ * Returns the cell's string value from a node's nested value node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return string The value associated with the cell
+ */
+ protected function getVNodeValue($node)
+ {
+ // for cell types having a "v" tag containing the value.
+ // if not, the returned value should be empty string.
+ $vNode = $node->getElementsByTagName(self::XML_NODE_VALUE)->item(0);
+
+ return (null !== $vNode) ? $vNode->nodeValue : '';
+ }
+
+ /**
+ * Returns the cell String value where string is inline.
+ *
+ * @param \DOMElement $node
+ *
+ * @return string The value associated with the cell
+ */
+ protected function formatInlineStringCellValue($node)
+ {
+ // inline strings are formatted this way (they can contain any number of nodes):
+ // [INLINE_STRING][INLINE_STRING_2]
+ $tNodes = $node->getElementsByTagName(self::XML_NODE_INLINE_STRING_VALUE);
+
+ $cellValue = '';
+ for ($i = 0; $i < $tNodes->count(); ++$i) {
+ $tNode = $tNodes->item($i);
+ $cellValue .= $this->escaper->unescape($tNode->nodeValue);
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns the cell String value from shared-strings file using nodeValue index.
+ *
+ * @param string $nodeValue
+ *
+ * @return string The value associated with the cell
+ */
+ protected function formatSharedStringCellValue($nodeValue)
+ {
+ // shared strings are formatted this way:
+ // [SHARED_STRING_INDEX]
+ $sharedStringIndex = (int) $nodeValue;
+ $escapedCellValue = $this->sharedStringsManager->getStringAtIndex($sharedStringIndex);
+
+ return $this->escaper->unescape($escapedCellValue);
+ }
+
+ /**
+ * Returns the cell String value, where string is stored in value node.
+ *
+ * @param string $nodeValue
+ *
+ * @return string The value associated with the cell
+ */
+ protected function formatStrCellValue($nodeValue)
+ {
+ $escapedCellValue = trim($nodeValue);
+
+ return $this->escaper->unescape($escapedCellValue);
+ }
+
+ /**
+ * Returns the cell Numeric value from string of nodeValue.
+ * The value can also represent a timestamp and a DateTime will be returned.
+ *
+ * @param string $nodeValue
+ * @param int $cellStyleId 0 being the default style
+ *
+ * @return \DateTime|float|int The value associated with the cell
+ */
+ protected function formatNumericCellValue($nodeValue, $cellStyleId)
+ {
+ // Numeric values can represent numbers as well as timestamps.
+ // We need to look at the style of the cell to determine whether it is one or the other.
+ $shouldFormatAsDate = $this->styleManager->shouldFormatNumericValueAsDate($cellStyleId);
+
+ if ($shouldFormatAsDate) {
+ $cellValue = $this->formatExcelTimestampValue((float) $nodeValue, $cellStyleId);
+ } else {
+ $nodeIntValue = (int) $nodeValue;
+ $nodeFloatValue = (float) $nodeValue;
+ $cellValue = ((float) $nodeIntValue === $nodeFloatValue) ? $nodeIntValue : $nodeFloatValue;
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns a cell's PHP Date value, associated to the given timestamp.
+ * NOTE: The timestamp is a float representing the number of days since the base Excel date:
+ * Dec 30th 1899, 1900 or Jan 1st, 1904, depending on the Workbook setting.
+ * NOTE: The timestamp can also represent a time, if it is a value between 0 and 1.
+ *
+ * @see ECMA-376 Part 1 - §18.17.4
+ *
+ * @param float $nodeValue
+ * @param int $cellStyleId 0 being the default style
+ *
+ * @throws InvalidValueException If the value is not a valid timestamp
+ *
+ * @return \DateTime The value associated with the cell
+ */
+ protected function formatExcelTimestampValue($nodeValue, $cellStyleId)
+ {
+ if ($this->isValidTimestampValue($nodeValue)) {
+ $cellValue = $this->formatExcelTimestampValueAsDateTimeValue($nodeValue, $cellStyleId);
+ } else {
+ throw new InvalidValueException($nodeValue);
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns whether the given timestamp is supported by SpreadsheetML.
+ *
+ * @see ECMA-376 Part 1 - §18.17.4 - this specifies the timestamp boundaries.
+ *
+ * @param float $timestampValue
+ *
+ * @return bool
+ */
+ protected function isValidTimestampValue($timestampValue)
+ {
+ // @NOTE: some versions of Excel don't support negative dates (e.g. Excel for Mac 2011)
+ return
+ $this->shouldUse1904Dates && $timestampValue >= -695055 && $timestampValue <= 2957003.9999884
+ || !$this->shouldUse1904Dates && $timestampValue >= -693593 && $timestampValue <= 2958465.9999884
+ ;
+ }
+
+ /**
+ * Returns a cell's PHP DateTime value, associated to the given timestamp.
+ * Only the time value matters. The date part is set to the base Excel date:
+ * Dec 30th 1899, 1900 or Jan 1st, 1904, depending on the Workbook setting.
+ *
+ * @param float $nodeValue
+ * @param int $cellStyleId 0 being the default style
+ *
+ * @return \DateTime|string The value associated with the cell
+ */
+ protected function formatExcelTimestampValueAsDateTimeValue($nodeValue, $cellStyleId)
+ {
+ $baseDate = $this->shouldUse1904Dates ? '1904-01-01' : '1899-12-30';
+
+ $daysSinceBaseDate = (int) $nodeValue;
+ $timeRemainder = fmod($nodeValue, 1);
+ $secondsRemainder = round($timeRemainder * self::NUM_SECONDS_IN_ONE_DAY, 0);
+
+ $dateObj = \DateTime::createFromFormat('|Y-m-d', $baseDate);
+ $dateObj->modify('+'.$daysSinceBaseDate.'days');
+ $dateObj->modify('+'.$secondsRemainder.'seconds');
+
+ if ($this->shouldFormatDates) {
+ $styleNumberFormatCode = $this->styleManager->getNumberFormatCode($cellStyleId);
+ $phpDateFormat = DateFormatHelper::toPHPDateFormat($styleNumberFormatCode);
+ $cellValue = $dateObj->format($phpDateFormat);
+ } else {
+ $cellValue = $dateObj;
+ }
+
+ return $cellValue;
+ }
+
+ /**
+ * Returns the cell Boolean value from a specific node's Value.
+ *
+ * @param string $nodeValue
+ *
+ * @return bool The value associated with the cell
+ */
+ protected function formatBooleanCellValue($nodeValue)
+ {
+ return (bool) $nodeValue;
+ }
+
+ /**
+ * Returns a cell's PHP Date value, associated to the given stored nodeValue.
+ *
+ * @see ECMA-376 Part 1 - §18.17.4
+ *
+ * @param string $nodeValue ISO 8601 Date string
+ *
+ * @throws InvalidValueException If the value is not a valid date
+ *
+ * @return \DateTime|string The value associated with the cell
+ */
+ protected function formatDateCellValue($nodeValue)
+ {
+ // Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php)
+ try {
+ $cellValue = ($this->shouldFormatDates) ? $nodeValue : new \DateTime($nodeValue);
+ } catch (\Exception $e) {
+ throw new InvalidValueException($nodeValue);
+ }
+
+ return $cellValue;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Helper/DateFormatHelper.php b/upstream-3.x/src/Reader/XLSX/Helper/DateFormatHelper.php
new file mode 100644
index 0000000..78fc4f4
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Helper/DateFormatHelper.php
@@ -0,0 +1,122 @@
+ [
+ // Time
+ 'am/pm' => 'A', // Uppercase Ante meridiem and Post meridiem
+ ':mm' => ':i', // Minutes with leading zeros - if preceded by a ":" (otherwise month)
+ 'mm:' => 'i:', // Minutes with leading zeros - if followed by a ":" (otherwise month)
+ 'ss' => 's', // Seconds, with leading zeros
+ '.s' => '', // Ignore (fractional seconds format does not exist in PHP)
+
+ // Date
+ 'e' => 'Y', // Full numeric representation of a year, 4 digits
+ 'yyyy' => 'Y', // Full numeric representation of a year, 4 digits
+ 'yy' => 'y', // Two digit representation of a year
+ 'mmmmm' => 'M', // Short textual representation of a month, three letters ("mmmmm" should only contain the 1st letter...)
+ 'mmmm' => 'F', // Full textual representation of a month
+ 'mmm' => 'M', // Short textual representation of a month, three letters
+ 'mm' => 'm', // Numeric representation of a month, with leading zeros
+ 'm' => 'n', // Numeric representation of a month, without leading zeros
+ 'dddd' => 'l', // Full textual representation of the day of the week
+ 'ddd' => 'D', // Textual representation of a day, three letters
+ 'dd' => 'd', // Day of the month, 2 digits with leading zeros
+ 'd' => 'j', // Day of the month without leading zeros
+ ],
+ self::KEY_HOUR_12 => [
+ 'hh' => 'h', // 12-hour format of an hour without leading zeros
+ 'h' => 'g', // 12-hour format of an hour without leading zeros
+ ],
+ self::KEY_HOUR_24 => [
+ 'hh' => 'H', // 24-hour hours with leading zero
+ 'h' => 'G', // 24-hour format of an hour without leading zeros
+ ],
+ ];
+
+ /**
+ * Converts the given Excel date format to a format understandable by the PHP date function.
+ *
+ * @param string $excelDateFormat Excel date format
+ *
+ * @return string PHP date format (as defined here: http://php.net/manual/en/function.date.php)
+ */
+ public static function toPHPDateFormat($excelDateFormat)
+ {
+ // Remove brackets potentially present at the beginning of the format string
+ // and text portion of the format at the end of it (starting with ";")
+ // See §18.8.31 of ECMA-376 for more detail.
+ $dateFormat = preg_replace('/^(?:\[\$[^\]]+?\])?([^;]*).*/', '$1', $excelDateFormat);
+
+ // Double quotes are used to escape characters that must not be interpreted.
+ // For instance, ["Day " dd] should result in "Day 13" and we should not try to interpret "D", "a", "y"
+ // By exploding the format string using double quote as a delimiter, we can get all parts
+ // that must be transformed (even indexes) and all parts that must not be (odd indexes).
+ $dateFormatParts = explode('"', $dateFormat);
+
+ foreach ($dateFormatParts as $partIndex => $dateFormatPart) {
+ // do not look at odd indexes
+ if (1 === $partIndex % 2) {
+ continue;
+ }
+
+ // Make sure all characters are lowercase, as the mapping table is using lowercase characters
+ $transformedPart = strtolower($dateFormatPart);
+
+ // Remove escapes related to non-format characters
+ $transformedPart = str_replace('\\', '', $transformedPart);
+
+ // Apply general transformation first...
+ $transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_GENERAL]);
+
+ // ... then apply hour transformation, for 12-hour or 24-hour format
+ if (self::has12HourFormatMarker($dateFormatPart)) {
+ $transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_12]);
+ } else {
+ $transformedPart = strtr($transformedPart, self::$excelDateFormatToPHPDateFormatMapping[self::KEY_HOUR_24]);
+ }
+
+ // overwrite the parts array with the new transformed part
+ $dateFormatParts[$partIndex] = $transformedPart;
+ }
+
+ // Merge all transformed parts back together
+ $phpDateFormat = implode('"', $dateFormatParts);
+
+ // Finally, to have the date format compatible with the DateTime::format() function, we need to escape
+ // all characters that are inside double quotes (and double quotes must be removed).
+ // For instance, ["Day " dd] should become [\D\a\y\ dd]
+ return preg_replace_callback('/"(.+?)"/', function ($matches) {
+ $stringToEscape = $matches[1];
+ $letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY);
+
+ return '\\'.implode('\\', $letters);
+ }, $phpDateFormat);
+ }
+
+ /**
+ * @param string $excelDateFormat Date format as defined by Excel
+ *
+ * @return bool Whether the given date format has the 12-hour format marker
+ */
+ private static function has12HourFormatMarker($excelDateFormat)
+ {
+ return false !== stripos($excelDateFormat, 'am/pm');
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/OptionsManager.php b/upstream-3.x/src/Reader/XLSX/Manager/OptionsManager.php
new file mode 100644
index 0000000..b04b92c
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/OptionsManager.php
@@ -0,0 +1,36 @@
+setOption(Options::TEMP_FOLDER, sys_get_temp_dir());
+ $this->setOption(Options::SHOULD_FORMAT_DATES, false);
+ $this->setOption(Options::SHOULD_PRESERVE_EMPTY_ROWS, false);
+ $this->setOption(Options::SHOULD_USE_1904_DATES, false);
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php
new file mode 100644
index 0000000..c2f8c9f
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php
@@ -0,0 +1,141 @@
+ 20 * 600 ≈ 12KB
+ */
+ public const AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB = 12;
+
+ /**
+ * To avoid running out of memory when extracting a huge number of shared strings, they can be saved to temporary files
+ * instead of in memory. Then, when accessing a string, the corresponding file contents will be loaded in memory
+ * and the string will be quickly retrieved.
+ * The performance bottleneck is not when creating these temporary files, but rather when loading their content.
+ * Because the contents of the last loaded file stays in memory until another file needs to be loaded, it works
+ * best when the indexes of the shared strings are sorted in the sheet data.
+ * 10,000 was chosen because it creates small files that are fast to be loaded in memory.
+ */
+ public const MAX_NUM_STRINGS_PER_TEMP_FILE = 10000;
+
+ /**
+ * Returns the best caching strategy, given the number of unique shared strings
+ * and the amount of memory available.
+ *
+ * @param null|int $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
+ * @param string $tempFolder Temporary folder where the temporary files to store shared strings will be stored
+ * @param HelperFactory $helperFactory Factory to create helpers
+ *
+ * @return CachingStrategyInterface The best caching strategy
+ */
+ public function createBestCachingStrategy($sharedStringsUniqueCount, $tempFolder, $helperFactory)
+ {
+ if ($this->isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)) {
+ return new InMemoryStrategy($sharedStringsUniqueCount);
+ }
+
+ return new FileBasedStrategy($tempFolder, self::MAX_NUM_STRINGS_PER_TEMP_FILE, $helperFactory);
+ }
+
+ /**
+ * Returns whether it is safe to use in-memory caching, given the number of unique shared strings
+ * and the amount of memory available.
+ *
+ * @param null|int $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
+ *
+ * @return bool
+ */
+ protected function isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)
+ {
+ // if the number of shared strings in unknown, do not use "in memory" strategy
+ if (null === $sharedStringsUniqueCount) {
+ return false;
+ }
+
+ $memoryAvailable = $this->getMemoryLimitInKB();
+
+ if (-1 === (int) $memoryAvailable) {
+ // if cannot get memory limit or if memory limit set as unlimited, don't trust and play safe
+ $isInMemoryStrategyUsageSafe = ($sharedStringsUniqueCount < self::MAX_NUM_STRINGS_PER_TEMP_FILE);
+ } else {
+ $memoryNeeded = $sharedStringsUniqueCount * self::AMOUNT_MEMORY_NEEDED_PER_STRING_IN_KB;
+ $isInMemoryStrategyUsageSafe = ($memoryAvailable > $memoryNeeded);
+ }
+
+ return $isInMemoryStrategyUsageSafe;
+ }
+
+ /**
+ * Returns the PHP "memory_limit" in Kilobytes.
+ *
+ * @return float
+ */
+ protected function getMemoryLimitInKB()
+ {
+ $memoryLimitFormatted = $this->getMemoryLimitFromIni();
+ $memoryLimitFormatted = strtolower(trim($memoryLimitFormatted));
+
+ // No memory limit
+ if ('-1' === $memoryLimitFormatted) {
+ return -1;
+ }
+
+ if (preg_match('/(\d+)([bkmgt])b?/', $memoryLimitFormatted, $matches)) {
+ $amount = (int) ($matches[1]);
+ $unit = $matches[2];
+
+ switch ($unit) {
+ case 'b': return $amount / 1024;
+
+ case 'k': return $amount;
+
+ case 'm': return $amount * 1024;
+
+ case 'g': return $amount * 1024 * 1024;
+
+ case 't': return $amount * 1024 * 1024 * 1024;
+ }
+ }
+
+ return -1;
+ }
+
+ /**
+ * Returns the formatted "memory_limit" value.
+ *
+ * @return string
+ */
+ protected function getMemoryLimitFromIni()
+ {
+ return ini_get('memory_limit');
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyInterface.php b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyInterface.php
new file mode 100644
index 0000000..b314827
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyInterface.php
@@ -0,0 +1,39 @@
+fileSystemHelper = $helperFactory->createFileSystemHelper($tempFolder);
+ $this->tempFolder = $this->fileSystemHelper->createFolder($tempFolder, uniqid('sharedstrings'));
+
+ $this->maxNumStringsPerTempFile = $maxNumStringsPerTempFile;
+
+ $this->globalFunctionsHelper = $helperFactory->createGlobalFunctionsHelper();
+ $this->tempFilePointer = null;
+ }
+
+ /**
+ * Adds the given string to the cache.
+ *
+ * @param string $sharedString The string to be added to the cache
+ * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
+ */
+ public function addStringForIndex($sharedString, $sharedStringIndex)
+ {
+ $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
+
+ if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
+ if ($this->tempFilePointer) {
+ $this->globalFunctionsHelper->fclose($this->tempFilePointer);
+ }
+ $this->tempFilePointer = $this->globalFunctionsHelper->fopen($tempFilePath, 'w');
+ }
+
+ // The shared string retrieval logic expects each cell data to be on one line only
+ // Encoding the line feed character allows to preserve this assumption
+ $lineFeedEncodedSharedString = $this->escapeLineFeed($sharedString);
+
+ $this->globalFunctionsHelper->fwrite($this->tempFilePointer, $lineFeedEncodedSharedString.PHP_EOL);
+ }
+
+ /**
+ * Closes the cache after the last shared string was added.
+ * This prevents any additional string from being added to the cache.
+ */
+ public function closeCache()
+ {
+ // close pointer to the last temp file that was written
+ if ($this->tempFilePointer) {
+ $this->globalFunctionsHelper->fclose($this->tempFilePointer);
+ }
+ }
+
+ /**
+ * Returns the string located at the given index from the cache.
+ *
+ * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
+ *
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
+ *
+ * @return string The shared string at the given index
+ */
+ public function getStringAtIndex($sharedStringIndex)
+ {
+ $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex);
+ $indexInFile = $sharedStringIndex % $this->maxNumStringsPerTempFile;
+
+ if (!$this->globalFunctionsHelper->file_exists($tempFilePath)) {
+ throw new SharedStringNotFoundException("Shared string temp file not found: {$tempFilePath} ; for index: {$sharedStringIndex}");
+ }
+
+ if ($this->inMemoryTempFilePath !== $tempFilePath) {
+ $this->inMemoryTempFileContents = explode(PHP_EOL, $this->globalFunctionsHelper->file_get_contents($tempFilePath));
+ $this->inMemoryTempFilePath = $tempFilePath;
+ }
+
+ $sharedString = null;
+
+ // Using isset here because it is way faster than array_key_exists...
+ if (isset($this->inMemoryTempFileContents[$indexInFile])) {
+ $escapedSharedString = $this->inMemoryTempFileContents[$indexInFile];
+ $sharedString = $this->unescapeLineFeed($escapedSharedString);
+ }
+
+ if (null === $sharedString) {
+ throw new SharedStringNotFoundException("Shared string not found for index: {$sharedStringIndex}");
+ }
+
+ return rtrim($sharedString, PHP_EOL);
+ }
+
+ /**
+ * Destroys the cache, freeing memory and removing any created artifacts.
+ */
+ public function clearCache()
+ {
+ if ($this->tempFolder) {
+ $this->fileSystemHelper->deleteFolderRecursively($this->tempFolder);
+ }
+ }
+
+ /**
+ * 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 = (int) ($sharedStringIndex / $this->maxNumStringsPerTempFile);
+
+ return $this->tempFolder.'/sharedstrings'.$numTempFile;
+ }
+
+ /**
+ * Escapes the line feed characters (\n).
+ *
+ * @param string $unescapedString
+ *
+ * @return string
+ */
+ private function escapeLineFeed($unescapedString)
+ {
+ return str_replace("\n", self::ESCAPED_LINE_FEED_CHARACTER, $unescapedString);
+ }
+
+ /**
+ * Unescapes the line feed characters (\n).
+ *
+ * @param string $escapedString
+ *
+ * @return string
+ */
+ private function unescapeLineFeed($escapedString)
+ {
+ return str_replace(self::ESCAPED_LINE_FEED_CHARACTER, "\n", $escapedString);
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php
new file mode 100644
index 0000000..04312ad
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php
@@ -0,0 +1,76 @@
+inMemoryCache = new \SplFixedArray($sharedStringsUniqueCount);
+ $this->isCacheClosed = false;
+ }
+
+ /**
+ * Adds the given string to the cache.
+ *
+ * @param string $sharedString The string to be added to the cache
+ * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
+ */
+ public function addStringForIndex($sharedString, $sharedStringIndex)
+ {
+ if (!$this->isCacheClosed) {
+ $this->inMemoryCache->offsetSet($sharedStringIndex, $sharedString);
+ }
+ }
+
+ /**
+ * Closes the cache after the last shared string was added.
+ * This prevents any additional string from being added to the cache.
+ */
+ public function closeCache()
+ {
+ $this->isCacheClosed = true;
+ }
+
+ /**
+ * Returns the string located at the given index from the cache.
+ *
+ * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
+ *
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
+ *
+ * @return string The shared string at the given index
+ */
+ public function getStringAtIndex($sharedStringIndex)
+ {
+ try {
+ return $this->inMemoryCache->offsetGet($sharedStringIndex);
+ } catch (\RuntimeException $e) {
+ throw new SharedStringNotFoundException("Shared string not found for index: {$sharedStringIndex}");
+ }
+ }
+
+ /**
+ * Destroys the cache, freeing memory and removing any created artifacts.
+ */
+ public function clearCache()
+ {
+ $this->inMemoryCache = new \SplFixedArray(0);
+ $this->isCacheClosed = false;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsManager.php b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsManager.php
new file mode 100644
index 0000000..120ce68
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/SharedStringsManager.php
@@ -0,0 +1,252 @@
+filePath = $filePath;
+ $this->tempFolder = $tempFolder;
+ $this->workbookRelationshipsManager = $workbookRelationshipsManager;
+ $this->entityFactory = $entityFactory;
+ $this->helperFactory = $helperFactory;
+ $this->cachingStrategyFactory = $cachingStrategyFactory;
+ }
+
+ /**
+ * Returns whether the XLSX file contains a shared strings XML file.
+ *
+ * @return bool
+ */
+ public function hasSharedStrings()
+ {
+ return $this->workbookRelationshipsManager->hasSharedStringsXMLFile();
+ }
+
+ /**
+ * Builds an in-memory array containing all the shared strings of the sheet.
+ * All the strings are stored in a XML file, located at 'xl/sharedStrings.xml'.
+ * It is then accessed by the sheet data, via the string index in the built table.
+ *
+ * More documentation available here: http://msdn.microsoft.com/en-us/library/office/gg278314.aspx
+ *
+ * The XML file can be really big with sheets containing a lot of data. That is why
+ * we need to use a XML reader that provides streaming like the XMLReader library.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If shared strings XML file can't be read
+ */
+ public function extractSharedStrings()
+ {
+ $sharedStringsXMLFilePath = $this->workbookRelationshipsManager->getSharedStringsXMLFilePath();
+ $xmlReader = $this->entityFactory->createXMLReader();
+ $sharedStringIndex = 0;
+
+ if (false === $xmlReader->openFileInZip($this->filePath, $sharedStringsXMLFilePath)) {
+ throw new IOException('Could not open "'.$sharedStringsXMLFilePath.'".');
+ }
+
+ try {
+ $sharedStringsUniqueCount = $this->getSharedStringsUniqueCount($xmlReader);
+ $this->cachingStrategy = $this->getBestSharedStringsCachingStrategy($sharedStringsUniqueCount);
+
+ $xmlReader->readUntilNodeFound(self::XML_NODE_SI);
+
+ while (self::XML_NODE_SI === $xmlReader->getCurrentNodeName()) {
+ $this->processSharedStringsItem($xmlReader, $sharedStringIndex);
+ ++$sharedStringIndex;
+
+ // jump to the next '' tag
+ $xmlReader->next(self::XML_NODE_SI);
+ }
+
+ $this->cachingStrategy->closeCache();
+ } catch (XMLProcessingException $exception) {
+ throw new IOException("The sharedStrings.xml file is invalid and cannot be read. [{$exception->getMessage()}]");
+ }
+
+ $xmlReader->close();
+ }
+
+ /**
+ * Returns the shared string at the given index, using the previously chosen caching strategy.
+ *
+ * @param int $sharedStringIndex Index of the shared string in the sharedStrings.xml file
+ *
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If no shared string found for the given index
+ *
+ * @return string The shared string at the given index
+ */
+ public function getStringAtIndex($sharedStringIndex)
+ {
+ return $this->cachingStrategy->getStringAtIndex($sharedStringIndex);
+ }
+
+ /**
+ * Destroys the cache, freeing memory and removing any created artifacts.
+ */
+ public function cleanup()
+ {
+ if (null !== $this->cachingStrategy) {
+ $this->cachingStrategy->clearCache();
+ }
+ }
+
+ /**
+ * Returns the shared strings unique count, as specified in tag.
+ *
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader instance
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If sharedStrings.xml is invalid and can't be read
+ *
+ * @return null|int Number of unique shared strings in the sharedStrings.xml file
+ */
+ protected function getSharedStringsUniqueCount($xmlReader)
+ {
+ $xmlReader->next(self::XML_NODE_SST);
+
+ // Iterate over the "sst" elements to get the actual "sst ELEMENT" (skips any DOCTYPE)
+ while (self::XML_NODE_SST === $xmlReader->getCurrentNodeName() && XMLReader::ELEMENT !== $xmlReader->nodeType) {
+ $xmlReader->read();
+ }
+
+ $uniqueCount = $xmlReader->getAttribute(self::XML_ATTRIBUTE_UNIQUE_COUNT);
+
+ // some software do not add the "uniqueCount" attribute but only use the "count" one
+ // @see https://github.com/box/spout/issues/254
+ if (null === $uniqueCount) {
+ $uniqueCount = $xmlReader->getAttribute(self::XML_ATTRIBUTE_COUNT);
+ }
+
+ return (null !== $uniqueCount) ? (int) $uniqueCount : null;
+ }
+
+ /**
+ * Returns the best shared strings caching strategy.
+ *
+ * @param null|int $sharedStringsUniqueCount Number of unique shared strings (NULL if unknown)
+ *
+ * @return CachingStrategyInterface
+ */
+ protected function getBestSharedStringsCachingStrategy($sharedStringsUniqueCount)
+ {
+ return $this->cachingStrategyFactory
+ ->createBestCachingStrategy($sharedStringsUniqueCount, $this->tempFolder, $this->helperFactory)
+ ;
+ }
+
+ /**
+ * Processes the shared strings item XML node which the given XML reader is positioned on.
+ *
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on a "" node
+ * @param int $sharedStringIndex Index of the processed shared strings item
+ */
+ protected function processSharedStringsItem($xmlReader, $sharedStringIndex)
+ {
+ $sharedStringValue = '';
+
+ // NOTE: expand() will automatically decode all XML entities of the child nodes
+ /** @var \DOMElement $siNode */
+ $siNode = $xmlReader->expand();
+ $textNodes = $siNode->getElementsByTagName(self::XML_NODE_T);
+
+ foreach ($textNodes as $textNode) {
+ if ($this->shouldExtractTextNodeValue($textNode)) {
+ $textNodeValue = $textNode->nodeValue;
+ $shouldPreserveWhitespace = $this->shouldPreserveWhitespace($textNode);
+
+ $sharedStringValue .= ($shouldPreserveWhitespace) ? $textNodeValue : trim($textNodeValue);
+ }
+ }
+
+ $this->cachingStrategy->addStringForIndex($sharedStringValue, $sharedStringIndex);
+ }
+
+ /**
+ * Not all text nodes' values must be extracted.
+ * Some text nodes are part of a node describing the pronunciation for instance.
+ * We'll only consider the nodes whose parents are "" or "".
+ *
+ * @param \DOMElement $textNode Text node to check
+ *
+ * @return bool Whether the given text node's value must be extracted
+ */
+ protected function shouldExtractTextNodeValue($textNode)
+ {
+ $parentTagName = $textNode->parentNode->localName;
+
+ return self::XML_NODE_SI === $parentTagName || self::XML_NODE_R === $parentTagName;
+ }
+
+ /**
+ * If the text node has the attribute 'xml:space="preserve"', then preserve whitespace.
+ *
+ * @param \DOMElement $textNode The text node element () whose whitespace may be preserved
+ *
+ * @return bool Whether whitespace should be preserved
+ */
+ protected function shouldPreserveWhitespace($textNode)
+ {
+ $spaceValue = $textNode->getAttribute(self::XML_ATTRIBUTE_XML_SPACE);
+
+ return self::XML_ATTRIBUTE_VALUE_PRESERVE === $spaceValue;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/SheetManager.php b/upstream-3.x/src/Reader/XLSX/Manager/SheetManager.php
new file mode 100644
index 0000000..e2e173e
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/SheetManager.php
@@ -0,0 +1,232 @@
+filePath = $filePath;
+ $this->optionsManager = $optionsManager;
+ $this->sharedStringsManager = $sharedStringsManager;
+ $this->escaper = $escaper;
+ $this->entityFactory = $entityFactory;
+ }
+
+ /**
+ * Returns the sheets metadata of the file located at the previously given file path.
+ * The paths to the sheets' data are read from the [Content_Types].xml file.
+ *
+ * @return Sheet[] Sheets within the XLSX file
+ */
+ public function getSheets()
+ {
+ $this->sheets = [];
+ $this->currentSheetIndex = 0;
+ $this->activeSheetIndex = 0; // By default, the first sheet is active
+
+ $xmlReader = $this->entityFactory->createXMLReader();
+ $xmlProcessor = $this->entityFactory->createXMLProcessor($xmlReader);
+
+ $xmlProcessor->registerCallback(self::XML_NODE_WORKBOOK_PROPERTIES, XMLProcessor::NODE_TYPE_START, [$this, 'processWorkbookPropertiesStartingNode']);
+ $xmlProcessor->registerCallback(self::XML_NODE_WORKBOOK_VIEW, XMLProcessor::NODE_TYPE_START, [$this, 'processWorkbookViewStartingNode']);
+ $xmlProcessor->registerCallback(self::XML_NODE_SHEET, XMLProcessor::NODE_TYPE_START, [$this, 'processSheetStartingNode']);
+ $xmlProcessor->registerCallback(self::XML_NODE_SHEETS, XMLProcessor::NODE_TYPE_END, [$this, 'processSheetsEndingNode']);
+
+ if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_FILE_PATH)) {
+ $xmlProcessor->readUntilStopped();
+ $xmlReader->close();
+ }
+
+ return $this->sheets;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processWorkbookPropertiesStartingNode($xmlReader)
+ {
+ // Using "filter_var($x, FILTER_VALIDATE_BOOLEAN)" here because the value of the "date1904" attribute
+ // may be the string "false", that is not mapped to the boolean "false" by default...
+ $shouldUse1904Dates = filter_var($xmlReader->getAttribute(self::XML_ATTRIBUTE_DATE_1904), FILTER_VALIDATE_BOOLEAN);
+ $this->optionsManager->setOption(Options::SHOULD_USE_1904_DATES, $shouldUse1904Dates);
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processWorkbookViewStartingNode($xmlReader)
+ {
+ // The "workbookView" node is located before "sheet" nodes, ensuring that
+ // the active sheet is known before parsing sheets data.
+ $this->activeSheetIndex = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_ACTIVE_TAB);
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processSheetStartingNode($xmlReader)
+ {
+ $isSheetActive = ($this->currentSheetIndex === $this->activeSheetIndex);
+ $this->sheets[] = $this->getSheetFromSheetXMLNode($xmlReader, $this->currentSheetIndex, $isSheetActive);
+ ++$this->currentSheetIndex;
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processSheetsEndingNode()
+ {
+ return XMLProcessor::PROCESSING_STOP;
+ }
+
+ /**
+ * Returns an instance of a sheet, given the XML node describing the sheet - from "workbook.xml".
+ * We can find the XML file path describing the sheet inside "workbook.xml.res", by mapping with the sheet ID
+ * ("r:id" in "workbook.xml", "Id" in "workbook.xml.res").
+ *
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReaderOnSheetNode XML Reader instance, pointing on the node describing the sheet, as defined in "workbook.xml"
+ * @param int $sheetIndexZeroBased Index of the sheet, based on order of appearance in the workbook (zero-based)
+ * @param bool $isSheetActive Whether this sheet was defined as active
+ *
+ * @return \OpenSpout\Reader\XLSX\Sheet Sheet instance
+ */
+ protected function getSheetFromSheetXMLNode($xmlReaderOnSheetNode, $sheetIndexZeroBased, $isSheetActive)
+ {
+ $sheetId = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_R_ID);
+
+ $sheetState = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_STATE);
+ $isSheetVisible = (self::SHEET_STATE_HIDDEN !== $sheetState);
+
+ $escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME);
+ $sheetName = $this->escaper->unescape($escapedSheetName);
+
+ $sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId);
+
+ return $this->entityFactory->createSheet(
+ $this->filePath,
+ $sheetDataXMLFilePath,
+ $sheetIndexZeroBased,
+ $sheetName,
+ $isSheetActive,
+ $isSheetVisible,
+ $this->optionsManager,
+ $this->sharedStringsManager
+ );
+ }
+
+ /**
+ * @param string $sheetId The sheet ID, as defined in "workbook.xml"
+ *
+ * @return string The XML file path describing the sheet inside "workbook.xml.res", for the given sheet ID
+ */
+ protected function getSheetDataXMLFilePathForSheetId($sheetId)
+ {
+ $sheetDataXMLFilePath = '';
+
+ // find the file path of the sheet, by looking at the "workbook.xml.res" file
+ $xmlReader = $this->entityFactory->createXMLReader();
+ if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_RELS_FILE_PATH)) {
+ while ($xmlReader->read()) {
+ if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_RELATIONSHIP)) {
+ $relationshipSheetId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ID);
+
+ if ($relationshipSheetId === $sheetId) {
+ // In workbook.xml.rels, it is only "worksheets/sheet1.xml"
+ // In [Content_Types].xml, the path is "/xl/worksheets/sheet1.xml"
+ $sheetDataXMLFilePath = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TARGET);
+
+ // sometimes, the sheet data file path already contains "/xl/"...
+ if (0 !== strpos($sheetDataXMLFilePath, '/xl/')) {
+ $sheetDataXMLFilePath = '/xl/'.$sheetDataXMLFilePath;
+
+ break;
+ }
+ }
+ }
+ }
+
+ $xmlReader->close();
+ }
+
+ return $sheetDataXMLFilePath;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Manager/StyleManager.php b/upstream-3.x/src/Reader/XLSX/Manager/StyleManager.php
new file mode 100644
index 0000000..4eb9046
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Manager/StyleManager.php
@@ -0,0 +1,349 @@
+ 'm/d/yyyy', // @NOTE: ECMA spec is 'mm-dd-yy'
+ 15 => 'd-mmm-yy',
+ 16 => 'd-mmm',
+ 17 => 'mmm-yy',
+ 18 => 'h:mm AM/PM',
+ 19 => 'h:mm:ss AM/PM',
+ 20 => 'h:mm',
+ 21 => 'h:mm:ss',
+ 22 => 'm/d/yyyy h:mm', // @NOTE: ECMA spec is 'm/d/yy h:mm',
+ 45 => 'mm:ss',
+ 46 => '[h]:mm:ss',
+ 47 => 'mm:ss.0', // @NOTE: ECMA spec is 'mmss.0',
+ ];
+
+ /** @var string Path of the XLSX file being read */
+ protected $filePath;
+
+ /** @var bool Whether the XLSX file contains a styles XML file */
+ protected $hasStylesXMLFile;
+
+ /** @var null|string Path of the styles XML file */
+ protected $stylesXMLFilePath;
+
+ /** @var InternalEntityFactory Factory to create entities */
+ protected $entityFactory;
+
+ /** @var array Array containing the IDs of built-in number formats indicating a date */
+ protected $builtinNumFmtIdIndicatingDates;
+
+ /** @var null|array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */
+ protected $customNumberFormats;
+
+ /** @var null|array Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */
+ protected $stylesAttributes;
+
+ /** @var array Cache containing a mapping NUM_FMT_ID => IS_DATE_FORMAT. Used to avoid lots of recalculations */
+ protected $numFmtIdToIsDateFormatCache = [];
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param WorkbookRelationshipsManager $workbookRelationshipsManager Helps retrieving workbook relationships
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ */
+ public function __construct($filePath, $workbookRelationshipsManager, $entityFactory)
+ {
+ $this->filePath = $filePath;
+ $this->entityFactory = $entityFactory;
+ $this->builtinNumFmtIdIndicatingDates = array_keys(self::$builtinNumFmtIdToNumFormatMapping);
+ $this->hasStylesXMLFile = $workbookRelationshipsManager->hasStylesXMLFile();
+ if ($this->hasStylesXMLFile) {
+ $this->stylesXMLFilePath = $workbookRelationshipsManager->getStylesXMLFilePath();
+ }
+ }
+
+ /**
+ * Returns whether the style with the given ID should consider
+ * numeric values as timestamps and format the cell as a date.
+ *
+ * @param int $styleId Zero-based style ID
+ *
+ * @return bool Whether the cell with the given cell should display a date instead of a numeric value
+ */
+ public function shouldFormatNumericValueAsDate($styleId)
+ {
+ if (!$this->hasStylesXMLFile) {
+ return false;
+ }
+
+ $stylesAttributes = $this->getStylesAttributes();
+
+ // Default style (0) does not format numeric values as timestamps. Only custom styles do.
+ // Also if the style ID does not exist in the styles.xml file, format as numeric value.
+ // Using isset here because it is way faster than array_key_exists...
+ if (self::DEFAULT_STYLE_ID === $styleId || !isset($stylesAttributes[$styleId])) {
+ return false;
+ }
+
+ $styleAttributes = $stylesAttributes[$styleId];
+
+ return $this->doesStyleIndicateDate($styleAttributes);
+ }
+
+ /**
+ * Returns the format as defined in "styles.xml" of the given style.
+ * NOTE: It is assumed that the style DOES have a number format associated to it.
+ *
+ * @param int $styleId Zero-based style ID
+ *
+ * @return string The number format code associated with the given style
+ */
+ public function getNumberFormatCode($styleId)
+ {
+ $stylesAttributes = $this->getStylesAttributes();
+ $styleAttributes = $stylesAttributes[$styleId];
+ $numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
+
+ if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) {
+ $numberFormatCode = self::$builtinNumFmtIdToNumFormatMapping[$numFmtId];
+ } else {
+ $customNumberFormats = $this->getCustomNumberFormats();
+ $numberFormatCode = $customNumberFormats[$numFmtId];
+ }
+
+ return $numberFormatCode;
+ }
+
+ /**
+ * Reads the styles.xml file and extract the relevant information from the file.
+ */
+ protected function extractRelevantInfo()
+ {
+ $this->customNumberFormats = [];
+ $this->stylesAttributes = [];
+
+ $xmlReader = $this->entityFactory->createXMLReader();
+
+ if ($xmlReader->openFileInZip($this->filePath, $this->stylesXMLFilePath)) {
+ while ($xmlReader->read()) {
+ if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS)) {
+ $this->extractNumberFormats($xmlReader);
+ } elseif ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_CELL_XFS)) {
+ $this->extractStyleAttributes($xmlReader);
+ }
+ }
+
+ $xmlReader->close();
+ }
+ }
+
+ /**
+ * Extracts number formats from the "numFmt" nodes.
+ * For simplicity, the styles attributes are kept in memory. This is possible thanks
+ * to the reuse of formats. So 1 million cells should not use 1 million formats.
+ *
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "numFmts" node
+ */
+ protected function extractNumberFormats($xmlReader)
+ {
+ while ($xmlReader->read()) {
+ if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMT)) {
+ $numFmtId = (int) ($xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID));
+ $formatCode = $xmlReader->getAttribute(self::XML_ATTRIBUTE_FORMAT_CODE);
+ $this->customNumberFormats[$numFmtId] = $formatCode;
+ } elseif ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_NUM_FMTS)) {
+ // Once done reading "numFmts" node's children
+ break;
+ }
+ }
+ }
+
+ /**
+ * Extracts style attributes from the "xf" nodes, inside the "cellXfs" section.
+ * For simplicity, the styles attributes are kept in memory. This is possible thanks
+ * to the reuse of styles. So 1 million cells should not use 1 million styles.
+ *
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XML Reader positioned on the "cellXfs" node
+ */
+ protected function extractStyleAttributes($xmlReader)
+ {
+ while ($xmlReader->read()) {
+ if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_XF)) {
+ $numFmtId = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_FMT_ID);
+ $normalizedNumFmtId = (null !== $numFmtId) ? (int) $numFmtId : null;
+
+ $applyNumberFormat = $xmlReader->getAttribute(self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT);
+ $normalizedApplyNumberFormat = (null !== $applyNumberFormat) ? (bool) $applyNumberFormat : null;
+
+ $this->stylesAttributes[] = [
+ self::XML_ATTRIBUTE_NUM_FMT_ID => $normalizedNumFmtId,
+ self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT => $normalizedApplyNumberFormat,
+ ];
+ } elseif ($xmlReader->isPositionedOnEndingNode(self::XML_NODE_CELL_XFS)) {
+ // Once done reading "cellXfs" node's children
+ break;
+ }
+ }
+ }
+
+ /**
+ * @return array The custom number formats
+ */
+ protected function getCustomNumberFormats()
+ {
+ if (!isset($this->customNumberFormats)) {
+ $this->extractRelevantInfo();
+ }
+
+ return $this->customNumberFormats;
+ }
+
+ /**
+ * @return array The styles attributes
+ */
+ protected function getStylesAttributes()
+ {
+ if (!isset($this->stylesAttributes)) {
+ $this->extractRelevantInfo();
+ }
+
+ return $this->stylesAttributes;
+ }
+
+ /**
+ * @param array $styleAttributes Array containing the style attributes (2 keys: "applyNumberFormat" and "numFmtId")
+ *
+ * @return bool Whether the style with the given attributes indicates that the number is a date
+ */
+ protected function doesStyleIndicateDate($styleAttributes)
+ {
+ $applyNumberFormat = $styleAttributes[self::XML_ATTRIBUTE_APPLY_NUMBER_FORMAT];
+ $numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID];
+
+ // A style may apply a date format if it has:
+ // - "applyNumberFormat" attribute not set to "false"
+ // - "numFmtId" attribute set
+ // This is a preliminary check, as having "numFmtId" set just means the style should apply a specific number format,
+ // but this is not necessarily a date.
+ if (false === $applyNumberFormat || null === $numFmtId) {
+ return false;
+ }
+
+ return $this->doesNumFmtIdIndicateDate($numFmtId);
+ }
+
+ /**
+ * Returns whether the number format ID indicates that the number is a date.
+ * The result is cached to avoid recomputing the same thing over and over, as
+ * "numFmtId" attributes can be shared between multiple styles.
+ *
+ * @param int $numFmtId
+ *
+ * @return bool Whether the number format ID indicates that the number is a date
+ */
+ protected function doesNumFmtIdIndicateDate($numFmtId)
+ {
+ if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) {
+ $formatCode = $this->getFormatCodeForNumFmtId($numFmtId);
+
+ $this->numFmtIdToIsDateFormatCache[$numFmtId] = (
+ $this->isNumFmtIdBuiltInDateFormat($numFmtId)
+ || $this->isFormatCodeCustomDateFormat($formatCode)
+ );
+ }
+
+ return $this->numFmtIdToIsDateFormatCache[$numFmtId];
+ }
+
+ /**
+ * @param int $numFmtId
+ *
+ * @return null|string The custom number format or NULL if none defined for the given numFmtId
+ */
+ protected function getFormatCodeForNumFmtId($numFmtId)
+ {
+ $customNumberFormats = $this->getCustomNumberFormats();
+
+ // Using isset here because it is way faster than array_key_exists...
+ return (isset($customNumberFormats[$numFmtId])) ? $customNumberFormats[$numFmtId] : null;
+ }
+
+ /**
+ * @param int $numFmtId
+ *
+ * @return bool Whether the number format ID indicates that the number is a date
+ */
+ protected function isNumFmtIdBuiltInDateFormat($numFmtId)
+ {
+ return \in_array($numFmtId, $this->builtinNumFmtIdIndicatingDates, true);
+ }
+
+ /**
+ * @param null|string $formatCode
+ *
+ * @return bool Whether the given format code indicates that the number is a date
+ */
+ protected function isFormatCodeCustomDateFormat($formatCode)
+ {
+ // if no associated format code or if using the default "General" format
+ if (null === $formatCode || 0 === strcasecmp($formatCode, self::NUMBER_FORMAT_GENERAL)) {
+ return false;
+ }
+
+ return $this->isFormatCodeMatchingDateFormatPattern($formatCode);
+ }
+
+ /**
+ * @param string $formatCode
+ *
+ * @return bool Whether the given format code matches a date format pattern
+ */
+ protected function isFormatCodeMatchingDateFormatPattern($formatCode)
+ {
+ // Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\")
+ $pattern = '((? [FILE_NAME] */
+ private $cachedWorkbookRelationships;
+
+ /**
+ * @param string $filePath Path of the XLSX file being read
+ * @param InternalEntityFactory $entityFactory Factory to create entities
+ */
+ public function __construct($filePath, $entityFactory)
+ {
+ $this->filePath = $filePath;
+ $this->entityFactory = $entityFactory;
+ }
+
+ /**
+ * @return string The path of the shared string XML file
+ */
+ public function getSharedStringsXMLFilePath()
+ {
+ $workbookRelationships = $this->getWorkbookRelationships();
+ $sharedStringsXMLFilePath = $workbookRelationships[self::RELATIONSHIP_TYPE_SHARED_STRINGS]
+ ?? $workbookRelationships[self::RELATIONSHIP_TYPE_SHARED_STRINGS_STRICT];
+
+ // the file path can be relative (e.g. "styles.xml") or absolute (e.g. "/xl/styles.xml")
+ $doesContainBasePath = (false !== strpos($sharedStringsXMLFilePath, self::BASE_PATH));
+ if (!$doesContainBasePath) {
+ // make sure we return an absolute file path
+ $sharedStringsXMLFilePath = self::BASE_PATH.$sharedStringsXMLFilePath;
+ }
+
+ return $sharedStringsXMLFilePath;
+ }
+
+ /**
+ * @return bool Whether the XLSX file contains a shared string XML file
+ */
+ public function hasSharedStringsXMLFile()
+ {
+ $workbookRelationships = $this->getWorkbookRelationships();
+
+ return isset($workbookRelationships[self::RELATIONSHIP_TYPE_SHARED_STRINGS])
+ || isset($workbookRelationships[self::RELATIONSHIP_TYPE_SHARED_STRINGS_STRICT]);
+ }
+
+ /**
+ * @return bool Whether the XLSX file contains a styles XML file
+ */
+ public function hasStylesXMLFile()
+ {
+ $workbookRelationships = $this->getWorkbookRelationships();
+
+ return isset($workbookRelationships[self::RELATIONSHIP_TYPE_STYLES])
+ || isset($workbookRelationships[self::RELATIONSHIP_TYPE_STYLES_STRICT]);
+ }
+
+ /**
+ * @return string The path of the styles XML file
+ */
+ public function getStylesXMLFilePath()
+ {
+ $workbookRelationships = $this->getWorkbookRelationships();
+ $stylesXMLFilePath = $workbookRelationships[self::RELATIONSHIP_TYPE_STYLES]
+ ?? $workbookRelationships[self::RELATIONSHIP_TYPE_STYLES_STRICT];
+
+ // the file path can be relative (e.g. "styles.xml") or absolute (e.g. "/xl/styles.xml")
+ $doesContainBasePath = (false !== strpos($stylesXMLFilePath, self::BASE_PATH));
+ if (!$doesContainBasePath) {
+ // make sure we return a full path
+ $stylesXMLFilePath = self::BASE_PATH.$stylesXMLFilePath;
+ }
+
+ return $stylesXMLFilePath;
+ }
+
+ /**
+ * Reads the workbook.xml.rels and extracts the filename associated to the different types.
+ * It caches the result so that the file is read only once.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If workbook.xml.rels can't be read
+ *
+ * @return array
+ */
+ private function getWorkbookRelationships()
+ {
+ if (!isset($this->cachedWorkbookRelationships)) {
+ $xmlReader = $this->entityFactory->createXMLReader();
+
+ if (false === $xmlReader->openFileInZip($this->filePath, self::WORKBOOK_RELS_XML_FILE_PATH)) {
+ throw new IOException('Could not open "'.self::WORKBOOK_RELS_XML_FILE_PATH.'".');
+ }
+
+ $this->cachedWorkbookRelationships = [];
+
+ while ($xmlReader->readUntilNodeFound(self::XML_NODE_RELATIONSHIP)) {
+ $this->processWorkbookRelationship($xmlReader);
+ }
+ }
+
+ return $this->cachedWorkbookRelationships;
+ }
+
+ /**
+ * Extracts and store the data of the current workbook relationship.
+ *
+ * @param XMLReader $xmlReader
+ */
+ private function processWorkbookRelationship($xmlReader)
+ {
+ $type = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TYPE);
+ $target = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TARGET);
+
+ // @NOTE: if a type is defined more than once, we overwrite the previous value
+ // To be changed if we want to get the file paths of sheet XML files for instance.
+ $this->cachedWorkbookRelationships[$type] = $target;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Reader.php b/upstream-3.x/src/Reader/XLSX/Reader.php
new file mode 100644
index 0000000..197c7fc
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Reader.php
@@ -0,0 +1,122 @@
+managerFactory = $managerFactory;
+ }
+
+ /**
+ * @param string $tempFolder Temporary folder where the temporary files will be created
+ *
+ * @return Reader
+ */
+ public function setTempFolder($tempFolder)
+ {
+ $this->optionsManager->setOption(Options::TEMP_FOLDER, $tempFolder);
+
+ return $this;
+ }
+
+ /**
+ * Returns whether stream wrappers are supported.
+ *
+ * @return bool
+ */
+ protected function doesSupportStreamWrapper()
+ {
+ return false;
+ }
+
+ /**
+ * Opens the file at the given file path to make it ready to be read.
+ * It also parses the sharedStrings.xml file to get all the shared strings available in memory
+ * and fetches all the available sheets.
+ *
+ * @param string $filePath Path of the file to be read
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the file at the given path or its content cannot be read
+ * @throws \OpenSpout\Reader\Exception\NoSheetsFoundException If there are no sheets in the file
+ */
+ protected function openReader($filePath)
+ {
+ /** @var InternalEntityFactory $entityFactory */
+ $entityFactory = $this->entityFactory;
+
+ $this->zip = $entityFactory->createZipArchive();
+
+ if (true === $this->zip->open($filePath)) {
+ $tempFolder = $this->optionsManager->getOption(Options::TEMP_FOLDER);
+ $this->sharedStringsManager = $this->managerFactory->createSharedStringsManager($filePath, $tempFolder, $entityFactory);
+
+ if ($this->sharedStringsManager->hasSharedStrings()) {
+ // Extracts all the strings from the sheets for easy access in the future
+ $this->sharedStringsManager->extractSharedStrings();
+ }
+
+ $this->sheetIterator = $entityFactory->createSheetIterator(
+ $filePath,
+ $this->optionsManager,
+ $this->sharedStringsManager
+ );
+ } else {
+ throw new IOException("Could not open {$filePath} for reading.");
+ }
+ }
+
+ /**
+ * Returns an iterator to iterate over sheets.
+ *
+ * @return SheetIterator To iterate over sheets
+ */
+ protected function getConcreteSheetIterator()
+ {
+ return $this->sheetIterator;
+ }
+
+ /**
+ * Closes the reader. To be used after reading the file.
+ */
+ protected function closeReader()
+ {
+ if (null !== $this->zip) {
+ $this->zip->close();
+ }
+
+ if (null !== $this->sharedStringsManager) {
+ $this->sharedStringsManager->cleanup();
+ }
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/RowIterator.php b/upstream-3.x/src/Reader/XLSX/RowIterator.php
new file mode 100644
index 0000000..de38370
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/RowIterator.php
@@ -0,0 +1,429 @@
+filePath = $filePath;
+ $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath);
+ $this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows;
+ $this->xmlReader = $xmlReader;
+ $this->cellValueFormatter = $cellValueFormatter;
+ $this->rowManager = $rowManager;
+ $this->entityFactory = $entityFactory;
+
+ // Register all callbacks to process different nodes when reading the XML file
+ $this->xmlProcessor = $xmlProcessor;
+ $this->xmlProcessor->registerCallback(self::XML_NODE_DIMENSION, XMLProcessor::NODE_TYPE_START, [$this, 'processDimensionStartingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_START, [$this, 'processRowStartingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processCellStartingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_ROW, XMLProcessor::NODE_TYPE_END, [$this, 'processRowEndingNode']);
+ $this->xmlProcessor->registerCallback(self::XML_NODE_WORKSHEET, XMLProcessor::NODE_TYPE_END, [$this, 'processWorksheetEndingNode']);
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ * Initializes the XMLReader object that reads the associated sheet data.
+ * The XMLReader is configured to be safe from billion laughs attack.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the sheet data XML cannot be read
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind(): void
+ {
+ $this->xmlReader->close();
+
+ if (false === $this->xmlReader->openFileInZip($this->filePath, $this->sheetDataXMLFilePath)) {
+ throw new IOException("Could not open \"{$this->sheetDataXMLFilePath}\".");
+ }
+
+ $this->numReadRows = 0;
+ $this->lastRowIndexProcessed = 0;
+ $this->nextRowIndexToBeProcessed = 0;
+ $this->rowBuffer = null;
+ $this->hasReachedEndOfFile = false;
+ $this->numColumns = 0;
+
+ $this->next();
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ */
+ #[\ReturnTypeWillChange]
+ public function valid(): bool
+ {
+ return !$this->hasReachedEndOfFile;
+ }
+
+ /**
+ * Move forward to next element. Reads data describing the next unprocessed row.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ *
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
+ * @throws \OpenSpout\Common\Exception\IOException If unable to read the sheet data XML
+ */
+ #[\ReturnTypeWillChange]
+ public function next(): void
+ {
+ ++$this->nextRowIndexToBeProcessed;
+
+ if ($this->doesNeedDataForNextRowToBeProcessed()) {
+ $this->readDataForNextRow();
+ }
+ }
+
+ /**
+ * Return the current element, either an empty row or from the buffer.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ */
+ #[\ReturnTypeWillChange]
+ public function current(): ?Row
+ {
+ $rowToBeProcessed = $this->rowBuffer;
+
+ if ($this->shouldPreserveEmptyRows) {
+ // when we need to preserve empty rows, we will either return
+ // an empty row or the last row read. This depends whether the
+ // index of last row that was read matches the index of the last
+ // row whose value should be returned.
+ if ($this->lastRowIndexProcessed !== $this->nextRowIndexToBeProcessed) {
+ // return empty row if mismatch between last processed row
+ // and the row that needs to be returned
+ $rowToBeProcessed = $this->entityFactory->createRow();
+ }
+ }
+
+ return $rowToBeProcessed;
+ }
+
+ /**
+ * Return the key of the current element. Here, the row index.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ */
+ #[\ReturnTypeWillChange]
+ public function key(): int
+ {
+ // TODO: This should return $this->nextRowIndexToBeProcessed
+ // but to avoid a breaking change, the return value for
+ // this function has been kept as the number of rows read.
+ return $this->shouldPreserveEmptyRows ?
+ $this->nextRowIndexToBeProcessed :
+ $this->numReadRows;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end(): void
+ {
+ $this->xmlReader->close();
+ }
+
+ /**
+ * @param string $sheetDataXMLFilePath Path of the sheet data XML file as in [Content_Types].xml
+ *
+ * @return string path of the XML file containing the sheet data,
+ * without the leading slash
+ */
+ protected function normalizeSheetDataXMLFilePath($sheetDataXMLFilePath)
+ {
+ return ltrim($sheetDataXMLFilePath, '/');
+ }
+
+ /**
+ * Returns whether we need data for the next row to be processed.
+ * We don't need to read data if:
+ * we have already read at least one row
+ * AND
+ * we need to preserve empty rows
+ * AND
+ * the last row that was read is not the row that need to be processed
+ * (i.e. if we need to return empty rows).
+ *
+ * @return bool whether we need data for the next row to be processed
+ */
+ protected function doesNeedDataForNextRowToBeProcessed()
+ {
+ $hasReadAtLeastOneRow = (0 !== $this->lastRowIndexProcessed);
+
+ return
+ !$hasReadAtLeastOneRow
+ || !$this->shouldPreserveEmptyRows
+ || $this->lastRowIndexProcessed < $this->nextRowIndexToBeProcessed
+ ;
+ }
+
+ /**
+ * @throws \OpenSpout\Reader\Exception\SharedStringNotFoundException If a shared string was not found
+ * @throws \OpenSpout\Common\Exception\IOException If unable to read the sheet data XML
+ */
+ protected function readDataForNextRow()
+ {
+ $this->currentlyProcessedRow = $this->entityFactory->createRow();
+
+ try {
+ $this->xmlProcessor->readUntilStopped();
+ } catch (XMLProcessingException $exception) {
+ throw new IOException("The {$this->sheetDataXMLFilePath} file cannot be read. [{$exception->getMessage()}]");
+ }
+
+ $this->rowBuffer = $this->currentlyProcessedRow;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processDimensionStartingNode($xmlReader)
+ {
+ // Read dimensions of the sheet
+ $dimensionRef = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); // returns 'A1:M13' for instance (or 'A1' for empty sheet)
+ if (preg_match('/[A-Z]+\d+:([A-Z]+\d+)/', $dimensionRef, $matches)) {
+ $this->numColumns = CellHelper::getColumnIndexFromCellIndex($matches[1]) + 1;
+ }
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processRowStartingNode($xmlReader)
+ {
+ // Reset index of the last processed column
+ $this->lastColumnIndexProcessed = -1;
+
+ // Mark the last processed row as the one currently being read
+ $this->lastRowIndexProcessed = $this->getRowIndex($xmlReader);
+
+ // Read spans info if present
+ $numberOfColumnsForRow = $this->numColumns;
+ $spans = $xmlReader->getAttribute(self::XML_ATTRIBUTE_SPANS); // returns '1:5' for instance
+ if ($spans) {
+ [, $numberOfColumnsForRow] = explode(':', $spans);
+ $numberOfColumnsForRow = (int) $numberOfColumnsForRow;
+ }
+
+ $cells = array_fill(0, $numberOfColumnsForRow, $this->entityFactory->createCell(''));
+ $this->currentlyProcessedRow->setCells($cells);
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" starting node
+ *
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processCellStartingNode($xmlReader)
+ {
+ $currentColumnIndex = $this->getColumnIndex($xmlReader);
+
+ // NOTE: expand() will automatically decode all XML entities of the child nodes
+ /** @var \DOMElement $node */
+ $node = $xmlReader->expand();
+ $cell = $this->getCell($node);
+
+ $this->currentlyProcessedRow->setCellAtIndex($cell, $currentColumnIndex);
+ $this->lastColumnIndexProcessed = $currentColumnIndex;
+
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ /**
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processRowEndingNode()
+ {
+ // if the fetched row is empty and we don't want to preserve it..,
+ if (!$this->shouldPreserveEmptyRows && $this->rowManager->isEmpty($this->currentlyProcessedRow)) {
+ // ... skip it
+ return XMLProcessor::PROCESSING_CONTINUE;
+ }
+
+ ++$this->numReadRows;
+
+ // If needed, we fill the empty cells
+ if (0 === $this->numColumns) {
+ $this->currentlyProcessedRow = $this->rowManager->fillMissingIndexesWithEmptyCells($this->currentlyProcessedRow);
+ }
+
+ // at this point, we have all the data we need for the row
+ // so that we can populate the buffer
+ return XMLProcessor::PROCESSING_STOP;
+ }
+
+ /**
+ * @return int A return code that indicates what action should the processor take next
+ */
+ protected function processWorksheetEndingNode()
+ {
+ // The closing "" marks the end of the file
+ $this->hasReachedEndOfFile = true;
+
+ return XMLProcessor::PROCESSING_STOP;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" node
+ *
+ * @throws \OpenSpout\Common\Exception\InvalidArgumentException When the given cell index is invalid
+ *
+ * @return int Row index
+ */
+ protected function getRowIndex($xmlReader)
+ {
+ // Get "r" attribute if present (from something like
+ $currentRowIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_ROW_INDEX);
+
+ return (null !== $currentRowIndex) ?
+ (int) $currentRowIndex :
+ $this->lastRowIndexProcessed + 1;
+ }
+
+ /**
+ * @param \OpenSpout\Reader\Wrapper\XMLReader $xmlReader XMLReader object, positioned on a "" node
+ *
+ * @throws \OpenSpout\Common\Exception\InvalidArgumentException When the given cell index is invalid
+ *
+ * @return int Column index
+ */
+ protected function getColumnIndex($xmlReader)
+ {
+ // Get "r" attribute if present (from something like
+ $currentCellIndex = $xmlReader->getAttribute(self::XML_ATTRIBUTE_CELL_INDEX);
+
+ return (null !== $currentCellIndex) ?
+ CellHelper::getColumnIndexFromCellIndex($currentCellIndex) :
+ $this->lastColumnIndexProcessed + 1;
+ }
+
+ /**
+ * Returns the cell with (unescaped) correctly marshalled, cell value associated to the given XML node.
+ *
+ * @param \DOMElement $node
+ *
+ * @return Cell The cell set with the associated with the cell
+ */
+ protected function getCell($node)
+ {
+ try {
+ $cellValue = $this->cellValueFormatter->extractAndFormatNodeValue($node);
+ $cell = $this->entityFactory->createCell($cellValue);
+ } catch (InvalidValueException $exception) {
+ $cell = $this->entityFactory->createCell($exception->getInvalidValue());
+ $cell->setType(Cell::TYPE_ERROR);
+ }
+
+ return $cell;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/Sheet.php b/upstream-3.x/src/Reader/XLSX/Sheet.php
new file mode 100644
index 0000000..64d7637
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/Sheet.php
@@ -0,0 +1,82 @@
+rowIterator = $rowIterator;
+ $this->index = $sheetIndex;
+ $this->name = $sheetName;
+ $this->isActive = $isSheetActive;
+ $this->isVisible = $isSheetVisible;
+ }
+
+ /**
+ * @return \OpenSpout\Reader\XLSX\RowIterator
+ */
+ public function getRowIterator()
+ {
+ return $this->rowIterator;
+ }
+
+ /**
+ * @return int Index of the sheet, based on order in the workbook (zero-based)
+ */
+ public function getIndex()
+ {
+ return $this->index;
+ }
+
+ /**
+ * @return string Name of the sheet
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * @return bool Whether the sheet was defined as active
+ */
+ public function isActive()
+ {
+ return $this->isActive;
+ }
+
+ /**
+ * @return bool Whether the sheet is visible
+ */
+ public function isVisible()
+ {
+ return $this->isVisible;
+ }
+}
diff --git a/upstream-3.x/src/Reader/XLSX/SheetIterator.php b/upstream-3.x/src/Reader/XLSX/SheetIterator.php
new file mode 100644
index 0000000..f58fc67
--- /dev/null
+++ b/upstream-3.x/src/Reader/XLSX/SheetIterator.php
@@ -0,0 +1,113 @@
+sheets = $sheetManager->getSheets();
+
+ if (0 === \count($this->sheets)) {
+ throw new NoSheetsFoundException('The file must contain at least one sheet.');
+ }
+ }
+
+ /**
+ * Rewind the Iterator to the first element.
+ *
+ * @see http://php.net/manual/en/iterator.rewind.php
+ */
+ #[\ReturnTypeWillChange]
+ public function rewind()
+ {
+ $this->currentSheetIndex = 0;
+ }
+
+ /**
+ * Checks if current position is valid.
+ *
+ * @see http://php.net/manual/en/iterator.valid.php
+ *
+ * @return bool
+ */
+ #[\ReturnTypeWillChange]
+ public function valid()
+ {
+ return $this->currentSheetIndex < \count($this->sheets);
+ }
+
+ /**
+ * Move forward to next element.
+ *
+ * @see http://php.net/manual/en/iterator.next.php
+ */
+ #[\ReturnTypeWillChange]
+ public function next()
+ {
+ // Using isset here because it is way faster than array_key_exists...
+ if (isset($this->sheets[$this->currentSheetIndex])) {
+ $currentSheet = $this->sheets[$this->currentSheetIndex];
+ $currentSheet->getRowIterator()->end();
+
+ ++$this->currentSheetIndex;
+ }
+ }
+
+ /**
+ * Return the current element.
+ *
+ * @see http://php.net/manual/en/iterator.current.php
+ *
+ * @return \OpenSpout\Reader\XLSX\Sheet
+ */
+ #[\ReturnTypeWillChange]
+ public function current()
+ {
+ return $this->sheets[$this->currentSheetIndex];
+ }
+
+ /**
+ * Return the key of the current element.
+ *
+ * @see http://php.net/manual/en/iterator.key.php
+ *
+ * @return int
+ */
+ #[\ReturnTypeWillChange]
+ public function key()
+ {
+ return $this->currentSheetIndex + 1;
+ }
+
+ /**
+ * Cleans up what was created to iterate over the object.
+ */
+ #[\ReturnTypeWillChange]
+ public function end()
+ {
+ // make sure we are not leaking memory in case the iteration stopped before the end
+ foreach ($this->sheets as $sheet) {
+ $sheet->getRowIterator()->end();
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/CSV/Manager/OptionsManager.php b/upstream-3.x/src/Writer/CSV/Manager/OptionsManager.php
new file mode 100644
index 0000000..c6e25a6
--- /dev/null
+++ b/upstream-3.x/src/Writer/CSV/Manager/OptionsManager.php
@@ -0,0 +1,34 @@
+setOption(Options::FIELD_DELIMITER, ',');
+ $this->setOption(Options::FIELD_ENCLOSURE, '"');
+ $this->setOption(Options::SHOULD_ADD_BOM, true);
+ }
+}
diff --git a/upstream-3.x/src/Writer/CSV/Writer.php b/upstream-3.x/src/Writer/CSV/Writer.php
new file mode 100644
index 0000000..2815d0e
--- /dev/null
+++ b/upstream-3.x/src/Writer/CSV/Writer.php
@@ -0,0 +1,109 @@
+optionsManager->setOption(Options::FIELD_DELIMITER, $fieldDelimiter);
+
+ return $this;
+ }
+
+ /**
+ * Sets the field enclosure for the CSV.
+ *
+ * @param string $fieldEnclosure Character that enclose fields
+ *
+ * @return Writer
+ */
+ public function setFieldEnclosure($fieldEnclosure)
+ {
+ $this->optionsManager->setOption(Options::FIELD_ENCLOSURE, $fieldEnclosure);
+
+ return $this;
+ }
+
+ /**
+ * Set if a BOM has to be added to the file.
+ *
+ * @param bool $shouldAddBOM
+ *
+ * @return Writer
+ */
+ public function setShouldAddBOM($shouldAddBOM)
+ {
+ $this->optionsManager->setOption(Options::SHOULD_ADD_BOM, (bool) $shouldAddBOM);
+
+ return $this;
+ }
+
+ /**
+ * Opens the CSV streamer and makes it ready to accept data.
+ */
+ protected function openWriter()
+ {
+ if ($this->optionsManager->getOption(Options::SHOULD_ADD_BOM)) {
+ // Adds UTF-8 BOM for Unicode compatibility
+ $this->globalFunctionsHelper->fputs($this->filePointer, EncodingHelper::BOM_UTF8);
+ }
+ }
+
+ /**
+ * Adds a row to the currently opened writer.
+ *
+ * @param Row $row The row containing cells and styles
+ *
+ * @throws IOException If unable to write data
+ */
+ protected function addRowToWriter(Row $row)
+ {
+ $fieldDelimiter = $this->optionsManager->getOption(Options::FIELD_DELIMITER);
+ $fieldEnclosure = $this->optionsManager->getOption(Options::FIELD_ENCLOSURE);
+
+ $wasWriteSuccessful = $this->globalFunctionsHelper->fputcsv($this->filePointer, $row->getCells(), $fieldDelimiter, $fieldEnclosure);
+ if (false === $wasWriteSuccessful) {
+ throw new IOException('Unable to write data');
+ }
+
+ ++$this->lastWrittenRowIndex;
+ if (0 === $this->lastWrittenRowIndex % self::FLUSH_THRESHOLD) {
+ $this->globalFunctionsHelper->fflush($this->filePointer);
+ }
+ }
+
+ /**
+ * Closes the CSV streamer, preventing any additional writing.
+ * If set, sets the headers and redirects output to the browser.
+ */
+ protected function closeWriter()
+ {
+ $this->lastWrittenRowIndex = 0;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Creator/InternalEntityFactory.php b/upstream-3.x/src/Writer/Common/Creator/InternalEntityFactory.php
new file mode 100644
index 0000000..63e96d3
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Creator/InternalEntityFactory.php
@@ -0,0 +1,52 @@
+border = new Border();
+ }
+
+ /**
+ * @param string $color Border A RGB color code
+ * @param string $width Border width @see BorderPart::allowedWidths
+ * @param string $style Border style @see BorderPart::allowedStyles
+ *
+ * @return BorderBuilder
+ */
+ public function setBorderTop($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
+ {
+ $this->border->addPart(new BorderPart(Border::TOP, $color, $width, $style));
+
+ return $this;
+ }
+
+ /**
+ * @param string $color Border A RGB color code
+ * @param string $width Border width @see BorderPart::allowedWidths
+ * @param string $style Border style @see BorderPart::allowedStyles
+ *
+ * @return BorderBuilder
+ */
+ public function setBorderRight($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
+ {
+ $this->border->addPart(new BorderPart(Border::RIGHT, $color, $width, $style));
+
+ return $this;
+ }
+
+ /**
+ * @param string $color Border A RGB color code
+ * @param string $width Border width @see BorderPart::allowedWidths
+ * @param string $style Border style @see BorderPart::allowedStyles
+ *
+ * @return BorderBuilder
+ */
+ public function setBorderBottom($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
+ {
+ $this->border->addPart(new BorderPart(Border::BOTTOM, $color, $width, $style));
+
+ return $this;
+ }
+
+ /**
+ * @param string $color Border A RGB color code
+ * @param string $width Border width @see BorderPart::allowedWidths
+ * @param string $style Border style @see BorderPart::allowedStyles
+ *
+ * @return BorderBuilder
+ */
+ public function setBorderLeft($color = Color::BLACK, $width = Border::WIDTH_MEDIUM, $style = Border::STYLE_SOLID)
+ {
+ $this->border->addPart(new BorderPart(Border::LEFT, $color, $width, $style));
+
+ return $this;
+ }
+
+ /**
+ * @return Border
+ */
+ public function build()
+ {
+ return $this->border;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Creator/Style/StyleBuilder.php b/upstream-3.x/src/Writer/Common/Creator/Style/StyleBuilder.php
new file mode 100644
index 0000000..2ef0d15
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Creator/Style/StyleBuilder.php
@@ -0,0 +1,214 @@
+style = new Style();
+ }
+
+ /**
+ * Makes the font bold.
+ *
+ * @return StyleBuilder
+ */
+ public function setFontBold()
+ {
+ $this->style->setFontBold();
+
+ return $this;
+ }
+
+ /**
+ * Makes the font italic.
+ *
+ * @return StyleBuilder
+ */
+ public function setFontItalic()
+ {
+ $this->style->setFontItalic();
+
+ return $this;
+ }
+
+ /**
+ * Makes the font underlined.
+ *
+ * @return StyleBuilder
+ */
+ public function setFontUnderline()
+ {
+ $this->style->setFontUnderline();
+
+ return $this;
+ }
+
+ /**
+ * Makes the font struck through.
+ *
+ * @return StyleBuilder
+ */
+ public function setFontStrikethrough()
+ {
+ $this->style->setFontStrikethrough();
+
+ return $this;
+ }
+
+ /**
+ * Sets the font size.
+ *
+ * @param int $fontSize Font size, in pixels
+ *
+ * @return StyleBuilder
+ */
+ public function setFontSize($fontSize)
+ {
+ $this->style->setFontSize($fontSize);
+
+ return $this;
+ }
+
+ /**
+ * Sets the font color.
+ *
+ * @param string $fontColor ARGB color (@see Color)
+ *
+ * @return StyleBuilder
+ */
+ public function setFontColor($fontColor)
+ {
+ $this->style->setFontColor($fontColor);
+
+ return $this;
+ }
+
+ /**
+ * Sets the font name.
+ *
+ * @param string $fontName Name of the font to use
+ *
+ * @return StyleBuilder
+ */
+ public function setFontName($fontName)
+ {
+ $this->style->setFontName($fontName);
+
+ return $this;
+ }
+
+ /**
+ * Makes the text wrap in the cell if requested.
+ *
+ * @param bool $shouldWrap Should the text be wrapped
+ *
+ * @return StyleBuilder
+ */
+ public function setShouldWrapText($shouldWrap = true)
+ {
+ $this->style->setShouldWrapText($shouldWrap);
+
+ return $this;
+ }
+
+ /**
+ * Sets the cell alignment.
+ *
+ * @param string $cellAlignment The cell alignment
+ *
+ * @throws InvalidArgumentException If the given cell alignment is not valid
+ *
+ * @return StyleBuilder
+ */
+ public function setCellAlignment($cellAlignment)
+ {
+ if (!CellAlignment::isValid($cellAlignment)) {
+ throw new InvalidArgumentException('Invalid cell alignment value');
+ }
+
+ $this->style->setCellAlignment($cellAlignment);
+
+ return $this;
+ }
+
+ /**
+ * Set a border.
+ *
+ * @return $this
+ */
+ public function setBorder(Border $border)
+ {
+ $this->style->setBorder($border);
+
+ return $this;
+ }
+
+ /**
+ * Sets a background color.
+ *
+ * @param string $color ARGB color (@see Color)
+ *
+ * @return StyleBuilder
+ */
+ public function setBackgroundColor($color)
+ {
+ $this->style->setBackgroundColor($color);
+
+ return $this;
+ }
+
+ /**
+ * Sets a format.
+ *
+ * @param string $format Format
+ *
+ * @return StyleBuilder
+ *
+ * @api
+ */
+ public function setFormat($format)
+ {
+ $this->style->setFormat($format);
+
+ return $this;
+ }
+
+ /**
+ * Set should shrink to fit.
+ *
+ * @param bool $shrinkToFit
+ *
+ * @return StyleBuilder
+ *
+ * @api
+ */
+ public function setShouldShrinkToFit($shrinkToFit = true)
+ {
+ $this->style->setShouldShrinkToFit($shrinkToFit);
+
+ return $this;
+ }
+
+ /**
+ * Returns the configured style. The style is cached and can be reused.
+ *
+ * @return Style
+ */
+ public function build()
+ {
+ return $this->style;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Creator/WriterEntityFactory.php b/upstream-3.x/src/Writer/Common/Creator/WriterEntityFactory.php
new file mode 100644
index 0000000..da6a43a
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Creator/WriterEntityFactory.php
@@ -0,0 +1,121 @@
+index = $sheetIndex;
+ $this->associatedWorkbookId = $associatedWorkbookId;
+
+ $this->sheetManager = $sheetManager;
+ $this->sheetManager->markWorkbookIdAsUsed($associatedWorkbookId);
+
+ $this->setName(self::DEFAULT_SHEET_NAME_PREFIX.($sheetIndex + 1));
+ $this->setIsVisible(true);
+ }
+
+ /**
+ * @return int Index of the sheet, based on order in the workbook (zero-based)
+ */
+ public function getIndex()
+ {
+ return $this->index;
+ }
+
+ /**
+ * @return string
+ */
+ public function getAssociatedWorkbookId()
+ {
+ return $this->associatedWorkbookId;
+ }
+
+ /**
+ * @return string Name of the sheet
+ */
+ public function getName()
+ {
+ return $this->name;
+ }
+
+ /**
+ * Sets the name of the sheet. Note that Excel has some restrictions on the name:
+ * - it should not be blank
+ * - it should not exceed 31 characters
+ * - it should not contain these characters: \ / ? * : [ or ]
+ * - it should be unique.
+ *
+ * @param string $name Name of the sheet
+ *
+ * @throws \OpenSpout\Writer\Exception\InvalidSheetNameException if the sheet's name is invalid
+ *
+ * @return Sheet
+ */
+ public function setName($name)
+ {
+ $this->sheetManager->throwIfNameIsInvalid($name, $this);
+
+ $this->name = $name;
+
+ $this->sheetManager->markSheetNameAsUsed($this);
+
+ return $this;
+ }
+
+ /**
+ * @return bool isVisible Visibility of the sheet
+ */
+ public function isVisible()
+ {
+ return $this->isVisible;
+ }
+
+ /**
+ * @param bool $isVisible Visibility of the sheet
+ *
+ * @return Sheet
+ */
+ public function setIsVisible($isVisible)
+ {
+ $this->isVisible = $isVisible;
+
+ return $this;
+ }
+
+ public function getSheetView(): ?SheetView
+ {
+ return $this->sheetView;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setSheetView(SheetView $sheetView)
+ {
+ $this->sheetView = $sheetView;
+
+ return $this;
+ }
+
+ public function hasSheetView(): bool
+ {
+ return $this->sheetView instanceof SheetView;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Entity/Workbook.php b/upstream-3.x/src/Writer/Common/Entity/Workbook.php
new file mode 100644
index 0000000..152ded8
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Entity/Workbook.php
@@ -0,0 +1,47 @@
+internalId = uniqid();
+ }
+
+ /**
+ * @return Worksheet[]
+ */
+ public function getWorksheets()
+ {
+ return $this->worksheets;
+ }
+
+ /**
+ * @param Worksheet[] $worksheets
+ */
+ public function setWorksheets($worksheets)
+ {
+ $this->worksheets = $worksheets;
+ }
+
+ /**
+ * @return string
+ */
+ public function getInternalId()
+ {
+ return $this->internalId;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Entity/Worksheet.php b/upstream-3.x/src/Writer/Common/Entity/Worksheet.php
new file mode 100644
index 0000000..0263429
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Entity/Worksheet.php
@@ -0,0 +1,131 @@
+filePath = $worksheetFilePath;
+ $this->filePointer = null;
+ $this->externalSheet = $externalSheet;
+ $this->maxNumColumns = 0;
+ $this->lastWrittenRowIndex = 0;
+ $this->sheetDataStarted = false;
+ }
+
+ /**
+ * @return string
+ */
+ public function getFilePath()
+ {
+ return $this->filePath;
+ }
+
+ /**
+ * @return resource
+ */
+ public function getFilePointer()
+ {
+ return $this->filePointer;
+ }
+
+ /**
+ * @param resource $filePointer
+ */
+ public function setFilePointer($filePointer)
+ {
+ $this->filePointer = $filePointer;
+ }
+
+ /**
+ * @return Sheet
+ */
+ public function getExternalSheet()
+ {
+ return $this->externalSheet;
+ }
+
+ /**
+ * @return int
+ */
+ public function getMaxNumColumns()
+ {
+ return $this->maxNumColumns;
+ }
+
+ /**
+ * @param int $maxNumColumns
+ */
+ public function setMaxNumColumns($maxNumColumns)
+ {
+ $this->maxNumColumns = $maxNumColumns;
+ }
+
+ /**
+ * @return int
+ */
+ public function getLastWrittenRowIndex()
+ {
+ return $this->lastWrittenRowIndex;
+ }
+
+ /**
+ * @param int $lastWrittenRowIndex
+ */
+ public function setLastWrittenRowIndex($lastWrittenRowIndex)
+ {
+ $this->lastWrittenRowIndex = $lastWrittenRowIndex;
+ }
+
+ /**
+ * @return int The ID of the worksheet
+ */
+ public function getId()
+ {
+ // sheet index is zero-based, while ID is 1-based
+ return $this->externalSheet->getIndex() + 1;
+ }
+
+ /**
+ * @return bool
+ */
+ public function getSheetDataStarted()
+ {
+ return $this->sheetDataStarted;
+ }
+
+ /**
+ * @param bool $sheetDataStarted
+ */
+ public function setSheetDataStarted($sheetDataStarted)
+ {
+ $this->sheetDataStarted = $sheetDataStarted;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Helper/CellHelper.php b/upstream-3.x/src/Writer/Common/Helper/CellHelper.php
new file mode 100644
index 0000000..400e82c
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Helper/CellHelper.php
@@ -0,0 +1,45 @@
+ column letters */
+ private static $columnIndexToColumnLettersCache = [];
+
+ /**
+ * Returns the column letters (base 26) associated to the base 10 column index.
+ * Excel uses A to Z letters for column indexing, where A is the 1st column,
+ * Z is the 26th and AA is the 27th.
+ * The mapping is zero based, so that 0 maps to A, B maps to 1, Z to 25 and AA to 26.
+ *
+ * @param int $columnIndexZeroBased The Excel column index (0, 42, ...)
+ *
+ * @return string The associated cell index ('A', 'BC', ...)
+ */
+ public static function getColumnLettersFromColumnIndex($columnIndexZeroBased)
+ {
+ $originalColumnIndex = $columnIndexZeroBased;
+
+ // Using isset here because it is way faster than array_key_exists...
+ if (!isset(self::$columnIndexToColumnLettersCache[$originalColumnIndex])) {
+ $columnLetters = '';
+ $capitalAAsciiValue = \ord('A');
+
+ do {
+ $modulus = $columnIndexZeroBased % 26;
+ $columnLetters = \chr($capitalAAsciiValue + $modulus).$columnLetters;
+
+ // substracting 1 because it's zero-based
+ $columnIndexZeroBased = (int) ($columnIndexZeroBased / 26) - 1;
+ } while ($columnIndexZeroBased >= 0);
+
+ self::$columnIndexToColumnLettersCache[$originalColumnIndex] = $columnLetters;
+ }
+
+ return self::$columnIndexToColumnLettersCache[$originalColumnIndex];
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php b/upstream-3.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php
new file mode 100644
index 0000000..f571ebf
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php
@@ -0,0 +1,24 @@
+entityFactory = $entityFactory;
+ }
+
+ /**
+ * Returns a new ZipArchive instance pointing at the given path.
+ *
+ * @param string $tmpFolderPath Path of the temp folder where the zip file will be created
+ *
+ * @return \ZipArchive
+ */
+ public function createZip($tmpFolderPath)
+ {
+ $zip = $this->entityFactory->createZipArchive();
+ $zipFilePath = $tmpFolderPath.self::ZIP_EXTENSION;
+
+ $zip->open($zipFilePath, \ZipArchive::CREATE | \ZipArchive::OVERWRITE);
+
+ return $zip;
+ }
+
+ /**
+ * @param \ZipArchive $zip An opened zip archive object
+ *
+ * @return string Path where the zip file of the given folder will be created
+ */
+ public function getZipFilePath(\ZipArchive $zip)
+ {
+ return $zip->filename;
+ }
+
+ /**
+ * Adds the given file, located under the given root folder to the archive.
+ * The file will be compressed.
+ *
+ * Example of use:
+ * addFileToArchive($zip, '/tmp/xlsx/foo', 'bar/baz.xml');
+ * => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
+ *
+ * @param \ZipArchive $zip An opened zip archive object
+ * @param string $rootFolderPath path of the root folder that will be ignored in the archive tree
+ * @param string $localFilePath Path of the file to be added, under the root folder
+ * @param string $existingFileMode Controls what to do when trying to add an existing file
+ */
+ public function addFileToArchive($zip, $rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
+ {
+ $this->addFileToArchiveWithCompressionMethod(
+ $zip,
+ $rootFolderPath,
+ $localFilePath,
+ $existingFileMode,
+ \ZipArchive::CM_DEFAULT
+ );
+ }
+
+ /**
+ * Adds the given file, located under the given root folder to the archive.
+ * The file will NOT be compressed.
+ *
+ * Example of use:
+ * addUncompressedFileToArchive($zip, '/tmp/xlsx/foo', 'bar/baz.xml');
+ * => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
+ *
+ * @param \ZipArchive $zip An opened zip archive object
+ * @param string $rootFolderPath path of the root folder that will be ignored in the archive tree
+ * @param string $localFilePath Path of the file to be added, under the root folder
+ * @param string $existingFileMode Controls what to do when trying to add an existing file
+ */
+ public function addUncompressedFileToArchive($zip, $rootFolderPath, $localFilePath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
+ {
+ $this->addFileToArchiveWithCompressionMethod(
+ $zip,
+ $rootFolderPath,
+ $localFilePath,
+ $existingFileMode,
+ \ZipArchive::CM_STORE
+ );
+ }
+
+ /**
+ * @return bool Whether it is possible to choose the desired compression method to be used
+ */
+ public static function canChooseCompressionMethod()
+ {
+ // setCompressionName() is a PHP7+ method...
+ return method_exists(new \ZipArchive(), 'setCompressionName');
+ }
+
+ /**
+ * @param \ZipArchive $zip An opened zip archive object
+ * @param string $folderPath Path to the folder to be zipped
+ * @param string $existingFileMode Controls what to do when trying to add an existing file
+ */
+ public function addFolderToArchive($zip, $folderPath, $existingFileMode = self::EXISTING_FILES_OVERWRITE)
+ {
+ $folderRealPath = $this->getNormalizedRealPath($folderPath).'/';
+ $itemIterator = new \RecursiveIteratorIterator(new \RecursiveDirectoryIterator($folderPath, \RecursiveDirectoryIterator::SKIP_DOTS), \RecursiveIteratorIterator::SELF_FIRST);
+
+ foreach ($itemIterator as $itemInfo) {
+ $itemRealPath = $this->getNormalizedRealPath($itemInfo->getPathname());
+ $itemLocalPath = str_replace($folderRealPath, '', $itemRealPath);
+
+ if ($itemInfo->isFile() && !$this->shouldSkipFile($zip, $itemLocalPath, $existingFileMode)) {
+ $zip->addFile($itemRealPath, $itemLocalPath);
+ }
+ }
+ }
+
+ /**
+ * Closes the archive and copies it into the given stream.
+ *
+ * @param \ZipArchive $zip An opened zip archive object
+ * @param resource $streamPointer Pointer to the stream to copy the zip
+ */
+ public function closeArchiveAndCopyToStream($zip, $streamPointer)
+ {
+ $zipFilePath = $zip->filename;
+ $zip->close();
+
+ $this->copyZipToStream($zipFilePath, $streamPointer);
+ }
+
+ /**
+ * Adds the given file, located under the given root folder to the archive.
+ * The file will NOT be compressed.
+ *
+ * Example of use:
+ * addUncompressedFileToArchive($zip, '/tmp/xlsx/foo', 'bar/baz.xml');
+ * => will add the file located at '/tmp/xlsx/foo/bar/baz.xml' in the archive, but only as 'bar/baz.xml'
+ *
+ * @param \ZipArchive $zip An opened zip archive object
+ * @param string $rootFolderPath path of the root folder that will be ignored in the archive tree
+ * @param string $localFilePath Path of the file to be added, under the root folder
+ * @param string $existingFileMode Controls what to do when trying to add an existing file
+ * @param int $compressionMethod The compression method
+ */
+ protected function addFileToArchiveWithCompressionMethod($zip, $rootFolderPath, $localFilePath, $existingFileMode, $compressionMethod)
+ {
+ if (!$this->shouldSkipFile($zip, $localFilePath, $existingFileMode)) {
+ $normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath.'/'.$localFilePath);
+ $zip->addFile($normalizedFullFilePath, $localFilePath);
+
+ if (self::canChooseCompressionMethod()) {
+ $zip->setCompressionName($localFilePath, $compressionMethod);
+ }
+ }
+ }
+
+ /**
+ * @param \ZipArchive $zip
+ * @param string $itemLocalPath
+ * @param string $existingFileMode
+ *
+ * @return bool Whether the file should be added to the archive or skipped
+ */
+ protected function shouldSkipFile($zip, $itemLocalPath, $existingFileMode)
+ {
+ // Skip files if:
+ // - EXISTING_FILES_SKIP mode chosen
+ // - File already exists in the archive
+ return self::EXISTING_FILES_SKIP === $existingFileMode && false !== $zip->locateName($itemLocalPath);
+ }
+
+ /**
+ * Returns canonicalized absolute pathname, containing only forward slashes.
+ *
+ * @param string $path Path to normalize
+ *
+ * @return string Normalized and canonicalized path
+ */
+ protected function getNormalizedRealPath($path)
+ {
+ $realPath = realpath($path);
+
+ return str_replace(\DIRECTORY_SEPARATOR, '/', $realPath);
+ }
+
+ /**
+ * Streams the contents of the zip file into the given stream.
+ *
+ * @param string $zipFilePath Path of the zip file
+ * @param resource $pointer Pointer to the stream to copy the zip
+ */
+ protected function copyZipToStream($zipFilePath, $pointer)
+ {
+ $zipFilePointer = fopen($zipFilePath, 'r');
+ stream_copy_to_stream($zipFilePointer, $pointer);
+ fclose($zipFilePointer);
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/CellManager.php b/upstream-3.x/src/Writer/Common/Manager/CellManager.php
new file mode 100644
index 0000000..5ce70d4
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/CellManager.php
@@ -0,0 +1,29 @@
+styleMerger = $styleMerger;
+ }
+
+ /**
+ * Merges a Style into a cell's Style.
+ */
+ public function applyStyle(Cell $cell, Style $style)
+ {
+ $mergedStyle = $this->styleMerger->merge($cell->getStyle(), $style);
+ $cell->setStyle($mergedStyle);
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/ManagesCellSize.php b/upstream-3.x/src/Writer/Common/Manager/ManagesCellSize.php
new file mode 100644
index 0000000..eb8c4a4
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/ManagesCellSize.php
@@ -0,0 +1,62 @@
+defaultColumnWidth = $width;
+ }
+
+ /**
+ * @param null|float $height
+ */
+ public function setDefaultRowHeight($height)
+ {
+ $this->defaultRowHeight = $height;
+ }
+
+ /**
+ * @param int ...$columns One or more columns with this width
+ */
+ public function setColumnWidth(float $width, ...$columns)
+ {
+ // Gather sequences
+ $sequence = [];
+ foreach ($columns as $i) {
+ $sequenceLength = \count($sequence);
+ if ($sequenceLength > 0) {
+ $previousValue = $sequence[$sequenceLength - 1];
+ if ($i !== $previousValue + 1) {
+ $this->setColumnWidthForRange($width, $sequence[0], $previousValue);
+ $sequence = [];
+ }
+ }
+ $sequence[] = $i;
+ }
+ $this->setColumnWidthForRange($width, $sequence[0], $sequence[\count($sequence) - 1]);
+ }
+
+ /**
+ * @param float $width The width to set
+ * @param int $start First column index of the range
+ * @param int $end Last column index of the range
+ */
+ public function setColumnWidthForRange(float $width, int $start, int $end)
+ {
+ $this->columnWidths[] = [$start, $end, $width];
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/RegisteredStyle.php b/upstream-3.x/src/Writer/Common/Manager/RegisteredStyle.php
new file mode 100644
index 0000000..d3ef877
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/RegisteredStyle.php
@@ -0,0 +1,37 @@
+style = $style;
+ $this->isMatchingRowStyle = $isMatchingRowStyle;
+ }
+
+ public function getStyle(): Style
+ {
+ return $this->style;
+ }
+
+ public function isMatchingRowStyle(): bool
+ {
+ return $this->isMatchingRowStyle;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/RowManager.php b/upstream-3.x/src/Writer/Common/Manager/RowManager.php
new file mode 100644
index 0000000..e702bf7
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/RowManager.php
@@ -0,0 +1,25 @@
+getCells() as $cell) {
+ if (!$cell->isEmpty()) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/SheetManager.php b/upstream-3.x/src/Writer/Common/Manager/SheetManager.php
new file mode 100644
index 0000000..4d6a6c2
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/SheetManager.php
@@ -0,0 +1,146 @@
+ [[SHEET_INDEX] => [SHEET_NAME]] keeping track of sheets' name to enforce uniqueness per workbook */
+ private static $SHEETS_NAME_USED = [];
+
+ /** @var StringHelper */
+ private $stringHelper;
+
+ /**
+ * SheetManager constructor.
+ */
+ public function __construct(StringHelper $stringHelper)
+ {
+ $this->stringHelper = $stringHelper;
+ }
+
+ /**
+ * Throws an exception if the given sheet's name is not valid.
+ *
+ * @see Sheet::setName for validity rules.
+ *
+ * @param string $name
+ * @param Sheet $sheet The sheet whose future name is checked
+ *
+ * @throws \OpenSpout\Writer\Exception\InvalidSheetNameException if the sheet's name is invalid
+ */
+ public function throwIfNameIsInvalid($name, Sheet $sheet)
+ {
+ if (!\is_string($name)) {
+ $actualType = \gettype($name);
+ $errorMessage = "The sheet's name is invalid. It must be a string ({$actualType} given).";
+
+ throw new InvalidSheetNameException($errorMessage);
+ }
+
+ $failedRequirements = [];
+ $nameLength = $this->stringHelper->getStringLength($name);
+
+ if (!$this->isNameUnique($name, $sheet)) {
+ $failedRequirements[] = 'It should be unique';
+ } else {
+ if (0 === $nameLength) {
+ $failedRequirements[] = 'It should not be blank';
+ } else {
+ if ($nameLength > self::MAX_LENGTH_SHEET_NAME) {
+ $failedRequirements[] = 'It should not exceed 31 characters';
+ }
+
+ if ($this->doesContainInvalidCharacters($name)) {
+ $failedRequirements[] = 'It should not contain these characters: \\ / ? * : [ or ]';
+ }
+
+ if ($this->doesStartOrEndWithSingleQuote($name)) {
+ $failedRequirements[] = 'It should not start or end with a single quote';
+ }
+ }
+ }
+
+ if (0 !== \count($failedRequirements)) {
+ $errorMessage = "The sheet's name (\"{$name}\") is invalid. It did not respect these rules:\n - ";
+ $errorMessage .= implode("\n - ", $failedRequirements);
+
+ throw new InvalidSheetNameException($errorMessage);
+ }
+ }
+
+ /**
+ * @param string $workbookId Workbook ID associated to a Sheet
+ */
+ public function markWorkbookIdAsUsed($workbookId)
+ {
+ if (!isset(self::$SHEETS_NAME_USED[$workbookId])) {
+ self::$SHEETS_NAME_USED[$workbookId] = [];
+ }
+ }
+
+ public function markSheetNameAsUsed(Sheet $sheet)
+ {
+ self::$SHEETS_NAME_USED[$sheet->getAssociatedWorkbookId()][$sheet->getIndex()] = $sheet->getName();
+ }
+
+ /**
+ * Returns whether the given name contains at least one invalid character.
+ *
+ * @see Sheet::$INVALID_CHARACTERS_IN_SHEET_NAME for the full list.
+ *
+ * @param string $name
+ *
+ * @return bool TRUE if the name contains invalid characters, FALSE otherwise
+ */
+ private function doesContainInvalidCharacters($name)
+ {
+ return str_replace(self::$INVALID_CHARACTERS_IN_SHEET_NAME, '', $name) !== $name;
+ }
+
+ /**
+ * Returns whether the given name starts or ends with a single quote.
+ *
+ * @param string $name
+ *
+ * @return bool TRUE if the name starts or ends with a single quote, FALSE otherwise
+ */
+ private function doesStartOrEndWithSingleQuote($name)
+ {
+ $startsWithSingleQuote = (0 === $this->stringHelper->getCharFirstOccurrencePosition('\'', $name));
+ $endsWithSingleQuote = ($this->stringHelper->getCharLastOccurrencePosition('\'', $name) === ($this->stringHelper->getStringLength($name) - 1));
+
+ return $startsWithSingleQuote || $endsWithSingleQuote;
+ }
+
+ /**
+ * Returns whether the given name is unique.
+ *
+ * @param string $name
+ * @param Sheet $sheet The sheet whose future name is checked
+ *
+ * @return bool TRUE if the name is unique, FALSE otherwise
+ */
+ private function isNameUnique($name, Sheet $sheet)
+ {
+ foreach (self::$SHEETS_NAME_USED[$sheet->getAssociatedWorkbookId()] as $sheetIndex => $sheetName) {
+ if ($sheetIndex !== $sheet->getIndex() && $sheetName === $name) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php b/upstream-3.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php
new file mode 100644
index 0000000..d78dd4b
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php
@@ -0,0 +1,31 @@
+style = $style;
+ $this->isUpdated = $isUpdated;
+ }
+
+ public function getStyle(): Style
+ {
+ return $this->style;
+ }
+
+ public function isUpdated(): bool
+ {
+ return $this->isUpdated;
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/Style/StyleManager.php b/upstream-3.x/src/Writer/Common/Manager/Style/StyleManager.php
new file mode 100644
index 0000000..47f4d34
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/Style/StyleManager.php
@@ -0,0 +1,82 @@
+styleRegistry = $styleRegistry;
+ }
+
+ /**
+ * Registers the given style as a used style.
+ * Duplicate styles won't be registered more than once.
+ *
+ * @param Style $style The style to be registered
+ *
+ * @return Style the registered style, updated with an internal ID
+ */
+ public function registerStyle($style)
+ {
+ return $this->styleRegistry->registerStyle($style);
+ }
+
+ /**
+ * Apply additional styles if the given row needs it.
+ * Typically, set "wrap text" if a cell contains a new line.
+ *
+ * @return PossiblyUpdatedStyle The eventually updated style
+ */
+ public function applyExtraStylesIfNeeded(Cell $cell): PossiblyUpdatedStyle
+ {
+ return $this->applyWrapTextIfCellContainsNewLine($cell);
+ }
+
+ /**
+ * Returns the default style.
+ *
+ * @return Style Default style
+ */
+ protected function getDefaultStyle()
+ {
+ // By construction, the default style has ID 0
+ return $this->styleRegistry->getRegisteredStyles()[0];
+ }
+
+ /**
+ * Set the "wrap text" option if a cell of the given row contains a new line.
+ *
+ * @NOTE: There is a bug on the Mac version of Excel (2011 and below) where new lines
+ * are ignored even when the "wrap text" option is set. This only occurs with
+ * inline strings (shared strings do work fine).
+ * A workaround would be to encode "\n" as "_x000D_" but it does not work
+ * on the Windows version of Excel...
+ *
+ * @param Cell $cell The cell the style should be applied to
+ *
+ * @return PossiblyUpdatedStyle The eventually updated style
+ */
+ protected function applyWrapTextIfCellContainsNewLine(Cell $cell): PossiblyUpdatedStyle
+ {
+ $cellStyle = $cell->getStyle();
+
+ // if the "wrap text" option is already set, no-op
+ if (!$cellStyle->hasSetWrapText() && $cell->isString() && false !== strpos($cell->getValue(), "\n")) {
+ $cellStyle->setShouldWrapText();
+
+ return new PossiblyUpdatedStyle($cellStyle, true);
+ }
+
+ return new PossiblyUpdatedStyle($cellStyle, false);
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php b/upstream-3.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php
new file mode 100644
index 0000000..0418f3d
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php
@@ -0,0 +1,30 @@
+mergeFontStyles($mergedStyle, $style, $baseStyle);
+ $this->mergeOtherFontProperties($mergedStyle, $style, $baseStyle);
+ $this->mergeCellProperties($mergedStyle, $style, $baseStyle);
+
+ return $mergedStyle;
+ }
+
+ /**
+ * @param Style $styleToUpdate (passed as reference)
+ */
+ private function mergeFontStyles(Style $styleToUpdate, Style $style, Style $baseStyle)
+ {
+ if (!$style->hasSetFontBold() && $baseStyle->isFontBold()) {
+ $styleToUpdate->setFontBold();
+ }
+ if (!$style->hasSetFontItalic() && $baseStyle->isFontItalic()) {
+ $styleToUpdate->setFontItalic();
+ }
+ if (!$style->hasSetFontUnderline() && $baseStyle->isFontUnderline()) {
+ $styleToUpdate->setFontUnderline();
+ }
+ if (!$style->hasSetFontStrikethrough() && $baseStyle->isFontStrikethrough()) {
+ $styleToUpdate->setFontStrikethrough();
+ }
+ }
+
+ /**
+ * @param Style $styleToUpdate Style to update (passed as reference)
+ */
+ private function mergeOtherFontProperties(Style $styleToUpdate, Style $style, Style $baseStyle)
+ {
+ if (!$style->hasSetFontSize() && Style::DEFAULT_FONT_SIZE !== $baseStyle->getFontSize()) {
+ $styleToUpdate->setFontSize($baseStyle->getFontSize());
+ }
+ if (!$style->hasSetFontColor() && Style::DEFAULT_FONT_COLOR !== $baseStyle->getFontColor()) {
+ $styleToUpdate->setFontColor($baseStyle->getFontColor());
+ }
+ if (!$style->hasSetFontName() && Style::DEFAULT_FONT_NAME !== $baseStyle->getFontName()) {
+ $styleToUpdate->setFontName($baseStyle->getFontName());
+ }
+ }
+
+ /**
+ * @param Style $styleToUpdate Style to update (passed as reference)
+ */
+ private function mergeCellProperties(Style $styleToUpdate, Style $style, Style $baseStyle)
+ {
+ if (!$style->hasSetWrapText() && $baseStyle->shouldWrapText()) {
+ $styleToUpdate->setShouldWrapText();
+ }
+ if (!$style->hasSetShrinkToFit() && $baseStyle->shouldShrinkToFit()) {
+ $styleToUpdate->setShouldShrinkToFit();
+ }
+ if (!$style->hasSetCellAlignment() && $baseStyle->shouldApplyCellAlignment()) {
+ $styleToUpdate->setCellAlignment($baseStyle->getCellAlignment());
+ }
+ if (null === $style->getBorder() && $baseStyle->shouldApplyBorder()) {
+ $styleToUpdate->setBorder($baseStyle->getBorder());
+ }
+ if (null === $style->getFormat() && $baseStyle->shouldApplyFormat()) {
+ $styleToUpdate->setFormat($baseStyle->getFormat());
+ }
+ if (!$style->shouldApplyBackgroundColor() && $baseStyle->shouldApplyBackgroundColor()) {
+ $styleToUpdate->setBackgroundColor($baseStyle->getBackgroundColor());
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/Style/StyleRegistry.php b/upstream-3.x/src/Writer/Common/Manager/Style/StyleRegistry.php
new file mode 100644
index 0000000..b3782a0
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/Style/StyleRegistry.php
@@ -0,0 +1,111 @@
+ [STYLE_ID] mapping table, keeping track of the registered styles */
+ protected $serializedStyleToStyleIdMappingTable = [];
+
+ /** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */
+ protected $styleIdToStyleMappingTable = [];
+
+ public function __construct(Style $defaultStyle)
+ {
+ // This ensures that the default style is the first one to be registered
+ $this->registerStyle($defaultStyle);
+ }
+
+ /**
+ * Registers the given style as a used style.
+ * Duplicate styles won't be registered more than once.
+ *
+ * @param Style $style The style to be registered
+ *
+ * @return Style the registered style, updated with an internal ID
+ */
+ public function registerStyle(Style $style)
+ {
+ $serializedStyle = $this->serialize($style);
+
+ if (!$this->hasSerializedStyleAlreadyBeenRegistered($serializedStyle)) {
+ $nextStyleId = \count($this->serializedStyleToStyleIdMappingTable);
+ $style->markAsRegistered($nextStyleId);
+
+ $this->serializedStyleToStyleIdMappingTable[$serializedStyle] = $nextStyleId;
+ $this->styleIdToStyleMappingTable[$nextStyleId] = $style;
+ }
+
+ return $this->getStyleFromSerializedStyle($serializedStyle);
+ }
+
+ /**
+ * @return Style[] List of registered styles
+ */
+ public function getRegisteredStyles()
+ {
+ return array_values($this->styleIdToStyleMappingTable);
+ }
+
+ /**
+ * @param int $styleId
+ *
+ * @return Style
+ */
+ public function getStyleFromStyleId($styleId)
+ {
+ return $this->styleIdToStyleMappingTable[$styleId];
+ }
+
+ /**
+ * Serializes the style for future comparison with other styles.
+ * The ID is excluded from the comparison, as we only care about
+ * actual style properties.
+ *
+ * @return string The serialized style
+ */
+ public function serialize(Style $style)
+ {
+ // In order to be able to properly compare style, set static ID value and reset registration
+ $currentId = $style->getId();
+ $style->unmarkAsRegistered();
+
+ $serializedStyle = serialize($style);
+
+ $style->markAsRegistered($currentId);
+
+ return $serializedStyle;
+ }
+
+ /**
+ * Returns whether the serialized style has already been registered.
+ *
+ * @param string $serializedStyle The serialized style
+ *
+ * @return bool
+ */
+ protected function hasSerializedStyleAlreadyBeenRegistered(string $serializedStyle)
+ {
+ // Using isset here because it is way faster than array_key_exists...
+ return isset($this->serializedStyleToStyleIdMappingTable[$serializedStyle]);
+ }
+
+ /**
+ * Returns the registered style associated to the given serialization.
+ *
+ * @param string $serializedStyle The serialized style from which the actual style should be fetched from
+ *
+ * @return Style
+ */
+ protected function getStyleFromSerializedStyle($serializedStyle)
+ {
+ $styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle];
+
+ return $this->styleIdToStyleMappingTable[$styleId];
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerAbstract.php b/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerAbstract.php
new file mode 100644
index 0000000..8ffd7e9
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerAbstract.php
@@ -0,0 +1,352 @@
+workbook = $workbook;
+ $this->optionsManager = $optionsManager;
+ $this->worksheetManager = $worksheetManager;
+ $this->styleManager = $styleManager;
+ $this->styleMerger = $styleMerger;
+ $this->fileSystemHelper = $fileSystemHelper;
+ $this->entityFactory = $entityFactory;
+ $this->managerFactory = $managerFactory;
+ }
+
+ /**
+ * @return null|Workbook
+ */
+ public function getWorkbook()
+ {
+ return $this->workbook;
+ }
+
+ /**
+ * 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
+ */
+ public function addNewSheetAndMakeItCurrent()
+ {
+ $worksheet = $this->addNewSheet();
+ $this->setCurrentWorksheet($worksheet);
+
+ return $worksheet;
+ }
+
+ /**
+ * @return Worksheet[] All the workbook's sheets
+ */
+ public function getWorksheets()
+ {
+ return $this->workbook->getWorksheets();
+ }
+
+ /**
+ * Returns the current sheet.
+ *
+ * @return Worksheet The current sheet
+ */
+ public function getCurrentWorksheet()
+ {
+ return $this->currentWorksheet;
+ }
+
+ /**
+ * Starts the current sheet and opens the file pointer.
+ *
+ * @throws IOException
+ */
+ public function startCurrentSheet()
+ {
+ $this->worksheetManager->startSheet($this->getCurrentWorksheet());
+ }
+
+ /**
+ * Sets the given sheet as the current one. New data will be written to this sheet.
+ * The writing will resume where it stopped (i.e. data won't be truncated).
+ *
+ * @param Sheet $sheet The "external" sheet to set as current
+ *
+ * @throws SheetNotFoundException If the given sheet does not exist in the workbook
+ */
+ public function setCurrentSheet(Sheet $sheet)
+ {
+ $worksheet = $this->getWorksheetFromExternalSheet($sheet);
+ if (null !== $worksheet) {
+ $this->currentWorksheet = $worksheet;
+ } else {
+ throw new SheetNotFoundException('The given sheet does not exist in the workbook.');
+ }
+ }
+
+ /**
+ * Adds a row 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 Row $row The row to be added
+ *
+ * @throws IOException If trying to create a new sheet and unable to open the sheet for writing
+ * @throws \OpenSpout\Common\Exception\InvalidArgumentException
+ */
+ public function addRowToCurrentWorksheet(Row $row)
+ {
+ $currentWorksheet = $this->getCurrentWorksheet();
+ $hasReachedMaxRows = $this->hasCurrentWorksheetReachedMaxRows();
+
+ // 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->optionsManager->getOption(Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY)) {
+ $currentWorksheet = $this->addNewSheetAndMakeItCurrent();
+
+ $this->addRowToWorksheet($currentWorksheet, $row);
+ }
+ // otherwise, do nothing as the data won't be written anyways
+ } else {
+ $this->addRowToWorksheet($currentWorksheet, $row);
+ }
+ }
+
+ public function setDefaultColumnWidth(float $width)
+ {
+ $this->worksheetManager->setDefaultColumnWidth($width);
+ }
+
+ public function setDefaultRowHeight(float $height)
+ {
+ $this->worksheetManager->setDefaultRowHeight($height);
+ }
+
+ /**
+ * @param int ...$columns One or more columns with this width
+ */
+ public function setColumnWidth(float $width, ...$columns)
+ {
+ $this->worksheetManager->setColumnWidth($width, ...$columns);
+ }
+
+ /**
+ * @param float $width The width to set
+ * @param int $start First column index of the range
+ * @param int $end Last column index of the range
+ */
+ public function setColumnWidthForRange(float $width, int $start, int $end)
+ {
+ $this->worksheetManager->setColumnWidthForRange($width, $start, $end);
+ }
+
+ /**
+ * Closes the workbook and all its associated sheets.
+ * All the necessary files are written to disk and zipped together to create the final file.
+ * All the temporary files are then deleted.
+ *
+ * @param resource $finalFilePointer Pointer to the spreadsheet that will be created
+ */
+ public function close($finalFilePointer)
+ {
+ $this->closeAllWorksheets();
+ $this->closeRemainingObjects();
+ $this->writeAllFilesToDiskAndZipThem($finalFilePointer);
+ $this->cleanupTempFolder();
+ }
+
+ /**
+ * @return int Maximum number of rows/columns a sheet can contain
+ */
+ abstract protected function getMaxRowsPerWorksheet();
+
+ /**
+ * @return string The file path where the data for the given sheet will be stored
+ */
+ abstract protected function getWorksheetFilePath(Sheet $sheet);
+
+ /**
+ * Closes custom objects that are still opened.
+ */
+ protected function closeRemainingObjects()
+ {
+ // do nothing by default
+ }
+
+ /**
+ * Writes all the necessary files to disk and zip them together to create the final file.
+ *
+ * @param resource $finalFilePointer Pointer to the spreadsheet that will be created
+ */
+ abstract protected function writeAllFilesToDiskAndZipThem($finalFilePointer);
+
+ /**
+ * Deletes the root folder created in the temp folder and all its contents.
+ */
+ protected function cleanupTempFolder()
+ {
+ $rootFolder = $this->fileSystemHelper->getRootFolder();
+ $this->fileSystemHelper->deleteFolderRecursively($rootFolder);
+ }
+
+ /**
+ * Creates a new sheet in the workbook. The current sheet remains unchanged.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to open the sheet for writing
+ *
+ * @return Worksheet The created sheet
+ */
+ private function addNewSheet()
+ {
+ $worksheets = $this->getWorksheets();
+
+ $newSheetIndex = \count($worksheets);
+ $sheetManager = $this->managerFactory->createSheetManager();
+ $sheet = $this->entityFactory->createSheet($newSheetIndex, $this->workbook->getInternalId(), $sheetManager);
+
+ $worksheetFilePath = $this->getWorksheetFilePath($sheet);
+ $worksheet = $this->entityFactory->createWorksheet($worksheetFilePath, $sheet);
+
+ $this->worksheetManager->startSheet($worksheet);
+
+ $worksheets[] = $worksheet;
+ $this->workbook->setWorksheets($worksheets);
+
+ return $worksheet;
+ }
+
+ /**
+ * @param Worksheet $worksheet
+ */
+ private function setCurrentWorksheet($worksheet)
+ {
+ $this->currentWorksheet = $worksheet;
+ }
+
+ /**
+ * Returns the worksheet associated to the given external sheet.
+ *
+ * @param Sheet $sheet
+ *
+ * @return null|Worksheet the worksheet associated to the given external sheet or null if not found
+ */
+ private function getWorksheetFromExternalSheet($sheet)
+ {
+ $worksheetFound = null;
+
+ foreach ($this->getWorksheets() as $worksheet) {
+ if ($worksheet->getExternalSheet() === $sheet) {
+ $worksheetFound = $worksheet;
+
+ break;
+ }
+ }
+
+ return $worksheetFound;
+ }
+
+ /**
+ * @return bool whether the current worksheet has reached the maximum number of rows per sheet
+ */
+ private function hasCurrentWorksheetReachedMaxRows()
+ {
+ $currentWorksheet = $this->getCurrentWorksheet();
+
+ return $currentWorksheet->getLastWrittenRowIndex() >= $this->getMaxRowsPerWorksheet();
+ }
+
+ /**
+ * Adds a row to the given sheet.
+ *
+ * @param Worksheet $worksheet Worksheet to write the row to
+ * @param Row $row The row to be added
+ *
+ * @throws IOException
+ * @throws \OpenSpout\Common\Exception\InvalidArgumentException
+ */
+ private function addRowToWorksheet(Worksheet $worksheet, Row $row)
+ {
+ $this->applyDefaultRowStyle($row);
+ $this->worksheetManager->addRow($worksheet, $row);
+
+ // update max num columns for the worksheet
+ $currentMaxNumColumns = $worksheet->getMaxNumColumns();
+ $cellsCount = $row->getNumCells();
+ $worksheet->setMaxNumColumns(max($currentMaxNumColumns, $cellsCount));
+ }
+
+ private function applyDefaultRowStyle(Row $row)
+ {
+ $defaultRowStyle = $this->optionsManager->getOption(Options::DEFAULT_ROW_STYLE);
+
+ if (null !== $defaultRowStyle) {
+ $mergedStyle = $this->styleMerger->merge($row->getStyle(), $defaultRowStyle);
+ $row->setStyle($mergedStyle);
+ }
+ }
+
+ /**
+ * Closes all workbook's associated sheets.
+ */
+ private function closeAllWorksheets()
+ {
+ $worksheets = $this->getWorksheets();
+
+ foreach ($worksheets as $worksheet) {
+ $this->worksheetManager->close($worksheet);
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerInterface.php b/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerInterface.php
new file mode 100644
index 0000000..209abc6
--- /dev/null
+++ b/upstream-3.x/src/Writer/Common/Manager/WorkbookManagerInterface.php
@@ -0,0 +1,97 @@
+getOption(Options::TEMP_FOLDER);
+ $zipHelper = $this->createZipHelper($entityFactory);
+
+ return new FileSystemHelper($tempFolder, $zipHelper);
+ }
+
+ /**
+ * @return Escaper\ODS
+ */
+ public function createStringsEscaper()
+ {
+ return new Escaper\ODS();
+ }
+
+ /**
+ * @return StringHelper
+ */
+ public function createStringHelper()
+ {
+ return new StringHelper();
+ }
+
+ /**
+ * @param InternalEntityFactory $entityFactory
+ *
+ * @return ZipHelper
+ */
+ private function createZipHelper($entityFactory)
+ {
+ return new ZipHelper($entityFactory);
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Creator/ManagerFactory.php b/upstream-3.x/src/Writer/ODS/Creator/ManagerFactory.php
new file mode 100644
index 0000000..a514286
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Creator/ManagerFactory.php
@@ -0,0 +1,107 @@
+entityFactory = $entityFactory;
+ $this->helperFactory = $helperFactory;
+ }
+
+ /**
+ * @return WorkbookManager
+ */
+ public function createWorkbookManager(OptionsManagerInterface $optionsManager)
+ {
+ $workbook = $this->entityFactory->createWorkbook();
+
+ $fileSystemHelper = $this->helperFactory->createSpecificFileSystemHelper($optionsManager, $this->entityFactory);
+ $fileSystemHelper->createBaseFilesAndFolders();
+
+ $styleMerger = $this->createStyleMerger();
+ $styleManager = $this->createStyleManager($optionsManager);
+ $worksheetManager = $this->createWorksheetManager($styleManager, $styleMerger);
+
+ return new WorkbookManager(
+ $workbook,
+ $optionsManager,
+ $worksheetManager,
+ $styleManager,
+ $styleMerger,
+ $fileSystemHelper,
+ $this->entityFactory,
+ $this
+ );
+ }
+
+ /**
+ * @return SheetManager
+ */
+ public function createSheetManager()
+ {
+ $stringHelper = $this->helperFactory->createStringHelper();
+
+ return new SheetManager($stringHelper);
+ }
+
+ /**
+ * @return WorksheetManager
+ */
+ private function createWorksheetManager(StyleManager $styleManager, StyleMerger $styleMerger)
+ {
+ $stringsEscaper = $this->helperFactory->createStringsEscaper();
+ $stringsHelper = $this->helperFactory->createStringHelper();
+
+ return new WorksheetManager($styleManager, $styleMerger, $stringsEscaper, $stringsHelper);
+ }
+
+ /**
+ * @return StyleManager
+ */
+ private function createStyleManager(OptionsManagerInterface $optionsManager)
+ {
+ $styleRegistry = $this->createStyleRegistry($optionsManager);
+
+ return new StyleManager($styleRegistry, $optionsManager);
+ }
+
+ /**
+ * @return StyleRegistry
+ */
+ private function createStyleRegistry(OptionsManagerInterface $optionsManager)
+ {
+ $defaultRowStyle = $optionsManager->getOption(Options::DEFAULT_ROW_STYLE);
+
+ return new StyleRegistry($defaultRowStyle);
+ }
+
+ /**
+ * @return StyleMerger
+ */
+ private function createStyleMerger()
+ {
+ return new StyleMerger();
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Helper/BorderHelper.php b/upstream-3.x/src/Writer/ODS/Helper/BorderHelper.php
new file mode 100644
index 0000000..caaf954
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Helper/BorderHelper.php
@@ -0,0 +1,65 @@
+
+ */
+class BorderHelper
+{
+ /**
+ * Width mappings.
+ *
+ * @var array
+ */
+ protected static $widthMap = [
+ Border::WIDTH_THIN => '0.75pt',
+ Border::WIDTH_MEDIUM => '1.75pt',
+ Border::WIDTH_THICK => '2.5pt',
+ ];
+
+ /**
+ * Style mapping.
+ *
+ * @var array
+ */
+ protected static $styleMap = [
+ Border::STYLE_SOLID => 'solid',
+ Border::STYLE_DASHED => 'dashed',
+ Border::STYLE_DOTTED => 'dotted',
+ Border::STYLE_DOUBLE => 'double',
+ ];
+
+ /**
+ * @return string
+ */
+ public static function serializeBorderPart(BorderPart $borderPart)
+ {
+ $definition = 'fo:border-%s="%s"';
+
+ if (Border::STYLE_NONE === $borderPart->getStyle()) {
+ $borderPartDefinition = sprintf($definition, $borderPart->getName(), 'none');
+ } else {
+ $attributes = [
+ self::$widthMap[$borderPart->getWidth()],
+ self::$styleMap[$borderPart->getStyle()],
+ '#'.$borderPart->getColor(),
+ ];
+ $borderPartDefinition = sprintf($definition, $borderPart->getName(), implode(' ', $attributes));
+ }
+
+ return $borderPartDefinition;
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Helper/FileSystemHelper.php b/upstream-3.x/src/Writer/ODS/Helper/FileSystemHelper.php
new file mode 100644
index 0000000..b3280b6
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Helper/FileSystemHelper.php
@@ -0,0 +1,304 @@
+zipHelper = $zipHelper;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRootFolder()
+ {
+ return $this->rootFolder;
+ }
+
+ /**
+ * @return string
+ */
+ public function getSheetsContentTempFolder()
+ {
+ return $this->sheetsContentTempFolder;
+ }
+
+ /**
+ * Creates all the folders needed to create a ODS file, as well as the files that won't change.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the base folders
+ */
+ public function createBaseFilesAndFolders()
+ {
+ $this
+ ->createRootFolder()
+ ->createMetaInfoFolderAndFile()
+ ->createSheetsContentTempFolder()
+ ->createMetaFile()
+ ->createMimetypeFile()
+ ;
+ }
+
+ /**
+ * Creates the "content.xml" file under the root folder.
+ *
+ * @param WorksheetManager $worksheetManager
+ * @param StyleManager $styleManager
+ * @param Worksheet[] $worksheets
+ *
+ * @return FileSystemHelper
+ */
+ public function createContentFile($worksheetManager, $styleManager, $worksheets)
+ {
+ $contentXmlFileContents = <<<'EOD'
+
+
+ EOD;
+
+ $contentXmlFileContents .= $styleManager->getContentXmlFontFaceSectionContent();
+ $contentXmlFileContents .= $styleManager->getContentXmlAutomaticStylesSectionContent($worksheets);
+
+ $contentXmlFileContents .= '';
+
+ $this->createFileWithContents($this->rootFolder, self::CONTENT_XML_FILE_NAME, $contentXmlFileContents);
+
+ // Append sheets content to "content.xml"
+ $contentXmlFilePath = $this->rootFolder.'/'.self::CONTENT_XML_FILE_NAME;
+ $contentXmlHandle = fopen($contentXmlFilePath, 'a');
+
+ foreach ($worksheets as $worksheet) {
+ // write the "" node, with the final sheet's name
+ fwrite($contentXmlHandle, $worksheetManager->getTableElementStartAsString($worksheet));
+
+ $worksheetFilePath = $worksheet->getFilePath();
+ $this->copyFileContentsToTarget($worksheetFilePath, $contentXmlHandle);
+
+ fwrite($contentXmlHandle, '');
+ }
+
+ $contentXmlFileContents = '';
+
+ fwrite($contentXmlHandle, $contentXmlFileContents);
+ fclose($contentXmlHandle);
+
+ return $this;
+ }
+
+ /**
+ * Deletes the temporary folder where sheets content was stored.
+ *
+ * @return FileSystemHelper
+ */
+ public function deleteWorksheetTempFolder()
+ {
+ $this->deleteFolderRecursively($this->sheetsContentTempFolder);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "styles.xml" file under the root folder.
+ *
+ * @param StyleManager $styleManager
+ * @param int $numWorksheets Number of created worksheets
+ *
+ * @return FileSystemHelper
+ */
+ public function createStylesFile($styleManager, $numWorksheets)
+ {
+ $stylesXmlFileContents = $styleManager->getStylesXMLFileContent($numWorksheets);
+ $this->createFileWithContents($this->rootFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Zips the root folder and streams the contents of the zip into the given stream.
+ *
+ * @param resource $streamPointer Pointer to the stream to copy the zip
+ */
+ public function zipRootFolderAndCopyToStream($streamPointer)
+ {
+ $zip = $this->zipHelper->createZip($this->rootFolder);
+
+ $zipFilePath = $this->zipHelper->getZipFilePath($zip);
+
+ // In order to have the file's mime type detected properly, files need to be added
+ // to the zip file in a particular order.
+ // @see http://www.jejik.com/articles/2010/03/how_to_correctly_create_odf_documents_using_zip/
+ $this->zipHelper->addUncompressedFileToArchive($zip, $this->rootFolder, self::MIMETYPE_FILE_NAME);
+
+ $this->zipHelper->addFolderToArchive($zip, $this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
+ $this->zipHelper->closeArchiveAndCopyToStream($zip, $streamPointer);
+
+ // once the zip is copied, remove it
+ $this->deleteFile($zipFilePath);
+ }
+
+ /**
+ * Creates the folder that will be used as root.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
+ *
+ * @return FileSystemHelper
+ */
+ protected function createRootFolder()
+ {
+ $this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('ods'));
+
+ return $this;
+ }
+
+ /**
+ * Creates the "META-INF" folder under the root folder as well as the "manifest.xml" file in it.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or the "manifest.xml" file
+ *
+ * @return FileSystemHelper
+ */
+ protected function createMetaInfoFolderAndFile()
+ {
+ $this->metaInfFolder = $this->createFolder($this->rootFolder, self::META_INF_FOLDER_NAME);
+
+ $this->createManifestFile();
+
+ return $this;
+ }
+
+ /**
+ * Creates the "manifest.xml" file under the "META-INF" folder (under root).
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ protected function createManifestFile()
+ {
+ $manifestXmlFileContents = <<<'EOD'
+
+
+
+
+
+
+
+ EOD;
+
+ $this->createFileWithContents($this->metaInfFolder, self::MANIFEST_XML_FILE_NAME, $manifestXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Creates the temp folder where specific sheets content will be written to.
+ * This folder is not part of the final ODS file and is only used to be able to jump between sheets.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
+ *
+ * @return FileSystemHelper
+ */
+ protected function createSheetsContentTempFolder()
+ {
+ $this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, self::SHEETS_CONTENT_TEMP_FOLDER_NAME);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "meta.xml" file under the root folder.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ protected function createMetaFile()
+ {
+ $appName = self::APP_NAME;
+ $createdDate = (new \DateTime())->format(\DateTime::W3C);
+
+ $metaXmlFileContents = <<
+
+
+ {$appName}
+ {$createdDate}
+ {$createdDate}
+
+
+ EOD;
+
+ $this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "mimetype" file under the root folder.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ protected function createMimetypeFile()
+ {
+ $this->createFileWithContents($this->rootFolder, self::MIMETYPE_FILE_NAME, self::MIMETYPE);
+
+ return $this;
+ }
+
+ /**
+ * Streams the content of the file at the given path into the target resource.
+ * Depending on which mode the target resource was created with, it will truncate then copy
+ * or append the content to the target file.
+ *
+ * @param string $sourceFilePath Path of the file whose content will be copied
+ * @param resource $targetResource Target resource that will receive the content
+ */
+ protected function copyFileContentsToTarget($sourceFilePath, $targetResource)
+ {
+ $sourceHandle = fopen($sourceFilePath, 'r');
+ stream_copy_to_stream($sourceHandle, $targetResource);
+ fclose($sourceHandle);
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Manager/OptionsManager.php b/upstream-3.x/src/Writer/ODS/Manager/OptionsManager.php
new file mode 100644
index 0000000..d098abf
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Manager/OptionsManager.php
@@ -0,0 +1,50 @@
+styleBuilder = $styleBuilder;
+ parent::__construct();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getSupportedOptions()
+ {
+ return [
+ Options::TEMP_FOLDER,
+ Options::DEFAULT_ROW_STYLE,
+ Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY,
+ Options::DEFAULT_COLUMN_WIDTH,
+ Options::DEFAULT_ROW_HEIGHT,
+ Options::COLUMN_WIDTHS,
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setDefaultOptions()
+ {
+ $this->setOption(Options::TEMP_FOLDER, sys_get_temp_dir());
+ $this->setOption(Options::DEFAULT_ROW_STYLE, $this->styleBuilder->build());
+ $this->setOption(Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY, true);
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Manager/Style/StyleManager.php b/upstream-3.x/src/Writer/ODS/Manager/Style/StyleManager.php
new file mode 100644
index 0000000..e1d9976
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Manager/Style/StyleManager.php
@@ -0,0 +1,462 @@
+setDefaultColumnWidth($optionsManager->getOption(Options::DEFAULT_COLUMN_WIDTH));
+ $this->setDefaultRowHeight($optionsManager->getOption(Options::DEFAULT_ROW_HEIGHT));
+ $this->columnWidths = $optionsManager->getOption(Options::COLUMN_WIDTHS) ?? [];
+ }
+
+ /**
+ * Returns the content of the "styles.xml" file, given a list of styles.
+ *
+ * @param int $numWorksheets Number of worksheets created
+ *
+ * @return string
+ */
+ public function getStylesXMLFileContent($numWorksheets)
+ {
+ $content = <<<'EOD'
+
+
+ EOD;
+
+ $content .= $this->getFontFaceSectionContent();
+ $content .= $this->getStylesSectionContent();
+ $content .= $this->getAutomaticStylesSectionContent($numWorksheets);
+ $content .= $this->getMasterStylesSectionContent($numWorksheets);
+
+ $content .= <<<'EOD'
+
+ EOD;
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "content.xml" file.
+ *
+ * @return string
+ */
+ public function getContentXmlFontFaceSectionContent()
+ {
+ $content = '';
+ foreach ($this->styleRegistry->getUsedFonts() as $fontName) {
+ $content .= '';
+ }
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "content.xml" file.
+ *
+ * @param Worksheet[] $worksheets
+ *
+ * @return string
+ */
+ public function getContentXmlAutomaticStylesSectionContent($worksheets)
+ {
+ $content = '';
+
+ foreach ($this->styleRegistry->getRegisteredStyles() as $style) {
+ $content .= $this->getStyleSectionContent($style);
+ }
+
+ $useOptimalRowHeight = empty($this->defaultRowHeight) ? 'true' : 'false';
+ $defaultRowHeight = empty($this->defaultRowHeight) ? '15pt' : "{$this->defaultRowHeight}pt";
+ $defaultColumnWidth = empty($this->defaultColumnWidth) ? '' : "style:column-width=\"{$this->defaultColumnWidth}pt\"";
+
+ $content .= <<
+
+
+
+
+
+ EOD;
+
+ foreach ($worksheets as $worksheet) {
+ $worksheetId = $worksheet->getId();
+ $isSheetVisible = $worksheet->getExternalSheet()->isVisible() ? 'true' : 'false';
+
+ $content .= <<
+
+
+ EOD;
+ }
+
+ // Sort column widths since ODS cares about order
+ usort($this->columnWidths, function ($a, $b) {
+ if ($a[0] === $b[0]) {
+ return 0;
+ }
+
+ return ($a[0] < $b[0]) ? -1 : 1;
+ });
+ $content .= $this->getTableColumnStylesXMLContent();
+
+ $content .= '';
+
+ return $content;
+ }
+
+ public function getTableColumnStylesXMLContent(): string
+ {
+ if (empty($this->columnWidths)) {
+ return '';
+ }
+
+ $content = '';
+ foreach ($this->columnWidths as $styleIndex => $entry) {
+ $content .= <<
+
+
+ EOD;
+ }
+
+ return $content;
+ }
+
+ public function getStyledTableColumnXMLContent(int $maxNumColumns): string
+ {
+ if (empty($this->columnWidths)) {
+ return '';
+ }
+
+ $content = '';
+ foreach ($this->columnWidths as $styleIndex => $entry) {
+ $numCols = $entry[1] - $entry[0] + 1;
+ $content .= <<
+ EOD;
+ }
+ // Note: This assumes the column widths are contiguous and default width is
+ // only applied to columns after the last custom column with a custom width
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section, inside "styles.xml" file.
+ *
+ * @return string
+ */
+ protected function getFontFaceSectionContent()
+ {
+ $content = '';
+ foreach ($this->styleRegistry->getUsedFonts() as $fontName) {
+ $content .= '';
+ }
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section, inside "styles.xml" file.
+ *
+ * @return string
+ */
+ protected function getStylesSectionContent()
+ {
+ $defaultStyle = $this->getDefaultStyle();
+
+ return <<
+
+
+
+
+
+
+
+
+ EOD;
+ }
+
+ /**
+ * Returns the content of the "" section, inside "styles.xml" file.
+ *
+ * @param int $numWorksheets Number of worksheets created
+ *
+ * @return string
+ */
+ protected function getAutomaticStylesSectionContent($numWorksheets)
+ {
+ $content = '';
+
+ for ($i = 1; $i <= $numWorksheets; ++$i) {
+ $content .= <<
+
+
+
+
+ EOD;
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section, inside "styles.xml" file.
+ *
+ * @param int $numWorksheets Number of worksheets created
+ *
+ * @return string
+ */
+ protected function getMasterStylesSectionContent($numWorksheets)
+ {
+ $content = '';
+
+ for ($i = 1; $i <= $numWorksheets; ++$i) {
+ $content .= <<
+
+
+
+
+
+ EOD;
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ protected function getStyleSectionContent($style)
+ {
+ $styleIndex = $style->getId() + 1; // 1-based
+
+ $content = '';
+
+ $content .= $this->getTextPropertiesSectionContent($style);
+ $content .= $this->getParagraphPropertiesSectionContent($style);
+ $content .= $this->getTableCellPropertiesSectionContent($style);
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getTextPropertiesSectionContent($style)
+ {
+ if (!$style->shouldApplyFont()) {
+ return '';
+ }
+
+ return 'getFontSectionContent($style)
+ .'/>';
+ }
+
+ /**
+ * Returns the contents of the fonts definition section, inside "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getFontSectionContent($style)
+ {
+ $defaultStyle = $this->getDefaultStyle();
+ $content = '';
+
+ $fontColor = $style->getFontColor();
+ if ($fontColor !== $defaultStyle->getFontColor()) {
+ $content .= ' fo:color="#'.$fontColor.'"';
+ }
+
+ $fontName = $style->getFontName();
+ if ($fontName !== $defaultStyle->getFontName()) {
+ $content .= ' style:font-name="'.$fontName.'" style:font-name-asian="'.$fontName.'" style:font-name-complex="'.$fontName.'"';
+ }
+
+ $fontSize = $style->getFontSize();
+ if ($fontSize !== $defaultStyle->getFontSize()) {
+ $content .= ' fo:font-size="'.$fontSize.'pt" style:font-size-asian="'.$fontSize.'pt" style:font-size-complex="'.$fontSize.'pt"';
+ }
+
+ if ($style->isFontBold()) {
+ $content .= ' fo:font-weight="bold" style:font-weight-asian="bold" style:font-weight-complex="bold"';
+ }
+ if ($style->isFontItalic()) {
+ $content .= ' fo:font-style="italic" style:font-style-asian="italic" style:font-style-complex="italic"';
+ }
+ if ($style->isFontUnderline()) {
+ $content .= ' style:text-underline-style="solid" style:text-underline-type="single"';
+ }
+ if ($style->isFontStrikethrough()) {
+ $content .= ' style:text-line-through-style="solid"';
+ }
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getParagraphPropertiesSectionContent($style)
+ {
+ if (!$style->shouldApplyCellAlignment()) {
+ return '';
+ }
+
+ return 'getCellAlignmentSectionContent($style)
+ .'/>';
+ }
+
+ /**
+ * Returns the contents of the cell alignment definition for the "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getCellAlignmentSectionContent($style)
+ {
+ return sprintf(
+ ' fo:text-align="%s" ',
+ $this->transformCellAlignment($style->getCellAlignment())
+ );
+ }
+
+ /**
+ * Even though "left" and "right" alignments are part of the spec, and interpreted
+ * respectively as "start" and "end", using the recommended values increase compatibility
+ * with software that will read the created ODS file.
+ *
+ * @param string $cellAlignment
+ *
+ * @return string
+ */
+ private function transformCellAlignment($cellAlignment)
+ {
+ switch ($cellAlignment) {
+ case CellAlignment::LEFT:
+ return 'start';
+
+ case CellAlignment::RIGHT:
+ return 'end';
+
+ default:
+ return $cellAlignment;
+ }
+ }
+
+ /**
+ * Returns the contents of the "" section, inside "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getTableCellPropertiesSectionContent($style)
+ {
+ $content = 'shouldWrapText()) {
+ $content .= $this->getWrapTextXMLContent();
+ }
+
+ if ($style->shouldApplyBorder()) {
+ $content .= $this->getBorderXMLContent($style);
+ }
+
+ if ($style->shouldApplyBackgroundColor()) {
+ $content .= $this->getBackgroundColorXMLContent($style);
+ }
+
+ $content .= '/>';
+
+ return $content;
+ }
+
+ /**
+ * Returns the contents of the wrap text definition for the "" section.
+ *
+ * @return string
+ */
+ private function getWrapTextXMLContent()
+ {
+ return ' fo:wrap-option="wrap" style:vertical-align="automatic" ';
+ }
+
+ /**
+ * Returns the contents of the borders definition for the "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getBorderXMLContent($style)
+ {
+ $borders = array_map(function (BorderPart $borderPart) {
+ return BorderHelper::serializeBorderPart($borderPart);
+ }, $style->getBorder()->getParts());
+
+ return sprintf(' %s ', implode(' ', $borders));
+ }
+
+ /**
+ * Returns the contents of the background color definition for the "" section.
+ *
+ * @param \OpenSpout\Common\Entity\Style\Style $style
+ *
+ * @return string
+ */
+ private function getBackgroundColorXMLContent($style)
+ {
+ return sprintf(' fo:background-color="#%s" ', $style->getBackgroundColor());
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Manager/Style/StyleRegistry.php b/upstream-3.x/src/Writer/ODS/Manager/Style/StyleRegistry.php
new file mode 100644
index 0000000..e90dc96
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Manager/Style/StyleRegistry.php
@@ -0,0 +1,42 @@
+ [] Map whose keys contain all the fonts used */
+ protected $usedFontsSet = [];
+
+ /**
+ * Registers the given style as a used style.
+ * Duplicate styles won't be registered more than once.
+ *
+ * @param Style $style The style to be registered
+ *
+ * @return Style the registered style, updated with an internal ID
+ */
+ public function registerStyle(Style $style)
+ {
+ if ($style->isRegistered()) {
+ return $style;
+ }
+
+ $registeredStyle = parent::registerStyle($style);
+ $this->usedFontsSet[$style->getFontName()] = true;
+
+ return $registeredStyle;
+ }
+
+ /**
+ * @return string[] List of used fonts name
+ */
+ public function getUsedFonts()
+ {
+ return array_keys($this->usedFontsSet);
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Manager/WorkbookManager.php b/upstream-3.x/src/Writer/ODS/Manager/WorkbookManager.php
new file mode 100644
index 0000000..90dc0e4
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Manager/WorkbookManager.php
@@ -0,0 +1,66 @@
+fileSystemHelper->getSheetsContentTempFolder();
+
+ return $sheetsContentTempFolder.'/sheet'.$sheet->getIndex().'.xml';
+ }
+
+ /**
+ * @return int Maximum number of rows/columns a sheet can contain
+ */
+ protected function getMaxRowsPerWorksheet()
+ {
+ return self::$maxRowsPerWorksheet;
+ }
+
+ /**
+ * Writes all the necessary files to disk and zip them together to create the final file.
+ *
+ * @param resource $finalFilePointer Pointer to the spreadsheet that will be created
+ */
+ protected function writeAllFilesToDiskAndZipThem($finalFilePointer)
+ {
+ $worksheets = $this->getWorksheets();
+ $numWorksheets = \count($worksheets);
+
+ $this->fileSystemHelper
+ ->createContentFile($this->worksheetManager, $this->styleManager, $worksheets)
+ ->deleteWorksheetTempFolder()
+ ->createStylesFile($this->styleManager, $numWorksheets)
+ ->zipRootFolderAndCopyToStream($finalFilePointer)
+ ;
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Manager/WorksheetManager.php b/upstream-3.x/src/Writer/ODS/Manager/WorksheetManager.php
new file mode 100644
index 0000000..3eeca5f
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Manager/WorksheetManager.php
@@ -0,0 +1,308 @@
+styleManager = $styleManager;
+ $this->styleMerger = $styleMerger;
+ $this->stringsEscaper = $stringsEscaper;
+ $this->stringHelper = $stringHelper;
+ }
+
+ /**
+ * Prepares the worksheet to accept data.
+ *
+ * @param Worksheet $worksheet The worksheet to start
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the sheet data file cannot be opened for writing
+ */
+ public function startSheet(Worksheet $worksheet)
+ {
+ $sheetFilePointer = fopen($worksheet->getFilePath(), 'w');
+ $this->throwIfSheetFilePointerIsNotAvailable($sheetFilePointer);
+
+ $worksheet->setFilePointer($sheetFilePointer);
+ }
+
+ /**
+ * Returns the table XML root node as string.
+ *
+ * @return string "" node as string
+ */
+ public function getTableElementStartAsString(Worksheet $worksheet)
+ {
+ $externalSheet = $worksheet->getExternalSheet();
+ $escapedSheetName = $this->stringsEscaper->escape($externalSheet->getName());
+ $tableStyleName = 'ta'.($externalSheet->getIndex() + 1);
+
+ $tableElement = '';
+ $tableElement .= $this->styleManager->getStyledTableColumnXMLContent($worksheet->getMaxNumColumns());
+
+ return $tableElement;
+ }
+
+ /**
+ * Adds a row to the given worksheet.
+ *
+ * @param Worksheet $worksheet The worksheet to add the row to
+ * @param Row $row The row to be added
+ *
+ * @throws InvalidArgumentException If a cell value's type is not supported
+ * @throws IOException If the data cannot be written
+ */
+ public function addRow(Worksheet $worksheet, Row $row)
+ {
+ $cells = $row->getCells();
+ $rowStyle = $row->getStyle();
+
+ $data = '';
+
+ $currentCellIndex = 0;
+ $nextCellIndex = 1;
+
+ for ($i = 0; $i < $row->getNumCells(); ++$i) {
+ /** @var Cell $cell */
+ $cell = $cells[$currentCellIndex];
+ /** @var null|Cell $nextCell */
+ $nextCell = $cells[$nextCellIndex] ?? null;
+
+ if (null === $nextCell || $cell->getValue() !== $nextCell->getValue()) {
+ $registeredStyle = $this->applyStyleAndRegister($cell, $rowStyle);
+ $cellStyle = $registeredStyle->getStyle();
+ if ($registeredStyle->isMatchingRowStyle()) {
+ $rowStyle = $cellStyle; // Replace actual rowStyle (possibly with null id) by registered style (with id)
+ }
+
+ $data .= $this->getCellXMLWithStyle($cell, $cellStyle, $currentCellIndex, $nextCellIndex);
+ $currentCellIndex = $nextCellIndex;
+ }
+
+ ++$nextCellIndex;
+ }
+
+ $data .= '';
+
+ $wasWriteSuccessful = fwrite($worksheet->getFilePointer(), $data);
+ if (false === $wasWriteSuccessful) {
+ throw new IOException("Unable to write data in {$worksheet->getFilePath()}");
+ }
+
+ // only update the count if the write worked
+ $lastWrittenRowIndex = $worksheet->getLastWrittenRowIndex();
+ $worksheet->setLastWrittenRowIndex($lastWrittenRowIndex + 1);
+ }
+
+ /**
+ * Closes the worksheet.
+ */
+ public function close(Worksheet $worksheet)
+ {
+ $worksheetFilePointer = $worksheet->getFilePointer();
+
+ if (!\is_resource($worksheetFilePointer)) {
+ return;
+ }
+
+ fclose($worksheetFilePointer);
+ }
+
+ /**
+ * @param null|float $width
+ */
+ public function setDefaultColumnWidth($width)
+ {
+ $this->styleManager->setDefaultColumnWidth($width);
+ }
+
+ /**
+ * @param null|float $height
+ */
+ public function setDefaultRowHeight($height)
+ {
+ $this->styleManager->setDefaultRowHeight($height);
+ }
+
+ /**
+ * @param int ...$columns One or more columns with this width
+ */
+ public function setColumnWidth(float $width, ...$columns)
+ {
+ $this->styleManager->setColumnWidth($width, ...$columns);
+ }
+
+ /**
+ * @param float $width The width to set
+ * @param int $start First column index of the range
+ * @param int $end Last column index of the range
+ */
+ public function setColumnWidthForRange(float $width, int $start, int $end)
+ {
+ $this->styleManager->setColumnWidthForRange($width, $start, $end);
+ }
+
+ /**
+ * Checks if the sheet has been sucessfully created. Throws an exception if not.
+ *
+ * @param bool|resource $sheetFilePointer Pointer to the sheet data file or FALSE if unable to open the file
+ *
+ * @throws IOException If the sheet data file cannot be opened for writing
+ */
+ private function throwIfSheetFilePointerIsNotAvailable($sheetFilePointer)
+ {
+ if (!$sheetFilePointer) {
+ throw new IOException('Unable to open sheet for writing.');
+ }
+ }
+
+ /**
+ * Applies styles to the given style, merging the cell's style with its row's style.
+ *
+ * @throws InvalidArgumentException If a cell value's type is not supported
+ */
+ private function applyStyleAndRegister(Cell $cell, Style $rowStyle): RegisteredStyle
+ {
+ $isMatchingRowStyle = false;
+ if ($cell->getStyle()->isEmpty()) {
+ $cell->setStyle($rowStyle);
+
+ $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
+
+ if ($possiblyUpdatedStyle->isUpdated()) {
+ $registeredStyle = $this->styleManager->registerStyle($possiblyUpdatedStyle->getStyle());
+ } else {
+ $registeredStyle = $this->styleManager->registerStyle($rowStyle);
+ $isMatchingRowStyle = true;
+ }
+ } else {
+ $mergedCellAndRowStyle = $this->styleMerger->merge($cell->getStyle(), $rowStyle);
+ $cell->setStyle($mergedCellAndRowStyle);
+
+ $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
+ if ($possiblyUpdatedStyle->isUpdated()) {
+ $newCellStyle = $possiblyUpdatedStyle->getStyle();
+ } else {
+ $newCellStyle = $mergedCellAndRowStyle;
+ }
+
+ $registeredStyle = $this->styleManager->registerStyle($newCellStyle);
+ }
+
+ return new RegisteredStyle($registeredStyle, $isMatchingRowStyle);
+ }
+
+ private function getCellXMLWithStyle(Cell $cell, Style $style, int $currentCellIndex, int $nextCellIndex): string
+ {
+ $styleIndex = $style->getId() + 1; // 1-based
+
+ $numTimesValueRepeated = ($nextCellIndex - $currentCellIndex);
+
+ return $this->getCellXML($cell, $styleIndex, $numTimesValueRepeated);
+ }
+
+ /**
+ * Returns the cell XML content, given its value.
+ *
+ * @param Cell $cell The cell to be written
+ * @param int $styleIndex Index of the used style
+ * @param int $numTimesValueRepeated Number of times the value is consecutively repeated
+ *
+ * @throws InvalidArgumentException If a cell value's type is not supported
+ *
+ * @return string The cell XML content
+ */
+ private function getCellXML(Cell $cell, $styleIndex, $numTimesValueRepeated)
+ {
+ $data = 'isString()) {
+ $data .= ' office:value-type="string" calcext:value-type="string">';
+
+ $cellValueLines = explode("\n", $cell->getValue());
+ foreach ($cellValueLines as $cellValueLine) {
+ $data .= ''.$this->stringsEscaper->escape($cellValueLine).'';
+ }
+
+ $data .= '';
+ } elseif ($cell->isBoolean()) {
+ $value = $cell->getValue() ? 'true' : 'false'; // boolean-value spec: http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#datatype-boolean
+ $data .= ' office:value-type="boolean" calcext:value-type="boolean" office:boolean-value="'.$value.'">';
+ $data .= ''.$cell->getValue().'';
+ $data .= '';
+ } elseif ($cell->isNumeric()) {
+ $cellValue = $this->stringHelper->formatNumericValue($cell->getValue());
+ $data .= ' office:value-type="float" calcext:value-type="float" office:value="'.$cellValue.'">';
+ $data .= ''.$cellValue.'';
+ $data .= '';
+ } elseif ($cell->isDate()) {
+ $value = $cell->getValue();
+ if ($value instanceof \DateTimeInterface) {
+ $datevalue = substr((new \DateTimeImmutable('@'.$value->getTimestamp()))->format(\DateTimeInterface::W3C), 0, -6);
+ $data .= ' office:value-type="date" calcext:value-type="date" office:date-value="'.$datevalue.'Z">';
+ $data .= ''.$datevalue.'Z';
+ } elseif ($value instanceof \DateInterval) {
+ // workaround for missing DateInterval::format('c'), see https://stackoverflow.com/a/61088115/53538
+ static $f = ['M0S', 'H0M', 'DT0H', 'M0D', 'Y0M', 'P0Y', 'Y0M', 'P0M'];
+ static $r = ['M', 'H', 'DT', 'M', 'Y0M', 'P', 'Y', 'P'];
+ $value = rtrim(str_replace($f, $r, $value->format('P%yY%mM%dDT%hH%iM%sS')), 'PT') ?: 'PT0S';
+ $data .= ' office:value-type="time" office:time-value="'.$value.'">';
+ $data .= ''.$value.'';
+ } else {
+ throw new InvalidArgumentException('Trying to add a date value with an unsupported type: '.\gettype($cell->getValue()));
+ }
+ $data .= '';
+ } elseif ($cell->isError() && \is_string($cell->getValueEvenIfError())) {
+ // only writes the error value if it's a string
+ $data .= ' office:value-type="string" calcext:value-type="error" office:value="">';
+ $data .= ''.$cell->getValueEvenIfError().'';
+ $data .= '';
+ } elseif ($cell->isEmpty()) {
+ $data .= '/>';
+ } else {
+ throw new InvalidArgumentException('Trying to add a value with an unsupported type: '.\gettype($cell->getValue()));
+ }
+
+ return $data;
+ }
+}
diff --git a/upstream-3.x/src/Writer/ODS/Writer.php b/upstream-3.x/src/Writer/ODS/Writer.php
new file mode 100644
index 0000000..5f510e6
--- /dev/null
+++ b/upstream-3.x/src/Writer/ODS/Writer.php
@@ -0,0 +1,34 @@
+throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+
+ $this->optionsManager->setOption(Options::TEMP_FOLDER, $tempFolder);
+
+ return $this;
+ }
+}
diff --git a/upstream-3.x/src/Writer/WriterAbstract.php b/upstream-3.x/src/Writer/WriterAbstract.php
new file mode 100644
index 0000000..fd7c472
--- /dev/null
+++ b/upstream-3.x/src/Writer/WriterAbstract.php
@@ -0,0 +1,253 @@
+optionsManager = $optionsManager;
+ $this->globalFunctionsHelper = $globalFunctionsHelper;
+ $this->helperFactory = $helperFactory;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function setDefaultRowStyle(Style $defaultStyle)
+ {
+ $this->optionsManager->setOption(Options::DEFAULT_ROW_STYLE, $defaultStyle);
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function openToFile($outputFilePath)
+ {
+ $this->outputFilePath = $outputFilePath;
+
+ $this->filePointer = $this->globalFunctionsHelper->fopen($this->outputFilePath, 'w');
+ $this->throwIfFilePointerIsNotAvailable();
+
+ $this->openWriter();
+ $this->isWriterOpened = true;
+
+ return $this;
+ }
+
+ /**
+ * @codeCoverageIgnore
+ * {@inheritdoc}
+ */
+ public function openToBrowser($outputFileName)
+ {
+ $this->outputFilePath = $this->globalFunctionsHelper->basename($outputFileName);
+
+ $this->filePointer = $this->globalFunctionsHelper->fopen('php://output', 'w');
+ $this->throwIfFilePointerIsNotAvailable();
+
+ // Clear any previous output (otherwise the generated file will be corrupted)
+ // @see https://github.com/box/spout/issues/241
+ $this->globalFunctionsHelper->ob_end_clean();
+
+ /*
+ * Set headers
+ *
+ * For newer browsers such as Firefox, Chrome, Opera, Safari, etc., they all support and use `filename*`
+ * specified by the new standard, even if they do not automatically decode filename; it does not matter;
+ * and for older versions of Internet Explorer, they are not recognized `filename*`, will automatically
+ * ignore it and use the old `filename` (the only minor flaw is that there must be an English suffix name).
+ * In this way, the multi-browser multi-language compatibility problem is perfectly solved, which does not
+ * require UA judgment and is more in line with the standard.
+ *
+ * @see https://github.com/box/spout/issues/745
+ * @see https://tools.ietf.org/html/rfc6266
+ * @see https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Disposition
+ */
+ $this->globalFunctionsHelper->header('Content-Type: '.static::$headerContentType);
+ $this->globalFunctionsHelper->header(
+ 'Content-Disposition: attachment; '.
+ 'filename="'.rawurlencode($this->outputFilePath).'"; '.
+ 'filename*=UTF-8\'\''.rawurlencode($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;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addRow(Row $row)
+ {
+ if ($this->isWriterOpened) {
+ try {
+ $this->addRowToWriter($row);
+ } catch (SpoutException $e) {
+ // if an exception occurs while writing data,
+ // close the writer and remove all files created so far.
+ $this->closeAndAttemptToCleanupAllFiles();
+
+ // re-throw the exception to alert developers of the error
+ throw $e;
+ }
+ } else {
+ throw new WriterNotOpenedException('The writer needs to be opened before adding row.');
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addRows(array $rows)
+ {
+ foreach ($rows as $row) {
+ if (!$row instanceof Row) {
+ $this->closeAndAttemptToCleanupAllFiles();
+
+ throw new InvalidArgumentException('The input should be an array of Row');
+ }
+
+ $this->addRow($row);
+ }
+
+ return $this;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function close()
+ {
+ if (!$this->isWriterOpened) {
+ return;
+ }
+
+ $this->closeWriter();
+
+ if (\is_resource($this->filePointer)) {
+ $this->globalFunctionsHelper->fclose($this->filePointer);
+ }
+
+ $this->isWriterOpened = false;
+ }
+
+ /**
+ * Opens the streamer and makes it ready to accept data.
+ *
+ * @throws IOException If the writer cannot be opened
+ */
+ abstract protected function openWriter();
+
+ /**
+ * Adds a row to the currently opened writer.
+ *
+ * @param Row $row The row containing cells and styles
+ *
+ * @throws WriterNotOpenedException If the workbook is not created yet
+ * @throws IOException If unable to write data
+ */
+ abstract protected function addRowToWriter(Row $row);
+
+ /**
+ * Closes the streamer, preventing any additional writing.
+ */
+ abstract protected function closeWriter();
+
+ /**
+ * Checks if the pointer to the file/stream to write to is available.
+ * Will throw an exception if not available.
+ *
+ * @throws IOException If the pointer is not available
+ */
+ protected function throwIfFilePointerIsNotAvailable()
+ {
+ if (!\is_resource($this->filePointer)) {
+ throw new IOException('File pointer has not be opened');
+ }
+ }
+
+ /**
+ * Checks if the writer has already been opened, since some actions must be done before it gets opened.
+ * Throws an exception if already opened.
+ *
+ * @param string $message Error message
+ *
+ * @throws WriterAlreadyOpenedException if the writer was already opened and must not be
+ */
+ protected function throwIfWriterAlreadyOpened($message)
+ {
+ if ($this->isWriterOpened) {
+ throw new WriterAlreadyOpenedException($message);
+ }
+ }
+
+ /**
+ * Closes the writer and attempts to cleanup all files that were
+ * created during the writing process (temp files & final file).
+ */
+ private function closeAndAttemptToCleanupAllFiles()
+ {
+ // close the writer, which should remove all temp files
+ $this->close();
+
+ // remove output file if it was created
+ if ($this->globalFunctionsHelper->file_exists($this->outputFilePath)) {
+ $outputFolderPath = \dirname($this->outputFilePath);
+ $fileSystemHelper = $this->helperFactory->createFileSystemHelper($outputFolderPath);
+ $fileSystemHelper->deleteFile($this->outputFilePath);
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/WriterInterface.php b/upstream-3.x/src/Writer/WriterInterface.php
new file mode 100644
index 0000000..71a298e
--- /dev/null
+++ b/upstream-3.x/src/Writer/WriterInterface.php
@@ -0,0 +1,77 @@
+managerFactory = $managerFactory;
+ }
+
+ /**
+ * Sets whether new sheets should be automatically created when the max rows limit per sheet is reached.
+ * This must be set before opening the writer.
+ *
+ * @param bool $shouldCreateNewSheetsAutomatically Whether new sheets should be automatically created when the max rows limit per sheet is reached
+ *
+ * @throws WriterAlreadyOpenedException If the writer was already opened
+ *
+ * @return WriterMultiSheetsAbstract
+ */
+ public function setShouldCreateNewSheetsAutomatically($shouldCreateNewSheetsAutomatically)
+ {
+ $this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+
+ $this->optionsManager->setOption(
+ Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY,
+ $shouldCreateNewSheetsAutomatically
+ );
+
+ return $this;
+ }
+
+ /**
+ * Returns all the workbook's sheets.
+ *
+ * @throws WriterNotOpenedException If the writer has not been opened yet
+ *
+ * @return Sheet[] All the workbook's sheets
+ */
+ public function getSheets()
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+
+ $externalSheets = [];
+ $worksheets = $this->workbookManager->getWorksheets();
+
+ 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.
+ *
+ * @throws IOException
+ * @throws WriterNotOpenedException If the writer has not been opened yet
+ *
+ * @return Sheet The created sheet
+ */
+ public function addNewSheetAndMakeItCurrent()
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+ $worksheet = $this->workbookManager->addNewSheetAndMakeItCurrent();
+
+ return $worksheet->getExternalSheet();
+ }
+
+ /**
+ * Returns the current sheet.
+ *
+ * @throws WriterNotOpenedException If the writer has not been opened yet
+ *
+ * @return Sheet The current sheet
+ */
+ public function getCurrentSheet()
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+
+ return $this->workbookManager->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
+ *
+ * @throws SheetNotFoundException If the given sheet does not exist in the workbook
+ * @throws WriterNotOpenedException If the writer has not been opened yet
+ */
+ public function setCurrentSheet($sheet)
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+ $this->workbookManager->setCurrentSheet($sheet);
+ }
+
+ /**
+ * @throws WriterAlreadyOpenedException
+ */
+ public function setDefaultColumnWidth(float $width)
+ {
+ $this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+ $this->optionsManager->setOption(
+ Options::DEFAULT_COLUMN_WIDTH,
+ $width
+ );
+ }
+
+ /**
+ * @throws WriterAlreadyOpenedException
+ */
+ public function setDefaultRowHeight(float $height)
+ {
+ $this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+ $this->optionsManager->setOption(
+ Options::DEFAULT_ROW_HEIGHT,
+ $height
+ );
+ }
+
+ /**
+ * @param null|float $width
+ * @param int ...$columns One or more columns with this width
+ *
+ * @throws WriterNotOpenedException
+ */
+ public function setColumnWidth($width, ...$columns)
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+ $this->workbookManager->setColumnWidth($width, ...$columns);
+ }
+
+ /**
+ * @param float $width The width to set
+ * @param int $start First column index of the range
+ * @param int $end Last column index of the range
+ *
+ * @throws WriterNotOpenedException
+ */
+ public function setColumnWidthForRange(float $width, int $start, int $end)
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+ $this->workbookManager->setColumnWidthForRange($width, $start, $end);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function openWriter()
+ {
+ if (null === $this->workbookManager) {
+ $this->workbookManager = $this->managerFactory->createWorkbookManager($this->optionsManager);
+ $this->workbookManager->addNewSheetAndMakeItCurrent();
+ }
+ }
+
+ /**
+ * Checks if the workbook has been created. Throws an exception if not created yet.
+ *
+ * @throws WriterNotOpenedException If the workbook is not created yet
+ */
+ protected function throwIfWorkbookIsNotAvailable()
+ {
+ if (!$this->workbookManager->getWorkbook()) {
+ throw new WriterNotOpenedException('The writer must be opened before performing this action.');
+ }
+ }
+
+ /**
+ * {@inheritdoc}
+ *
+ * @throws Exception\WriterException
+ */
+ protected function addRowToWriter(Row $row)
+ {
+ $this->throwIfWorkbookIsNotAvailable();
+ $this->workbookManager->addRowToCurrentWorksheet($row);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function closeWriter()
+ {
+ if (null !== $this->workbookManager) {
+ $this->workbookManager->close($this->filePointer);
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Creator/HelperFactory.php b/upstream-3.x/src/Writer/XLSX/Creator/HelperFactory.php
new file mode 100644
index 0000000..38a7f0d
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Creator/HelperFactory.php
@@ -0,0 +1,53 @@
+getOption(Options::TEMP_FOLDER);
+ $zipHelper = $this->createZipHelper($entityFactory);
+ $escaper = $this->createStringsEscaper();
+
+ return new FileSystemHelper($tempFolder, $zipHelper, $escaper);
+ }
+
+ /**
+ * @return Escaper\XLSX
+ */
+ public function createStringsEscaper()
+ {
+ return new Escaper\XLSX();
+ }
+
+ /**
+ * @return StringHelper
+ */
+ public function createStringHelper()
+ {
+ return new StringHelper();
+ }
+
+ /**
+ * @return ZipHelper
+ */
+ private function createZipHelper(InternalEntityFactory $entityFactory)
+ {
+ return new ZipHelper($entityFactory);
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Creator/ManagerFactory.php b/upstream-3.x/src/Writer/XLSX/Creator/ManagerFactory.php
new file mode 100644
index 0000000..47e60ce
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Creator/ManagerFactory.php
@@ -0,0 +1,145 @@
+entityFactory = $entityFactory;
+ $this->helperFactory = $helperFactory;
+ }
+
+ /**
+ * @return WorkbookManager
+ */
+ public function createWorkbookManager(OptionsManagerInterface $optionsManager)
+ {
+ $workbook = $this->entityFactory->createWorkbook();
+
+ $fileSystemHelper = $this->helperFactory->createSpecificFileSystemHelper($optionsManager, $this->entityFactory);
+ $fileSystemHelper->createBaseFilesAndFolders();
+
+ $xlFolder = $fileSystemHelper->getXlFolder();
+ $sharedStringsManager = $this->createSharedStringsManager($xlFolder);
+
+ $styleMerger = $this->createStyleMerger();
+ $styleManager = $this->createStyleManager($optionsManager);
+ $worksheetManager = $this->createWorksheetManager($optionsManager, $styleManager, $styleMerger, $sharedStringsManager);
+
+ return new WorkbookManager(
+ $workbook,
+ $optionsManager,
+ $worksheetManager,
+ $styleManager,
+ $styleMerger,
+ $fileSystemHelper,
+ $this->entityFactory,
+ $this
+ );
+ }
+
+ /**
+ * @return SheetManager
+ */
+ public function createSheetManager()
+ {
+ $stringHelper = $this->helperFactory->createStringHelper();
+
+ return new SheetManager($stringHelper);
+ }
+
+ /**
+ * @return RowManager
+ */
+ public function createRowManager()
+ {
+ return new RowManager();
+ }
+
+ /**
+ * @return WorksheetManager
+ */
+ private function createWorksheetManager(
+ OptionsManagerInterface $optionsManager,
+ StyleManager $styleManager,
+ StyleMerger $styleMerger,
+ SharedStringsManager $sharedStringsManager
+ ) {
+ $rowManager = $this->createRowManager();
+ $stringsEscaper = $this->helperFactory->createStringsEscaper();
+ $stringsHelper = $this->helperFactory->createStringHelper();
+
+ return new WorksheetManager(
+ $optionsManager,
+ $rowManager,
+ $styleManager,
+ $styleMerger,
+ $sharedStringsManager,
+ $stringsEscaper,
+ $stringsHelper
+ );
+ }
+
+ /**
+ * @return StyleManager
+ */
+ private function createStyleManager(OptionsManagerInterface $optionsManager)
+ {
+ $styleRegistry = $this->createStyleRegistry($optionsManager);
+
+ return new StyleManager($styleRegistry);
+ }
+
+ /**
+ * @return StyleRegistry
+ */
+ private function createStyleRegistry(OptionsManagerInterface $optionsManager)
+ {
+ $defaultRowStyle = $optionsManager->getOption(Options::DEFAULT_ROW_STYLE);
+
+ return new StyleRegistry($defaultRowStyle);
+ }
+
+ /**
+ * @return StyleMerger
+ */
+ private function createStyleMerger()
+ {
+ return new StyleMerger();
+ }
+
+ /**
+ * @param string $xlFolder Path to the "xl" folder
+ *
+ * @return SharedStringsManager
+ */
+ private function createSharedStringsManager($xlFolder)
+ {
+ $stringEscaper = $this->helperFactory->createStringsEscaper();
+
+ return new SharedStringsManager($xlFolder, $stringEscaper);
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Entity/SheetView.php b/upstream-3.x/src/Writer/XLSX/Entity/SheetView.php
new file mode 100644
index 0000000..7141615
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Entity/SheetView.php
@@ -0,0 +1,289 @@
+showFormulas = $showFormulas;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setShowGridLines(bool $showGridLines): self
+ {
+ $this->showGridLines = $showGridLines;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setShowRowColHeaders(bool $showRowColHeaders): self
+ {
+ $this->showRowColHeaders = $showRowColHeaders;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setShowZeroes(bool $showZeroes): self
+ {
+ $this->showZeroes = $showZeroes;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setRightToLeft(bool $rightToLeft): self
+ {
+ $this->rightToLeft = $rightToLeft;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setTabSelected(bool $tabSelected): self
+ {
+ $this->tabSelected = $tabSelected;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setShowOutlineSymbols(bool $showOutlineSymbols): self
+ {
+ $this->showOutlineSymbols = $showOutlineSymbols;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setDefaultGridColor(bool $defaultGridColor): self
+ {
+ $this->defaultGridColor = $defaultGridColor;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setView(string $view): self
+ {
+ $this->view = $view;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setTopLeftCell(string $topLeftCell): self
+ {
+ $this->topLeftCell = $topLeftCell;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setColorId(int $colorId): self
+ {
+ $this->colorId = $colorId;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setZoomScale(int $zoomScale): self
+ {
+ $this->zoomScale = $zoomScale;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setZoomScaleNormal(int $zoomScaleNormal): self
+ {
+ $this->zoomScaleNormal = $zoomScaleNormal;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setZoomScalePageLayoutView(int $zoomScalePageLayoutView): self
+ {
+ $this->zoomScalePageLayoutView = $zoomScalePageLayoutView;
+
+ return $this;
+ }
+
+ /**
+ * @return $this
+ */
+ public function setWorkbookViewId(int $workbookViewId): self
+ {
+ $this->workbookViewId = $workbookViewId;
+
+ return $this;
+ }
+
+ /**
+ * @param int $freezeRow Set to 2 to fix the first row
+ *
+ * @return $this
+ */
+ public function setFreezeRow(int $freezeRow): self
+ {
+ if ($freezeRow < 1) {
+ throw new InvalidArgumentException('Freeze row must be a positive integer', 1589543073);
+ }
+
+ $this->freezeRow = $freezeRow;
+
+ return $this;
+ }
+
+ /**
+ * @param string $freezeColumn Set to B to fix the first column
+ *
+ * @return $this
+ */
+ public function setFreezeColumn(string $freezeColumn): self
+ {
+ $this->freezeColumn = strtoupper($freezeColumn);
+
+ return $this;
+ }
+
+ public function getXml(): string
+ {
+ return 'getSheetViewAttributes().'>'.
+ $this->getFreezeCellPaneXml().
+ '';
+ }
+
+ protected function getSheetViewAttributes(): string
+ {
+ // Get class properties
+ $propertyValues = get_object_vars($this);
+ unset($propertyValues['freezeRow'], $propertyValues['freezeColumn']);
+
+ return $this->generateAttributes($propertyValues);
+ }
+
+ protected function getFreezeCellPaneXml(): string
+ {
+ if ($this->freezeRow < 2 && 'A' === $this->freezeColumn) {
+ return '';
+ }
+
+ $columnIndex = CellHelper::getColumnIndexFromCellIndex($this->freezeColumn.'1');
+
+ return 'generateAttributes([
+ 'xSplit' => $columnIndex,
+ 'ySplit' => $this->freezeRow - 1,
+ 'topLeftCell' => $this->freezeColumn.$this->freezeRow,
+ 'activePane' => 'bottomRight',
+ 'state' => 'frozen',
+ ]).'/>';
+ }
+
+ /**
+ * @param array $data with key containing the attribute name and value containing the attribute value
+ */
+ protected function generateAttributes(array $data): string
+ {
+ // Create attribute for each key
+ $attributes = array_map(function ($key, $value) {
+ if (\is_bool($value)) {
+ $value = $value ? 'true' : 'false';
+ }
+
+ return $key.'="'.$value.'"';
+ }, array_keys($data), $data);
+
+ // Append all attributes
+ return ' '.implode(' ', $attributes);
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Helper/BorderHelper.php b/upstream-3.x/src/Writer/XLSX/Helper/BorderHelper.php
new file mode 100644
index 0000000..1df43b9
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Helper/BorderHelper.php
@@ -0,0 +1,66 @@
+ [
+ Border::WIDTH_THIN => 'thin',
+ Border::WIDTH_MEDIUM => 'medium',
+ Border::WIDTH_THICK => 'thick',
+ ],
+ Border::STYLE_DOTTED => [
+ Border::WIDTH_THIN => 'dotted',
+ Border::WIDTH_MEDIUM => 'dotted',
+ Border::WIDTH_THICK => 'dotted',
+ ],
+ Border::STYLE_DASHED => [
+ Border::WIDTH_THIN => 'dashed',
+ Border::WIDTH_MEDIUM => 'mediumDashed',
+ Border::WIDTH_THICK => 'mediumDashed',
+ ],
+ Border::STYLE_DOUBLE => [
+ Border::WIDTH_THIN => 'double',
+ Border::WIDTH_MEDIUM => 'double',
+ Border::WIDTH_THICK => 'double',
+ ],
+ Border::STYLE_NONE => [
+ Border::WIDTH_THIN => 'none',
+ Border::WIDTH_MEDIUM => 'none',
+ Border::WIDTH_THICK => 'none',
+ ],
+ ];
+
+ /**
+ * @return string
+ */
+ public static function serializeBorderPart(BorderPart $borderPart)
+ {
+ $borderStyle = self::getBorderStyle($borderPart);
+
+ $colorEl = $borderPart->getColor() ? sprintf('', $borderPart->getColor()) : '';
+ $partEl = sprintf(
+ '<%s style="%s">%s%s>',
+ $borderPart->getName(),
+ $borderStyle,
+ $colorEl,
+ $borderPart->getName()
+ );
+
+ return $partEl.PHP_EOL;
+ }
+
+ /**
+ * Get the style definition from the style map.
+ *
+ * @return string
+ */
+ protected static function getBorderStyle(BorderPart $borderPart)
+ {
+ return self::$xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()];
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Helper/DateHelper.php b/upstream-3.x/src/Writer/XLSX/Helper/DateHelper.php
new file mode 100644
index 0000000..fb72df5
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Helper/DateHelper.php
@@ -0,0 +1,45 @@
+format('Y');
+ $month = (int) $dateTime->format('m');
+ $day = (int) $dateTime->format('d');
+ $hours = (int) $dateTime->format('H');
+ $minutes = (int) $dateTime->format('i');
+ $seconds = (int) $dateTime->format('s');
+ // Fudge factor for the erroneous fact that the year 1900 is treated as a Leap Year in MS Excel
+ // This affects every date following 28th February 1900
+ $excel1900isLeapYear = true;
+ if ((1900 === $year) && ($month <= 2)) {
+ $excel1900isLeapYear = false;
+ }
+ $myexcelBaseDate = 2415020;
+
+ // Julian base date Adjustment
+ if ($month > 2) {
+ $month -= 3;
+ } else {
+ $month += 9;
+ --$year;
+ }
+
+ // Calculate the Julian Date, then subtract the Excel base date (JD 2415020 = 31-Dec-1899 Giving Excel Date of 0)
+ $century = (int) substr((string) $year, 0, 2);
+ $decade = (int) substr((string) $year, 2, 2);
+ $excelDate = floor((146097 * $century) / 4) + floor((1461 * $decade) / 4) + floor((153 * $month + 2) / 5) + $day + 1721119 - $myexcelBaseDate + $excel1900isLeapYear;
+
+ $excelTime = (($hours * 3600) + ($minutes * 60) + $seconds) / 86400;
+
+ return (float) $excelDate + $excelTime;
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Helper/FileSystemHelper.php b/upstream-3.x/src/Writer/XLSX/Helper/FileSystemHelper.php
new file mode 100644
index 0000000..12e4d79
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Helper/FileSystemHelper.php
@@ -0,0 +1,403 @@
+zipHelper = $zipHelper;
+ $this->escaper = $escaper;
+ }
+
+ /**
+ * @return string
+ */
+ public function getRootFolder()
+ {
+ return $this->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.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the base folders
+ */
+ public function createBaseFilesAndFolders()
+ {
+ $this
+ ->createRootFolder()
+ ->createRelsFolderAndFile()
+ ->createDocPropsFolderAndFiles()
+ ->createXlFolderAndSubFolders()
+ ;
+ }
+
+ /**
+ * Creates the "[Content_Types].xml" file under the root folder.
+ *
+ * @param Worksheet[] $worksheets
+ *
+ * @return FileSystemHelper
+ */
+ public function createContentTypesFile($worksheets)
+ {
+ $contentTypesXmlFileContents = <<<'EOD'
+
+
+
+
+
+ EOD;
+
+ /** @var Worksheet $worksheet */
+ foreach ($worksheets as $worksheet) {
+ $contentTypesXmlFileContents .= '';
+ }
+
+ $contentTypesXmlFileContents .= <<<'EOD'
+
+
+
+
+
+ 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'
+
+
+
+ EOD;
+
+ /** @var Worksheet $worksheet */
+ foreach ($worksheets as $worksheet) {
+ $worksheetName = $worksheet->getExternalSheet()->getName();
+ $worksheetVisibility = $worksheet->getExternalSheet()->isVisible() ? 'visible' : 'hidden';
+ $worksheetId = $worksheet->getId();
+ $workbookXmlFileContents .= '';
+ }
+
+ $workbookXmlFileContents .= <<<'EOD'
+
+
+ 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'
+
+
+
+
+ EOD;
+
+ /** @var Worksheet $worksheet */
+ foreach ($worksheets as $worksheet) {
+ $worksheetId = $worksheet->getId();
+ $workbookRelsXmlFileContents .= '';
+ }
+
+ $workbookRelsXmlFileContents .= '';
+
+ $this->createFileWithContents($this->xlRelsFolder, self::WORKBOOK_RELS_XML_FILE_NAME, $workbookRelsXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "styles.xml" file under the "xl" folder.
+ *
+ * @param StyleManager $styleManager
+ *
+ * @return FileSystemHelper
+ */
+ public function createStylesFile($styleManager)
+ {
+ $stylesXmlFileContents = $styleManager->getStylesXMLFileContent();
+ $this->createFileWithContents($this->xlFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Zips the root folder and streams the contents of the zip into the given stream.
+ *
+ * @param resource $streamPointer Pointer to the stream to copy the zip
+ */
+ public function zipRootFolderAndCopyToStream($streamPointer)
+ {
+ $zip = $this->zipHelper->createZip($this->rootFolder);
+
+ $zipFilePath = $this->zipHelper->getZipFilePath($zip);
+
+ // In order to have the file's mime type detected properly, files need to be added
+ // to the zip file in a particular order.
+ // "[Content_Types].xml" then at least 2 files located in "xl" folder should be zipped first.
+ $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::CONTENT_TYPES_XML_FILE_NAME);
+ $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::XL_FOLDER_NAME.'/'.self::WORKBOOK_XML_FILE_NAME);
+ $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::XL_FOLDER_NAME.'/'.self::STYLES_XML_FILE_NAME);
+
+ $this->zipHelper->addFolderToArchive($zip, $this->rootFolder, ZipHelper::EXISTING_FILES_SKIP);
+ $this->zipHelper->closeArchiveAndCopyToStream($zip, $streamPointer);
+
+ // once the zip is copied, remove it
+ $this->deleteFile($zipFilePath);
+ }
+
+ /**
+ * Creates the folder that will be used as root.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
+ *
+ * @return FileSystemHelper
+ */
+ private function createRootFolder()
+ {
+ $this->rootFolder = $this->createFolder($this->baseFolderRealPath, uniqid('xlsx', true));
+
+ return $this;
+ }
+
+ /**
+ * Creates the "_rels" folder under the root folder as well as the ".rels" file in it.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or the ".rels" file
+ *
+ * @return FileSystemHelper
+ */
+ private 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).
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ private function createRelsFile()
+ {
+ $relsFileContents = <<<'EOD'
+
+
+
+
+
+
+ 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.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder or one of the files
+ *
+ * @return FileSystemHelper
+ */
+ private 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.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ private 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.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the file
+ *
+ * @return FileSystemHelper
+ */
+ private function createCoreXmlFile()
+ {
+ $createdDate = (new \DateTime())->format(\DateTime::W3C);
+ $coreXmlFileContents = <<
+
+ {$createdDate}
+ {$createdDate}
+ 0
+
+ EOD;
+
+ $this->createFileWithContents($this->docPropsFolder, self::CORE_XML_FILE_NAME, $coreXmlFileContents);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "xl" folder under the root folder as well as its subfolders.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create at least one of the folders
+ *
+ * @return FileSystemHelper
+ */
+ private 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.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
+ *
+ * @return FileSystemHelper
+ */
+ private function createXlRelsFolder()
+ {
+ $this->xlRelsFolder = $this->createFolder($this->xlFolder, self::RELS_FOLDER_NAME);
+
+ return $this;
+ }
+
+ /**
+ * Creates the "worksheets" folder under the "xl" folder.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If unable to create the folder
+ *
+ * @return FileSystemHelper
+ */
+ private function createXlWorksheetsFolder()
+ {
+ $this->xlWorksheetsFolder = $this->createFolder($this->xlFolder, self::WORKSHEETS_FOLDER_NAME);
+
+ return $this;
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/OptionsManager.php b/upstream-3.x/src/Writer/XLSX/Manager/OptionsManager.php
new file mode 100644
index 0000000..b7e7eae
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/OptionsManager.php
@@ -0,0 +1,64 @@
+styleBuilder = $styleBuilder;
+ parent::__construct();
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function getSupportedOptions()
+ {
+ return [
+ Options::TEMP_FOLDER,
+ Options::DEFAULT_ROW_STYLE,
+ Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY,
+ Options::SHOULD_USE_INLINE_STRINGS,
+ Options::DEFAULT_COLUMN_WIDTH,
+ Options::DEFAULT_ROW_HEIGHT,
+ Options::COLUMN_WIDTHS,
+ Options::MERGE_CELLS,
+ ];
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ protected function setDefaultOptions()
+ {
+ $defaultRowStyle = $this->styleBuilder
+ ->setFontSize(self::DEFAULT_FONT_SIZE)
+ ->setFontName(self::DEFAULT_FONT_NAME)
+ ->build()
+ ;
+
+ $this->setOption(Options::TEMP_FOLDER, sys_get_temp_dir());
+ $this->setOption(Options::DEFAULT_ROW_STYLE, $defaultRowStyle);
+ $this->setOption(Options::SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY, true);
+ $this->setOption(Options::SHOULD_USE_INLINE_STRINGS, true);
+ $this->setOption(Options::MERGE_CELLS, []);
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/SharedStringsManager.php b/upstream-3.x/src/Writer/XLSX/Manager/SharedStringsManager.php
new file mode 100644
index 0000000..9739556
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/SharedStringsManager.php
@@ -0,0 +1,103 @@
+
+ sharedStringsFilePointer = fopen($sharedStringsFilePath, 'w');
+
+ $this->throwIfSharedStringsFilePointerIsNotAvailable();
+
+ // the headers is split into different parts so that we can fseek and put in the correct count and uniqueCount later
+ $header = self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER.' '.self::DEFAULT_STRINGS_COUNT_PART.'>';
+ fwrite($this->sharedStringsFilePointer, $header);
+
+ $this->stringsEscaper = $stringsEscaper;
+ }
+
+ /**
+ * Writes the given string into the sharedStrings.xml file.
+ * Starting and ending whitespaces are preserved.
+ *
+ * @param string $string
+ *
+ * @return int ID of the written shared string
+ */
+ public function writeString($string)
+ {
+ fwrite($this->sharedStringsFilePointer, ''.$this->stringsEscaper->escape($string).'');
+ ++$this->numSharedStrings;
+
+ // Shared string ID is zero-based
+ return $this->numSharedStrings - 1;
+ }
+
+ /**
+ * Finishes writing the data in the sharedStrings.xml file and closes the file.
+ */
+ public function close()
+ {
+ if (!\is_resource($this->sharedStringsFilePointer)) {
+ return;
+ }
+
+ fwrite($this->sharedStringsFilePointer, '');
+
+ // Replace the default strings count with the actual number of shared strings in the file header
+ $firstPartHeaderLength = \strlen(self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER);
+ $defaultStringsCountPartLength = \strlen(self::DEFAULT_STRINGS_COUNT_PART);
+
+ // Adding 1 to take into account the space between the last xml attribute and "count"
+ fseek($this->sharedStringsFilePointer, $firstPartHeaderLength + 1);
+ fwrite($this->sharedStringsFilePointer, sprintf("%-{$defaultStringsCountPartLength}s", 'count="'.$this->numSharedStrings.'" uniqueCount="'.$this->numSharedStrings.'"'));
+
+ fclose($this->sharedStringsFilePointer);
+ }
+
+ /**
+ * Checks if the book has been created. Throws an exception if not created yet.
+ *
+ * @throws \OpenSpout\Common\Exception\IOException If the sheet data file cannot be opened for writing
+ */
+ protected function throwIfSharedStringsFilePointerIsNotAvailable()
+ {
+ if (!\is_resource($this->sharedStringsFilePointer)) {
+ throw new IOException('Unable to open shared strings file for writing.');
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleManager.php b/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleManager.php
new file mode 100644
index 0000000..5f284b9
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleManager.php
@@ -0,0 +1,342 @@
+styleRegistry->getFillIdForStyleId($styleId);
+ $hasStyleCustomFill = (null !== $associatedFillId && 0 !== $associatedFillId);
+
+ $associatedBorderId = $this->styleRegistry->getBorderIdForStyleId($styleId);
+ $hasStyleCustomBorders = (null !== $associatedBorderId && 0 !== $associatedBorderId);
+
+ $associatedFormatId = $this->styleRegistry->getFormatIdForStyleId($styleId);
+ $hasStyleCustomFormats = (null !== $associatedFormatId && 0 !== $associatedFormatId);
+
+ return $hasStyleCustomFill || $hasStyleCustomBorders || $hasStyleCustomFormats;
+ }
+
+ /**
+ * Returns the content of the "styles.xml" file, given a list of styles.
+ *
+ * @return string
+ */
+ public function getStylesXMLFileContent()
+ {
+ $content = <<<'EOD'
+
+
+ EOD;
+
+ $content .= $this->getFormatsSectionContent();
+ $content .= $this->getFontsSectionContent();
+ $content .= $this->getFillsSectionContent();
+ $content .= $this->getBordersSectionContent();
+ $content .= $this->getCellStyleXfsSectionContent();
+ $content .= $this->getCellXfsSectionContent();
+ $content .= $this->getCellStylesSectionContent();
+
+ $content .= <<<'EOD'
+
+ EOD;
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getFormatsSectionContent()
+ {
+ $tags = [];
+ $registeredFormats = $this->styleRegistry->getRegisteredFormats();
+ foreach ($registeredFormats as $styleId) {
+ $numFmtId = $this->styleRegistry->getFormatIdForStyleId($styleId);
+
+ //Built-in formats do not need to be declared, skip them
+ if ($numFmtId < 164) {
+ continue;
+ }
+
+ /** @var Style $style */
+ $style = $this->styleRegistry->getStyleFromStyleId($styleId);
+ $format = $style->getFormat();
+ $tags[] = '';
+ }
+ $content = '';
+ $content .= implode('', $tags);
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getFontsSectionContent()
+ {
+ $registeredStyles = $this->styleRegistry->getRegisteredStyles();
+
+ $content = '';
+
+ /** @var Style $style */
+ foreach ($registeredStyles as $style) {
+ $content .= '';
+
+ $content .= '';
+ $content .= '';
+ $content .= '';
+
+ if ($style->isFontBold()) {
+ $content .= '';
+ }
+ if ($style->isFontItalic()) {
+ $content .= '';
+ }
+ if ($style->isFontUnderline()) {
+ $content .= '';
+ }
+ if ($style->isFontStrikethrough()) {
+ $content .= '';
+ }
+
+ $content .= '';
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getFillsSectionContent()
+ {
+ $registeredFills = $this->styleRegistry->getRegisteredFills();
+
+ // Excel reserves two default fills
+ $fillsCount = \count($registeredFills) + 2;
+ $content = sprintf('', $fillsCount);
+
+ $content .= '';
+ $content .= '';
+
+ // The other fills are actually registered by setting a background color
+ foreach ($registeredFills as $styleId) {
+ /** @var Style $style */
+ $style = $this->styleRegistry->getStyleFromStyleId($styleId);
+
+ $backgroundColor = $style->getBackgroundColor();
+ $content .= sprintf(
+ '',
+ $backgroundColor
+ );
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getBordersSectionContent()
+ {
+ $registeredBorders = $this->styleRegistry->getRegisteredBorders();
+
+ // There is one default border with index 0
+ $borderCount = \count($registeredBorders) + 1;
+
+ $content = '';
+
+ // Default border starting at index 0
+ $content .= '';
+
+ foreach ($registeredBorders as $styleId) {
+ /** @var Style $style */
+ $style = $this->styleRegistry->getStyleFromStyleId($styleId);
+ $border = $style->getBorder();
+ $content .= '';
+
+ /** @see https://github.com/box/spout/issues/271 */
+ $sortOrder = ['left', 'right', 'top', 'bottom'];
+
+ foreach ($sortOrder as $partName) {
+ if ($border->hasPart($partName)) {
+ /** @var BorderPart $part */
+ $part = $border->getPart($partName);
+ $content .= BorderHelper::serializeBorderPart($part);
+ }
+ }
+
+ $content .= '';
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getCellStyleXfsSectionContent()
+ {
+ return <<<'EOD'
+
+
+
+ EOD;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getCellXfsSectionContent()
+ {
+ $registeredStyles = $this->styleRegistry->getRegisteredStyles();
+
+ $content = '';
+
+ foreach ($registeredStyles as $style) {
+ $styleId = $style->getId();
+ $fillId = $this->getFillIdForStyleId($styleId);
+ $borderId = $this->getBorderIdForStyleId($styleId);
+ $numFmtId = $this->getFormatIdForStyleId($styleId);
+
+ $content .= 'shouldApplyFont()) {
+ $content .= ' applyFont="1"';
+ }
+
+ $content .= sprintf(' applyBorder="%d"', $style->shouldApplyBorder() ? 1 : 0);
+
+ if ($style->shouldApplyCellAlignment() || $style->shouldWrapText() || $style->shouldShrinkToFit()) {
+ $content .= ' applyAlignment="1">';
+ $content .= 'shouldApplyCellAlignment()) {
+ $content .= sprintf(' horizontal="%s"', $style->getCellAlignment());
+ }
+ if ($style->shouldWrapText()) {
+ $content .= ' wrapText="1"';
+ }
+ if ($style->shouldShrinkToFit()) {
+ $content .= ' shrinkToFit="true"';
+ }
+
+ $content .= '/>';
+ $content .= '';
+ } else {
+ $content .= '/>';
+ }
+ }
+
+ $content .= '';
+
+ return $content;
+ }
+
+ /**
+ * Returns the content of the "" section.
+ *
+ * @return string
+ */
+ protected function getCellStylesSectionContent()
+ {
+ return <<<'EOD'
+
+
+
+ EOD;
+ }
+
+ /**
+ * Returns the fill ID associated to the given style ID.
+ * For the default style, we don't a fill.
+ *
+ * @param int $styleId
+ *
+ * @return int
+ */
+ private function getFillIdForStyleId($styleId)
+ {
+ // For the default style (ID = 0), we don't want to override the fill.
+ // Otherwise all cells of the spreadsheet will have a background color.
+ $isDefaultStyle = (0 === $styleId);
+
+ return $isDefaultStyle ? 0 : ($this->styleRegistry->getFillIdForStyleId($styleId) ?: 0);
+ }
+
+ /**
+ * Returns the fill ID associated to the given style ID.
+ * For the default style, we don't a border.
+ *
+ * @param int $styleId
+ *
+ * @return int
+ */
+ private function getBorderIdForStyleId($styleId)
+ {
+ // For the default style (ID = 0), we don't want to override the border.
+ // Otherwise all cells of the spreadsheet will have a border.
+ $isDefaultStyle = (0 === $styleId);
+
+ return $isDefaultStyle ? 0 : ($this->styleRegistry->getBorderIdForStyleId($styleId) ?: 0);
+ }
+
+ /**
+ * Returns the format ID associated to the given style ID.
+ * For the default style use general format.
+ *
+ * @param int $styleId
+ *
+ * @return int
+ */
+ private function getFormatIdForStyleId($styleId)
+ {
+ // For the default style (ID = 0), we don't want to override the format.
+ // Otherwise all cells of the spreadsheet will have a format.
+ $isDefaultStyle = (0 === $styleId);
+
+ return $isDefaultStyle ? 0 : ($this->styleRegistry->getFormatIdForStyleId($styleId) ?: 0);
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php b/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php
new file mode 100644
index 0000000..259d4e8
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php
@@ -0,0 +1,276 @@
+ 0,
+ '0' => 1,
+ '0.00' => 2,
+ '#,##0' => 3,
+ '#,##0.00' => 4,
+ '$#,##0,\-$#,##0' => 5,
+ '$#,##0,[Red]\-$#,##0' => 6,
+ '$#,##0.00,\-$#,##0.00' => 7,
+ '$#,##0.00,[Red]\-$#,##0.00' => 8,
+ '0%' => 9,
+ '0.00%' => 10,
+ '0.00E+00' => 11,
+ '# ?/?' => 12,
+ '# ??/??' => 13,
+ 'mm-dd-yy' => 14,
+ 'd-mmm-yy' => 15,
+ 'd-mmm' => 16,
+ 'mmm-yy' => 17,
+ 'h:mm AM/PM' => 18,
+ 'h:mm:ss AM/PM' => 19,
+ 'h:mm' => 20,
+ 'h:mm:ss' => 21,
+ 'm/d/yy h:mm' => 22,
+
+ '#,##0 ,(#,##0)' => 37,
+ '#,##0 ,[Red](#,##0)' => 38,
+ '#,##0.00,(#,##0.00)' => 39,
+ '#,##0.00,[Red](#,##0.00)' => 40,
+
+ '_("$"* #,##0.00_),_("$"* \(#,##0.00\),_("$"* "-"??_),_(@_)' => 44,
+ 'mm:ss' => 45,
+ '[h]:mm:ss' => 46,
+ 'mm:ss.0' => 47,
+
+ '##0.0E+0' => 48,
+ '@' => 49,
+
+ '[$-404]e/m/d' => 27,
+ 'm/d/yy' => 30,
+ 't0' => 59,
+ 't0.00' => 60,
+ 't#,##0' => 61,
+ 't#,##0.00' => 62,
+ 't0%' => 67,
+ 't0.00%' => 68,
+ 't# ?/?' => 69,
+ 't# ??/??' => 70,
+ ];
+
+ /**
+ * @var array
+ */
+ protected $registeredFormats = [];
+
+ /**
+ * @var array [STYLE_ID] => [FORMAT_ID] maps a style to a format declaration
+ */
+ protected $styleIdToFormatsMappingTable = [];
+
+ /**
+ * If the numFmtId is lower than 0xA4 (164 in decimal)
+ * then it's a built-in number format.
+ * Since Excel is the dominant vendor - we play along here.
+ *
+ * @var int the fill index counter for custom fills
+ */
+ protected $formatIndex = 164;
+
+ /**
+ * @var array
+ */
+ protected $registeredFills = [];
+
+ /**
+ * @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration
+ */
+ protected $styleIdToFillMappingTable = [];
+
+ /**
+ * Excel preserves two default fills with index 0 and 1
+ * Since Excel is the dominant vendor - we play along here.
+ *
+ * @var int the fill index counter for custom fills
+ */
+ protected $fillIndex = 2;
+
+ /**
+ * @var array
+ */
+ protected $registeredBorders = [];
+
+ /**
+ * @var array [STYLE_ID] => [BORDER_ID] maps a style to a border declaration
+ */
+ protected $styleIdToBorderMappingTable = [];
+
+ /**
+ * XLSX specific operations on the registered styles.
+ *
+ * @return Style
+ */
+ public function registerStyle(Style $style)
+ {
+ if ($style->isRegistered()) {
+ return $style;
+ }
+
+ $registeredStyle = parent::registerStyle($style);
+ $this->registerFill($registeredStyle);
+ $this->registerFormat($registeredStyle);
+ $this->registerBorder($registeredStyle);
+
+ return $registeredStyle;
+ }
+
+ /**
+ * @param int $styleId
+ *
+ * @return null|int Format ID associated to the given style ID
+ */
+ public function getFormatIdForStyleId($styleId)
+ {
+ return $this->styleIdToFormatsMappingTable[$styleId] ?? null;
+ }
+
+ /**
+ * @param int $styleId
+ *
+ * @return null|int Fill ID associated to the given style ID
+ */
+ public function getFillIdForStyleId($styleId)
+ {
+ return (isset($this->styleIdToFillMappingTable[$styleId])) ?
+ $this->styleIdToFillMappingTable[$styleId] :
+ null;
+ }
+
+ /**
+ * @param int $styleId
+ *
+ * @return null|int Fill ID associated to the given style ID
+ */
+ public function getBorderIdForStyleId($styleId)
+ {
+ return (isset($this->styleIdToBorderMappingTable[$styleId])) ?
+ $this->styleIdToBorderMappingTable[$styleId] :
+ null;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRegisteredFills()
+ {
+ return $this->registeredFills;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRegisteredBorders()
+ {
+ return $this->registeredBorders;
+ }
+
+ /**
+ * @return array
+ */
+ public function getRegisteredFormats()
+ {
+ return $this->registeredFormats;
+ }
+
+ /**
+ * Register a format definition.
+ */
+ protected function registerFormat(Style $style)
+ {
+ $styleId = $style->getId();
+
+ $format = $style->getFormat();
+ if ($format) {
+ $isFormatRegistered = isset($this->registeredFormats[$format]);
+
+ // We need to track the already registered format definitions
+ if ($isFormatRegistered) {
+ $registeredStyleId = $this->registeredFormats[$format];
+ $registeredFormatId = $this->styleIdToFormatsMappingTable[$registeredStyleId];
+ $this->styleIdToFormatsMappingTable[$styleId] = $registeredFormatId;
+ } else {
+ $this->registeredFormats[$format] = $styleId;
+
+ $id = self::$builtinNumFormatToIdMapping[$format] ?? $this->formatIndex++;
+ $this->styleIdToFormatsMappingTable[$styleId] = $id;
+ }
+ } else {
+ // The formatId maps a style to a format declaration
+ // When there is no format definition - we default to 0 ( General )
+ $this->styleIdToFormatsMappingTable[$styleId] = 0;
+ }
+ }
+
+ /**
+ * Register a fill definition.
+ */
+ private function registerFill(Style $style)
+ {
+ $styleId = $style->getId();
+
+ // Currently - only solid backgrounds are supported
+ // so $backgroundColor is a scalar value (RGB Color)
+ $backgroundColor = $style->getBackgroundColor();
+
+ if ($backgroundColor) {
+ $isBackgroundColorRegistered = isset($this->registeredFills[$backgroundColor]);
+
+ // We need to track the already registered background definitions
+ if ($isBackgroundColorRegistered) {
+ $registeredStyleId = $this->registeredFills[$backgroundColor];
+ $registeredFillId = $this->styleIdToFillMappingTable[$registeredStyleId];
+ $this->styleIdToFillMappingTable[$styleId] = $registeredFillId;
+ } else {
+ $this->registeredFills[$backgroundColor] = $styleId;
+ $this->styleIdToFillMappingTable[$styleId] = $this->fillIndex++;
+ }
+ } else {
+ // The fillId maps a style to a fill declaration
+ // When there is no background color definition - we default to 0
+ $this->styleIdToFillMappingTable[$styleId] = 0;
+ }
+ }
+
+ /**
+ * Register a border definition.
+ */
+ private function registerBorder(Style $style)
+ {
+ $styleId = $style->getId();
+
+ if ($style->shouldApplyBorder()) {
+ $border = $style->getBorder();
+ $serializedBorder = serialize($border);
+
+ $isBorderAlreadyRegistered = isset($this->registeredBorders[$serializedBorder]);
+
+ if ($isBorderAlreadyRegistered) {
+ $registeredStyleId = $this->registeredBorders[$serializedBorder];
+ $registeredBorderId = $this->styleIdToBorderMappingTable[$registeredStyleId];
+ $this->styleIdToBorderMappingTable[$styleId] = $registeredBorderId;
+ } else {
+ $this->registeredBorders[$serializedBorder] = $styleId;
+ $this->styleIdToBorderMappingTable[$styleId] = \count($this->registeredBorders);
+ }
+ } else {
+ // If no border should be applied - the mapping is the default border: 0
+ $this->styleIdToBorderMappingTable[$styleId] = 0;
+ }
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/WorkbookManager.php b/upstream-3.x/src/Writer/XLSX/Manager/WorkbookManager.php
new file mode 100644
index 0000000..2feb331
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/WorkbookManager.php
@@ -0,0 +1,74 @@
+fileSystemHelper->getXlWorksheetsFolder();
+
+ return $worksheetFilesFolder.'/'.strtolower($sheet->getName()).'.xml';
+ }
+
+ /**
+ * @return int Maximum number of rows/columns a sheet can contain
+ */
+ protected function getMaxRowsPerWorksheet()
+ {
+ return self::$maxRowsPerWorksheet;
+ }
+
+ /**
+ * Closes custom objects that are still opened.
+ */
+ protected function closeRemainingObjects()
+ {
+ $this->worksheetManager->getSharedStringsManager()->close();
+ }
+
+ /**
+ * Writes all the necessary files to disk and zip them together to create the final file.
+ *
+ * @param resource $finalFilePointer Pointer to the spreadsheet that will be created
+ */
+ protected function writeAllFilesToDiskAndZipThem($finalFilePointer)
+ {
+ $worksheets = $this->getWorksheets();
+
+ $this->fileSystemHelper
+ ->createContentTypesFile($worksheets)
+ ->createWorkbookFile($worksheets)
+ ->createWorkbookRelsFile($worksheets)
+ ->createStylesFile($this->styleManager)
+ ->zipRootFolderAndCopyToStream($finalFilePointer)
+ ;
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Manager/WorksheetManager.php b/upstream-3.x/src/Writer/XLSX/Manager/WorksheetManager.php
new file mode 100644
index 0000000..c84be5a
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Manager/WorksheetManager.php
@@ -0,0 +1,376 @@
+
+
+ EOD;
+
+ /** @var bool Whether inline or shared strings should be used */
+ protected $shouldUseInlineStrings;
+
+ /** @var OptionsManagerInterface */
+ private $optionsManager;
+
+ /** @var RowManager Manages rows */
+ private $rowManager;
+
+ /** @var StyleManager Manages styles */
+ private $styleManager;
+
+ /** @var StyleMerger Helper to merge styles together */
+ private $styleMerger;
+
+ /** @var SharedStringsManager Helper to write shared strings */
+ private $sharedStringsManager;
+
+ /** @var XLSXEscaper Strings escaper */
+ private $stringsEscaper;
+
+ /** @var StringHelper String helper */
+ private $stringHelper;
+
+ /**
+ * WorksheetManager constructor.
+ */
+ public function __construct(
+ OptionsManagerInterface $optionsManager,
+ RowManager $rowManager,
+ StyleManager $styleManager,
+ StyleMerger $styleMerger,
+ SharedStringsManager $sharedStringsManager,
+ XLSXEscaper $stringsEscaper,
+ StringHelper $stringHelper
+ ) {
+ $this->optionsManager = $optionsManager;
+ $this->shouldUseInlineStrings = $optionsManager->getOption(Options::SHOULD_USE_INLINE_STRINGS);
+ $this->setDefaultColumnWidth($optionsManager->getOption(Options::DEFAULT_COLUMN_WIDTH));
+ $this->setDefaultRowHeight($optionsManager->getOption(Options::DEFAULT_ROW_HEIGHT));
+ $this->columnWidths = $optionsManager->getOption(Options::COLUMN_WIDTHS) ?? [];
+ $this->rowManager = $rowManager;
+ $this->styleManager = $styleManager;
+ $this->styleMerger = $styleMerger;
+ $this->sharedStringsManager = $sharedStringsManager;
+ $this->stringsEscaper = $stringsEscaper;
+ $this->stringHelper = $stringHelper;
+ }
+
+ /**
+ * @return SharedStringsManager
+ */
+ public function getSharedStringsManager()
+ {
+ return $this->sharedStringsManager;
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function startSheet(Worksheet $worksheet)
+ {
+ $sheetFilePointer = fopen($worksheet->getFilePath(), 'w');
+ $this->throwIfSheetFilePointerIsNotAvailable($sheetFilePointer);
+
+ $worksheet->setFilePointer($sheetFilePointer);
+
+ fwrite($sheetFilePointer, self::SHEET_XML_FILE_HEADER);
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function addRow(Worksheet $worksheet, Row $row)
+ {
+ if (!$this->rowManager->isEmpty($row)) {
+ $this->addNonEmptyRow($worksheet, $row);
+ }
+
+ $worksheet->setLastWrittenRowIndex($worksheet->getLastWrittenRowIndex() + 1);
+ }
+
+ /**
+ * Construct column width references xml to inject into worksheet xml file.
+ *
+ * @return string
+ */
+ public function getXMLFragmentForColumnWidths()
+ {
+ if (empty($this->columnWidths)) {
+ return '';
+ }
+ $xml = '';
+ foreach ($this->columnWidths as $entry) {
+ $xml .= '';
+ }
+ $xml .= '';
+
+ return $xml;
+ }
+
+ /**
+ * Constructs default row height and width xml to inject into worksheet xml file.
+ *
+ * @return string
+ */
+ public function getXMLFragmentForDefaultCellSizing()
+ {
+ $rowHeightXml = empty($this->defaultRowHeight) ? '' : " defaultRowHeight=\"{$this->defaultRowHeight}\"";
+ $colWidthXml = empty($this->defaultColumnWidth) ? '' : " defaultColWidth=\"{$this->defaultColumnWidth}\"";
+ if (empty($colWidthXml) && empty($rowHeightXml)) {
+ return '';
+ }
+ // Ensure that the required defaultRowHeight is set
+ $rowHeightXml = empty($rowHeightXml) ? ' defaultRowHeight="0"' : $rowHeightXml;
+
+ return "";
+ }
+
+ /**
+ * {@inheritdoc}
+ */
+ public function close(Worksheet $worksheet)
+ {
+ $worksheetFilePointer = $worksheet->getFilePointer();
+
+ if (!\is_resource($worksheetFilePointer)) {
+ return;
+ }
+ $this->ensureSheetDataStated($worksheet);
+ fwrite($worksheetFilePointer, '');
+
+ // create nodes for merge cells
+ if ($this->optionsManager->getOption(Options::MERGE_CELLS)) {
+ $mergeCellString = '';
+ foreach ($this->optionsManager->getOption(Options::MERGE_CELLS) as $values) {
+ $output = array_map(function ($value) {
+ return CellHelper::getColumnLettersFromColumnIndex($value[0]).$value[1];
+ }, $values);
+ $mergeCellString .= '';
+ }
+ $mergeCellString .= '';
+ fwrite($worksheet->getFilePointer(), $mergeCellString);
+ }
+
+ fwrite($worksheetFilePointer, '');
+ fclose($worksheetFilePointer);
+ }
+
+ /**
+ * Writes the sheet data header.
+ *
+ * @param Worksheet $worksheet The worksheet to add the row to
+ */
+ private function ensureSheetDataStated(Worksheet $worksheet)
+ {
+ if (!$worksheet->getSheetDataStarted()) {
+ $worksheetFilePointer = $worksheet->getFilePointer();
+ $sheet = $worksheet->getExternalSheet();
+ if ($sheet->hasSheetView()) {
+ fwrite($worksheetFilePointer, ''.$sheet->getSheetView()->getXml().'');
+ }
+ fwrite($worksheetFilePointer, $this->getXMLFragmentForDefaultCellSizing());
+ fwrite($worksheetFilePointer, $this->getXMLFragmentForColumnWidths());
+ fwrite($worksheetFilePointer, '');
+ $worksheet->setSheetDataStarted(true);
+ }
+ }
+
+ /**
+ * Checks if the sheet has been sucessfully created. Throws an exception if not.
+ *
+ * @param bool|resource $sheetFilePointer Pointer to the sheet data file or FALSE if unable to open the file
+ *
+ * @throws IOException If the sheet data file cannot be opened for writing
+ */
+ private function throwIfSheetFilePointerIsNotAvailable($sheetFilePointer)
+ {
+ if (!$sheetFilePointer) {
+ throw new IOException('Unable to open sheet for writing.');
+ }
+ }
+
+ /**
+ * Adds non empty row to the worksheet.
+ *
+ * @param Worksheet $worksheet The worksheet to add the row to
+ * @param Row $row The row to be written
+ *
+ * @throws InvalidArgumentException If a cell value's type is not supported
+ * @throws IOException If the data cannot be written
+ */
+ private function addNonEmptyRow(Worksheet $worksheet, Row $row)
+ {
+ $this->ensureSheetDataStated($worksheet);
+ $sheetFilePointer = $worksheet->getFilePointer();
+ $rowStyle = $row->getStyle();
+ $rowIndexOneBased = $worksheet->getLastWrittenRowIndex() + 1;
+ $numCells = $row->getNumCells();
+
+ $hasCustomHeight = $this->defaultRowHeight > 0 ? '1' : '0';
+ $rowXML = "";
+
+ foreach ($row->getCells() as $columnIndexZeroBased => $cell) {
+ $registeredStyle = $this->applyStyleAndRegister($cell, $rowStyle);
+ $cellStyle = $registeredStyle->getStyle();
+ if ($registeredStyle->isMatchingRowStyle()) {
+ $rowStyle = $cellStyle; // Replace actual rowStyle (possibly with null id) by registered style (with id)
+ }
+ $rowXML .= $this->getCellXML($rowIndexOneBased, $columnIndexZeroBased, $cell, $cellStyle->getId());
+ }
+
+ $rowXML .= ' ';
+
+ $wasWriteSuccessful = fwrite($sheetFilePointer, $rowXML);
+ if (false === $wasWriteSuccessful) {
+ throw new IOException("Unable to write data in {$worksheet->getFilePath()}");
+ }
+ }
+
+ /**
+ * Applies styles to the given style, merging the cell's style with its row's style.
+ *
+ * @throws InvalidArgumentException If the given value cannot be processed
+ */
+ private function applyStyleAndRegister(Cell $cell, Style $rowStyle): RegisteredStyle
+ {
+ $isMatchingRowStyle = false;
+ if ($cell->getStyle()->isEmpty()) {
+ $cell->setStyle($rowStyle);
+
+ $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
+
+ if ($possiblyUpdatedStyle->isUpdated()) {
+ $registeredStyle = $this->styleManager->registerStyle($possiblyUpdatedStyle->getStyle());
+ } else {
+ $registeredStyle = $this->styleManager->registerStyle($rowStyle);
+ $isMatchingRowStyle = true;
+ }
+ } else {
+ $mergedCellAndRowStyle = $this->styleMerger->merge($cell->getStyle(), $rowStyle);
+ $cell->setStyle($mergedCellAndRowStyle);
+
+ $possiblyUpdatedStyle = $this->styleManager->applyExtraStylesIfNeeded($cell);
+
+ if ($possiblyUpdatedStyle->isUpdated()) {
+ $newCellStyle = $possiblyUpdatedStyle->getStyle();
+ } else {
+ $newCellStyle = $mergedCellAndRowStyle;
+ }
+
+ $registeredStyle = $this->styleManager->registerStyle($newCellStyle);
+ }
+
+ return new RegisteredStyle($registeredStyle, $isMatchingRowStyle);
+ }
+
+ /**
+ * Builds and returns xml for a single cell.
+ *
+ * @param int $rowIndexOneBased
+ * @param int $columnIndexZeroBased
+ * @param int $styleId
+ *
+ * @throws InvalidArgumentException If the given value cannot be processed
+ *
+ * @return string
+ */
+ private function getCellXML($rowIndexOneBased, $columnIndexZeroBased, Cell $cell, $styleId)
+ {
+ $columnLetters = CellHelper::getColumnLettersFromColumnIndex($columnIndexZeroBased);
+ $cellXML = 'isString()) {
+ $cellXML .= $this->getCellXMLFragmentForNonEmptyString($cell->getValue());
+ } elseif ($cell->isBoolean()) {
+ $cellXML .= ' t="b">'.(int) ($cell->getValue()).'';
+ } elseif ($cell->isNumeric()) {
+ $cellXML .= '>'.$this->stringHelper->formatNumericValue($cell->getValue()).'';
+ } elseif ($cell->isFormula()) {
+ $cellXML .= '>'.substr($cell->getValue(), 1).'';
+ } elseif ($cell->isDate()) {
+ $value = $cell->getValue();
+ if ($value instanceof \DateTimeInterface) {
+ $cellXML .= '>'.(string) DateHelper::toExcel($value).'';
+ } else {
+ throw new InvalidArgumentException('Trying to add a date value with an unsupported type: '.\gettype($value));
+ }
+ } elseif ($cell->isError() && \is_string($cell->getValueEvenIfError())) {
+ // only writes the error value if it's a string
+ $cellXML .= ' t="e">'.$cell->getValueEvenIfError().'';
+ } elseif ($cell->isEmpty()) {
+ if ($this->styleManager->shouldApplyStyleOnEmptyCell($styleId)) {
+ $cellXML .= '/>';
+ } else {
+ // don't write empty cells that do no need styling
+ // NOTE: not appending to $cellXML is the right behavior!!
+ $cellXML = '';
+ }
+ } else {
+ throw new InvalidArgumentException('Trying to add a value with an unsupported type: '.\gettype($cell->getValue()));
+ }
+
+ return $cellXML;
+ }
+
+ /**
+ * Returns the XML fragment for a cell containing a non empty string.
+ *
+ * @param string $cellValue The cell value
+ *
+ * @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell
+ *
+ * @return string The XML fragment representing the cell
+ */
+ private function getCellXMLFragmentForNonEmptyString($cellValue)
+ {
+ if ($this->stringHelper->getStringLength($cellValue) > self::MAX_CHARACTERS_PER_CELL) {
+ throw new InvalidArgumentException('Trying to add a value that exceeds the maximum number of characters allowed in a cell (32,767)');
+ }
+
+ if ($this->shouldUseInlineStrings) {
+ $cellXMLFragment = ' t="inlineStr">'.$this->stringsEscaper->escape($cellValue).'';
+ } else {
+ $sharedStringId = $this->sharedStringsManager->writeString($cellValue);
+ $cellXMLFragment = ' t="s">'.$sharedStringId.'';
+ }
+
+ return $cellXMLFragment;
+ }
+}
diff --git a/upstream-3.x/src/Writer/XLSX/Writer.php b/upstream-3.x/src/Writer/XLSX/Writer.php
new file mode 100644
index 0000000..fa3a054
--- /dev/null
+++ b/upstream-3.x/src/Writer/XLSX/Writer.php
@@ -0,0 +1,72 @@
+throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+
+ $this->optionsManager->setOption(Options::TEMP_FOLDER, $tempFolder);
+
+ return $this;
+ }
+
+ /**
+ * Use inline string to be more memory efficient. If set to false, it will use shared strings.
+ * This must be set before opening the writer.
+ *
+ * @param bool $shouldUseInlineStrings Whether inline or shared strings should be used
+ *
+ * @throws \OpenSpout\Writer\Exception\WriterAlreadyOpenedException If the writer was already opened
+ *
+ * @return Writer
+ */
+ public function setShouldUseInlineStrings($shouldUseInlineStrings)
+ {
+ $this->throwIfWriterAlreadyOpened('Writer must be configured before opening it.');
+
+ $this->optionsManager->setOption(Options::SHOULD_USE_INLINE_STRINGS, $shouldUseInlineStrings);
+
+ return $this;
+ }
+
+ /**
+ * Merge cells.
+ * Row coordinates are indexed from 1, columns from 0 (A = 0),
+ * so a merge B2:G2 looks like $writer->mergeCells([1,2], [6, 2]);.
+ *
+ * You may use CellHelper::getColumnLettersFromColumnIndex() to convert from "B2" to "[1,2]"
+ *
+ * @param int[] $range1 - top left cell's coordinate [column, row]
+ * @param int[] $range2 - bottom right cell's coordinate [column, row]
+ *
+ * @return $this
+ */
+ public function mergeCells(array $range1, array $range2)
+ {
+ $this->optionsManager->addOption(Options::MERGE_CELLS, [$range1, $range2]);
+
+ return $this;
+ }
+}
|