commit a2c3280bb801de79ac21624f24ba2ab026fbbeaf Author: Jephte Clain Date: Wed Nov 27 13:38:17 2024 +0400 importation upstream 3.4.7 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 + +[![Latest Stable Version](https://poser.pugx.org/openspout/openspout/v/stable)](https://packagist.org/packages/openspout/openspout) +[![Build Status](https://github.com/openspout/openspout/actions/workflows/ci.yml/badge.svg)](https://github.com/openspout/openspout/actions/workflows/ci.yml) +[![Code Coverage](https://codecov.io/gh/openspout/openspout/coverage.svg?branch=main)](https://codecov.io/gh/openspout/openspout?branch=main) +[![Total Downloads](https://poser.pugx.org/openspout/openspout/downloads)](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: [![Build Status](https://travis-ci.org/box/spout.svg?branch=perf-tests)](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', + $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; + } +}