From 3c8601a38738987a18ca3d6c38d3c7a096466a36 Mon Sep 17 00:00:00 2001 From: Jephte Clain Date: Wed, 27 Nov 2024 13:43:12 +0400 Subject: [PATCH] importation upstream 4.27.0 --- upstream-4.x/LICENSE | 21 + upstream-4.x/LICENSE-for-cc42c1d | 166 ++++ upstream-4.x/README.md | 38 + upstream-4.x/UPGRADE.md | 162 ++++ upstream-4.x/composer.json | 76 ++ upstream-4.x/renovate.json | 6 + upstream-4.x/src/Common/Entity/Cell.php | 65 ++ .../src/Common/Entity/Cell/BooleanCell.php | 24 + .../Common/Entity/Cell/DateIntervalCell.php | 31 + .../src/Common/Entity/Cell/DateTimeCell.php | 25 + .../src/Common/Entity/Cell/EmptyCell.php | 24 + .../src/Common/Entity/Cell/ErrorCell.php | 29 + .../src/Common/Entity/Cell/FormulaCell.php | 31 + .../src/Common/Entity/Cell/NumericCell.php | 24 + .../src/Common/Entity/Cell/StringCell.php | 24 + .../src/Common/Entity/Comment/Comment.php | 47 ++ .../src/Common/Entity/Comment/TextRun.php | 23 + upstream-4.x/src/Common/Entity/Row.php | 169 ++++ .../src/Common/Entity/Style/Border.php | 46 ++ .../src/Common/Entity/Style/BorderPart.php | 90 +++ .../src/Common/Entity/Style/CellAlignment.php | 31 + .../Entity/Style/CellVerticalAlignment.php | 37 + .../src/Common/Entity/Style/Color.php | 88 ++ .../src/Common/Entity/Style/Style.php | 495 ++++++++++++ .../Exception/EncodingConversionException.php | 7 + .../src/Common/Exception/IOException.php | 7 + .../Exception/InvalidArgumentException.php | 7 + .../Exception/InvalidColorException.php | 7 + .../Common/Exception/OpenSpoutException.php | 9 + .../Exception/UnsupportedTypeException.php | 7 + .../src/Common/Helper/EncodingHelper.php | 195 +++++ .../Helper/Escaper/EscaperInterface.php | 29 + .../src/Common/Helper/Escaper/ODS.php | 47 ++ .../src/Common/Helper/Escaper/XLSX.php | 193 +++++ .../src/Common/Helper/FileSystemHelper.php | 164 ++++ .../Helper/FileSystemHelperInterface.php | 57 ++ .../src/Common/Helper/StringHelper.php | 80 ++ .../src/Common/TempFolderOptionTrait.php | 33 + upstream-4.x/src/Reader/AbstractReader.php | 171 ++++ upstream-4.x/src/Reader/CSV/Options.php | 15 + upstream-4.x/src/Reader/CSV/Reader.php | 80 ++ upstream-4.x/src/Reader/CSV/RowIterator.php | 219 +++++ upstream-4.x/src/Reader/CSV/Sheet.php | 53 ++ upstream-4.x/src/Reader/CSV/SheetIterator.php | 77 ++ .../src/Reader/Common/ColumnWidth.php | 21 + .../Reader/Common/Creator/ReaderFactory.php | 64 ++ .../src/Reader/Common/Manager/RowManager.php | 51 ++ .../src/Reader/Common/XMLProcessor.php | 153 ++++ .../Exception/InvalidValueException.php | 23 + .../IteratorNotRewindableException.php | 7 + .../Exception/NoSheetsFoundException.php | 7 + .../src/Reader/Exception/ReaderException.php | 9 + .../Exception/ReaderNotOpenedException.php | 7 + .../SharedStringNotFoundException.php | 7 + .../Exception/XMLProcessingException.php | 7 + .../Reader/ODS/Helper/CellValueFormatter.php | 283 +++++++ .../src/Reader/ODS/Helper/SettingsHelper.php | 54 ++ upstream-4.x/src/Reader/ODS/Options.php | 11 + upstream-4.x/src/Reader/ODS/Reader.php | 72 ++ upstream-4.x/src/Reader/ODS/RowIterator.php | 343 ++++++++ upstream-4.x/src/Reader/ODS/Sheet.php | 81 ++ upstream-4.x/src/Reader/ODS/SheetIterator.php | 228 ++++++ upstream-4.x/src/Reader/ReaderInterface.php | 37 + .../src/Reader/RowIteratorInterface.php | 16 + upstream-4.x/src/Reader/SheetInterface.php | 31 + .../src/Reader/SheetIteratorInterface.php | 20 + .../Reader/SheetWithMergeCellsInterface.php | 18 + .../Reader/SheetWithVisibilityInterface.php | 18 + .../Wrapper/XMLInternalErrorsHelper.php | 77 ++ upstream-4.x/src/Reader/Wrapper/XMLReader.php | 187 +++++ .../src/Reader/XLSX/Helper/CellHelper.php | 85 ++ .../Reader/XLSX/Helper/CellValueFormatter.php | 344 ++++++++ .../Reader/XLSX/Helper/DateFormatHelper.php | 125 +++ .../XLSX/Helper/DateIntervalFormatHelper.php | 100 +++ .../CachingStrategyFactory.php | 103 +++ .../CachingStrategyFactoryInterface.php | 19 + .../CachingStrategyInterface.php | 43 + .../FileBasedStrategy.php | 184 +++++ .../SharedStringsCaching/InMemoryStrategy.php | 81 ++ .../SharedStringsCaching/MemoryLimit.php | 50 ++ .../XLSX/Manager/SharedStringsManager.php | 241 ++++++ .../src/Reader/XLSX/Manager/SheetManager.php | 295 +++++++ .../src/Reader/XLSX/Manager/StyleManager.php | 325 ++++++++ .../XLSX/Manager/StyleManagerInterface.php | 31 + .../Manager/WorkbookRelationshipsManager.php | 151 ++++ upstream-4.x/src/Reader/XLSX/Options.php | 17 + upstream-4.x/src/Reader/XLSX/Reader.php | 111 +++ upstream-4.x/src/Reader/XLSX/RowIterator.php | 398 +++++++++ upstream-4.x/src/Reader/XLSX/Sheet.php | 116 +++ .../src/Reader/XLSX/SheetHeaderReader.php | 119 +++ .../src/Reader/XLSX/SheetIterator.php | 86 ++ .../src/Reader/XLSX/SheetMergeCellsReader.php | 69 ++ upstream-4.x/src/Writer/AbstractWriter.php | 169 ++++ .../src/Writer/AbstractWriterMultiSheets.php | 121 +++ upstream-4.x/src/Writer/AutoFilter.php | 21 + upstream-4.x/src/Writer/CSV/Options.php | 15 + upstream-4.x/src/Writer/CSV/Writer.php | 91 +++ .../src/Writer/Common/AbstractOptions.php | 67 ++ .../src/Writer/Common/ColumnWidth.php | 21 + .../Writer/Common/Creator/WriterFactory.php | 39 + .../src/Writer/Common/Entity/Sheet.php | 240 ++++++ .../src/Writer/Common/Entity/Workbook.php | 46 ++ .../src/Writer/Common/Entity/Worksheet.php | 92 +++ .../src/Writer/Common/Helper/CellHelper.php | 47 ++ ...ileSystemWithRootFolderHelperInterface.php | 25 + .../src/Writer/Common/Helper/ZipHelper.php | 200 +++++ .../Manager/AbstractWorkbookManager.php | 290 +++++++ .../Writer/Common/Manager/RegisteredStyle.php | 35 + .../Writer/Common/Manager/SheetManager.php | 134 ++++ .../Manager/Style/AbstractStyleManager.php | 84 ++ .../Manager/Style/AbstractStyleRegistry.php | 96 +++ .../Manager/Style/PossiblyUpdatedStyle.php | 32 + .../Manager/Style/StyleManagerInterface.php | 32 + .../Common/Manager/Style/StyleMerger.php | 100 +++ .../Manager/WorkbookManagerInterface.php | 71 ++ .../Manager/WorksheetManagerInterface.php | 41 + .../Exception/Border/InvalidNameException.php | 18 + .../Border/InvalidStyleException.php | 18 + .../Border/InvalidWidthException.php | 18 + .../Exception/InvalidSheetNameException.php | 7 + .../Exception/SheetNotFoundException.php | 7 + .../WriterAlreadyOpenedException.php | 7 + .../src/Writer/Exception/WriterException.php | 9 + .../Exception/WriterNotOpenedException.php | 7 + .../src/Writer/ODS/Helper/BorderHelper.php | 62 ++ .../Writer/ODS/Helper/FileSystemHelper.php | 328 ++++++++ .../Writer/ODS/Manager/Style/StyleManager.php | 436 ++++++++++ .../ODS/Manager/Style/StyleRegistry.php | 45 ++ .../Writer/ODS/Manager/WorkbookManager.php | 73 ++ .../Writer/ODS/Manager/WorksheetManager.php | 273 +++++++ upstream-4.x/src/Writer/ODS/Options.php | 9 + upstream-4.x/src/Writer/ODS/Writer.php | 54 ++ upstream-4.x/src/Writer/WriterInterface.php | 71 ++ .../src/Writer/XLSX/Entity/SheetView.php | 270 +++++++ .../src/Writer/XLSX/Helper/BorderHelper.php | 70 ++ .../src/Writer/XLSX/Helper/DateHelper.php | 57 ++ .../Writer/XLSX/Helper/DateIntervalHelper.php | 41 + .../Writer/XLSX/Helper/FileSystemHelper.php | 755 ++++++++++++++++++ .../Writer/XLSX/Helper/PasswordHashHelper.php | 30 + .../Writer/XLSX/Manager/CommentsManager.php | 225 ++++++ .../XLSX/Manager/SharedStringsManager.php | 86 ++ .../XLSX/Manager/Style/StyleManager.php | 327 ++++++++ .../XLSX/Manager/Style/StyleRegistry.php | 254 ++++++ .../Writer/XLSX/Manager/WorkbookManager.php | 85 ++ .../Writer/XLSX/Manager/WorksheetManager.php | 246 ++++++ upstream-4.x/src/Writer/XLSX/MergeCell.php | 26 + upstream-4.x/src/Writer/XLSX/Options.php | 118 +++ .../src/Writer/XLSX/Options/HeaderFooter.php | 16 + .../src/Writer/XLSX/Options/PageMargin.php | 17 + .../Writer/XLSX/Options/PageOrientation.php | 11 + .../src/Writer/XLSX/Options/PageSetup.php | 19 + .../src/Writer/XLSX/Options/PaperSize.php | 34 + .../Writer/XLSX/Options/SheetProtection.php | 76 ++ .../XLSX/Options/WorkbookProtection.php | 50 ++ upstream-4.x/src/Writer/XLSX/Writer.php | 82 ++ 155 files changed, 14600 insertions(+) create mode 100644 upstream-4.x/LICENSE create mode 100644 upstream-4.x/LICENSE-for-cc42c1d create mode 100644 upstream-4.x/README.md create mode 100644 upstream-4.x/UPGRADE.md create mode 100644 upstream-4.x/composer.json create mode 100644 upstream-4.x/renovate.json create mode 100644 upstream-4.x/src/Common/Entity/Cell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/BooleanCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/DateIntervalCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/DateTimeCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/EmptyCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/ErrorCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/FormulaCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/NumericCell.php create mode 100644 upstream-4.x/src/Common/Entity/Cell/StringCell.php create mode 100644 upstream-4.x/src/Common/Entity/Comment/Comment.php create mode 100644 upstream-4.x/src/Common/Entity/Comment/TextRun.php create mode 100644 upstream-4.x/src/Common/Entity/Row.php create mode 100644 upstream-4.x/src/Common/Entity/Style/Border.php create mode 100644 upstream-4.x/src/Common/Entity/Style/BorderPart.php create mode 100644 upstream-4.x/src/Common/Entity/Style/CellAlignment.php create mode 100644 upstream-4.x/src/Common/Entity/Style/CellVerticalAlignment.php create mode 100644 upstream-4.x/src/Common/Entity/Style/Color.php create mode 100644 upstream-4.x/src/Common/Entity/Style/Style.php create mode 100644 upstream-4.x/src/Common/Exception/EncodingConversionException.php create mode 100644 upstream-4.x/src/Common/Exception/IOException.php create mode 100644 upstream-4.x/src/Common/Exception/InvalidArgumentException.php create mode 100644 upstream-4.x/src/Common/Exception/InvalidColorException.php create mode 100644 upstream-4.x/src/Common/Exception/OpenSpoutException.php create mode 100644 upstream-4.x/src/Common/Exception/UnsupportedTypeException.php create mode 100644 upstream-4.x/src/Common/Helper/EncodingHelper.php create mode 100644 upstream-4.x/src/Common/Helper/Escaper/EscaperInterface.php create mode 100644 upstream-4.x/src/Common/Helper/Escaper/ODS.php create mode 100644 upstream-4.x/src/Common/Helper/Escaper/XLSX.php create mode 100644 upstream-4.x/src/Common/Helper/FileSystemHelper.php create mode 100644 upstream-4.x/src/Common/Helper/FileSystemHelperInterface.php create mode 100644 upstream-4.x/src/Common/Helper/StringHelper.php create mode 100644 upstream-4.x/src/Common/TempFolderOptionTrait.php create mode 100644 upstream-4.x/src/Reader/AbstractReader.php create mode 100644 upstream-4.x/src/Reader/CSV/Options.php create mode 100644 upstream-4.x/src/Reader/CSV/Reader.php create mode 100644 upstream-4.x/src/Reader/CSV/RowIterator.php create mode 100644 upstream-4.x/src/Reader/CSV/Sheet.php create mode 100644 upstream-4.x/src/Reader/CSV/SheetIterator.php create mode 100644 upstream-4.x/src/Reader/Common/ColumnWidth.php create mode 100644 upstream-4.x/src/Reader/Common/Creator/ReaderFactory.php create mode 100644 upstream-4.x/src/Reader/Common/Manager/RowManager.php create mode 100644 upstream-4.x/src/Reader/Common/XMLProcessor.php create mode 100644 upstream-4.x/src/Reader/Exception/InvalidValueException.php create mode 100644 upstream-4.x/src/Reader/Exception/IteratorNotRewindableException.php create mode 100644 upstream-4.x/src/Reader/Exception/NoSheetsFoundException.php create mode 100644 upstream-4.x/src/Reader/Exception/ReaderException.php create mode 100644 upstream-4.x/src/Reader/Exception/ReaderNotOpenedException.php create mode 100644 upstream-4.x/src/Reader/Exception/SharedStringNotFoundException.php create mode 100644 upstream-4.x/src/Reader/Exception/XMLProcessingException.php create mode 100644 upstream-4.x/src/Reader/ODS/Helper/CellValueFormatter.php create mode 100644 upstream-4.x/src/Reader/ODS/Helper/SettingsHelper.php create mode 100644 upstream-4.x/src/Reader/ODS/Options.php create mode 100644 upstream-4.x/src/Reader/ODS/Reader.php create mode 100644 upstream-4.x/src/Reader/ODS/RowIterator.php create mode 100644 upstream-4.x/src/Reader/ODS/Sheet.php create mode 100644 upstream-4.x/src/Reader/ODS/SheetIterator.php create mode 100644 upstream-4.x/src/Reader/ReaderInterface.php create mode 100644 upstream-4.x/src/Reader/RowIteratorInterface.php create mode 100644 upstream-4.x/src/Reader/SheetInterface.php create mode 100644 upstream-4.x/src/Reader/SheetIteratorInterface.php create mode 100644 upstream-4.x/src/Reader/SheetWithMergeCellsInterface.php create mode 100644 upstream-4.x/src/Reader/SheetWithVisibilityInterface.php create mode 100644 upstream-4.x/src/Reader/Wrapper/XMLInternalErrorsHelper.php create mode 100644 upstream-4.x/src/Reader/Wrapper/XMLReader.php create mode 100644 upstream-4.x/src/Reader/XLSX/Helper/CellHelper.php create mode 100644 upstream-4.x/src/Reader/XLSX/Helper/CellValueFormatter.php create mode 100644 upstream-4.x/src/Reader/XLSX/Helper/DateFormatHelper.php create mode 100644 upstream-4.x/src/Reader/XLSX/Helper/DateIntervalFormatHelper.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactoryInterface.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyInterface.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/FileBasedStrategy.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/MemoryLimit.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SharedStringsManager.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/SheetManager.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/StyleManager.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/StyleManagerInterface.php create mode 100644 upstream-4.x/src/Reader/XLSX/Manager/WorkbookRelationshipsManager.php create mode 100644 upstream-4.x/src/Reader/XLSX/Options.php create mode 100644 upstream-4.x/src/Reader/XLSX/Reader.php create mode 100644 upstream-4.x/src/Reader/XLSX/RowIterator.php create mode 100644 upstream-4.x/src/Reader/XLSX/Sheet.php create mode 100644 upstream-4.x/src/Reader/XLSX/SheetHeaderReader.php create mode 100644 upstream-4.x/src/Reader/XLSX/SheetIterator.php create mode 100644 upstream-4.x/src/Reader/XLSX/SheetMergeCellsReader.php create mode 100644 upstream-4.x/src/Writer/AbstractWriter.php create mode 100644 upstream-4.x/src/Writer/AbstractWriterMultiSheets.php create mode 100644 upstream-4.x/src/Writer/AutoFilter.php create mode 100644 upstream-4.x/src/Writer/CSV/Options.php create mode 100644 upstream-4.x/src/Writer/CSV/Writer.php create mode 100644 upstream-4.x/src/Writer/Common/AbstractOptions.php create mode 100644 upstream-4.x/src/Writer/Common/ColumnWidth.php create mode 100644 upstream-4.x/src/Writer/Common/Creator/WriterFactory.php create mode 100644 upstream-4.x/src/Writer/Common/Entity/Sheet.php create mode 100644 upstream-4.x/src/Writer/Common/Entity/Workbook.php create mode 100644 upstream-4.x/src/Writer/Common/Entity/Worksheet.php create mode 100644 upstream-4.x/src/Writer/Common/Helper/CellHelper.php create mode 100644 upstream-4.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php create mode 100644 upstream-4.x/src/Writer/Common/Helper/ZipHelper.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/AbstractWorkbookManager.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/RegisteredStyle.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/SheetManager.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleManager.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleRegistry.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/Style/StyleMerger.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/WorkbookManagerInterface.php create mode 100644 upstream-4.x/src/Writer/Common/Manager/WorksheetManagerInterface.php create mode 100644 upstream-4.x/src/Writer/Exception/Border/InvalidNameException.php create mode 100644 upstream-4.x/src/Writer/Exception/Border/InvalidStyleException.php create mode 100644 upstream-4.x/src/Writer/Exception/Border/InvalidWidthException.php create mode 100644 upstream-4.x/src/Writer/Exception/InvalidSheetNameException.php create mode 100644 upstream-4.x/src/Writer/Exception/SheetNotFoundException.php create mode 100644 upstream-4.x/src/Writer/Exception/WriterAlreadyOpenedException.php create mode 100644 upstream-4.x/src/Writer/Exception/WriterException.php create mode 100644 upstream-4.x/src/Writer/Exception/WriterNotOpenedException.php create mode 100644 upstream-4.x/src/Writer/ODS/Helper/BorderHelper.php create mode 100644 upstream-4.x/src/Writer/ODS/Helper/FileSystemHelper.php create mode 100644 upstream-4.x/src/Writer/ODS/Manager/Style/StyleManager.php create mode 100644 upstream-4.x/src/Writer/ODS/Manager/Style/StyleRegistry.php create mode 100644 upstream-4.x/src/Writer/ODS/Manager/WorkbookManager.php create mode 100644 upstream-4.x/src/Writer/ODS/Manager/WorksheetManager.php create mode 100644 upstream-4.x/src/Writer/ODS/Options.php create mode 100644 upstream-4.x/src/Writer/ODS/Writer.php create mode 100644 upstream-4.x/src/Writer/WriterInterface.php create mode 100644 upstream-4.x/src/Writer/XLSX/Entity/SheetView.php create mode 100644 upstream-4.x/src/Writer/XLSX/Helper/BorderHelper.php create mode 100644 upstream-4.x/src/Writer/XLSX/Helper/DateHelper.php create mode 100644 upstream-4.x/src/Writer/XLSX/Helper/DateIntervalHelper.php create mode 100644 upstream-4.x/src/Writer/XLSX/Helper/FileSystemHelper.php create mode 100644 upstream-4.x/src/Writer/XLSX/Helper/PasswordHashHelper.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/CommentsManager.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/SharedStringsManager.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/Style/StyleManager.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/WorkbookManager.php create mode 100644 upstream-4.x/src/Writer/XLSX/Manager/WorksheetManager.php create mode 100644 upstream-4.x/src/Writer/XLSX/MergeCell.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/HeaderFooter.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/PageMargin.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/PageOrientation.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/PageSetup.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/PaperSize.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/SheetProtection.php create mode 100644 upstream-4.x/src/Writer/XLSX/Options/WorkbookProtection.php create mode 100644 upstream-4.x/src/Writer/XLSX/Writer.php diff --git a/upstream-4.x/LICENSE b/upstream-4.x/LICENSE new file mode 100644 index 0000000..38ce746 --- /dev/null +++ b/upstream-4.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-4.x/LICENSE-for-cc42c1d b/upstream-4.x/LICENSE-for-cc42c1d new file mode 100644 index 0000000..167ec4d --- /dev/null +++ b/upstream-4.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-4.x/README.md b/upstream-4.x/README.md new file mode 100644 index 0000000..a94eb45 --- /dev/null +++ b/upstream-4.x/README.md @@ -0,0 +1,38 @@ +# OpenSpout + +[![Latest Stable Version](https://poser.pugx.org/openspout/openspout/v/stable)](https://packagist.org/packages/openspout/openspout) +[![Total Downloads](https://poser.pugx.org/openspout/openspout/downloads)](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) +[![Infection MSI](https://img.shields.io/endpoint?style=flat&url=https%3A%2F%2Fbadge-api.stryker-mutator.io%2Fgithub.com%2Fopenspout%2Fopenspout%2F4.x)](https://dashboard.stryker-mutator.io/reports/github.com/openspout/openspout/4.x) + +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 [`docs/`](docs). + +## Upgrade from `box/spout:v3` to `openspout/openspout:v3` + +1. Replace `box/spout` with `openspout/openspout` in your `composer.json` +2. Replace `Box\Spout` with `OpenSpout` in your code + +## Upgrade guide + +Version 4 introduced new functionality but also some breaking changes. If you want to upgrade your OpenSpout codebase +please consult the [Upgrade guide](UPGRADE.md). + +## 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-4.x/UPGRADE.md b/upstream-4.x/UPGRADE.md new file mode 100644 index 0000000..d8327fa --- /dev/null +++ b/upstream-4.x/UPGRADE.md @@ -0,0 +1,162 @@ +# Upgrade guide + +## Upgrading from 3.x to 4.0 + +Beginning with v4, only actively supported [PHP version](https://www.php.net/supported-versions.php) will be supported. +Removing support for EOLed PHP versions as well adding support for new PHP versions will be included in MINOR releases. + +### Most notable changes + +1. OpenSpout is now fully typed +2. Classes and interfaces not consumed by the user are now marked as `@internal` +3. Classes used by the user are all `final` + +### Reader & Writer objects + +Both readers and writers have to be naturally instantiated with `new` keyword, passing the eventual needed `Options` +class as the first argument: + +```php +use OpenSpout\Reader\CSV\Reader; +use OpenSpout\Reader\CSV\Options; + +$options = new Options(); +$options->FIELD_DELIMITER = '|'; +$options->FIELD_ENCLOSURE = '@'; +$reader = new Reader($options); +``` + +### Cell types on writes + +Cell types are now handled with separate classes: + +```php +use OpenSpout\Common\Entity\Cell; +use OpenSpout\Common\Entity\Row; + +$row = new Row([ + new Cell\BooleanCell(true), + new Cell\DateIntervalCell(new DateInterval('P1D')), + new Cell\DateTimeCell(new DateTimeImmutable('now')), + new Cell\EmptyCell(null), + new Cell\FormulaCell('=SUM(A1:A2)'), + new Cell\NumericCell(3), + new Cell\StringCell('foo'), +]); +``` + +Auto-typing is still available though: + +```php +use OpenSpout\Common\Entity\Cell; +use OpenSpout\Common\Entity\Row; + +$cell = Cell::fromValue(true); // Instance of Cell\BooleanCell + +$row = Row::fromValues([ + true, + new DateInterval('P1D'), + new DateTimeImmutable('now'), + null, + '=SUM(A1:A2)', + 3, + 'foo', +]); +``` + +## Upgrading from 2.x to 3.0 + +OpenSpout 3.0 introduced several backwards-incompatible changes. The upgrade from OpenSpout 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. + +OpenSpout 3.0 tries to enforce better typing. For instance, instead of using/returning generic arrays, OpenSpout now +makes use of specific `Row` and `Cell` objects that can encapsulate more data such as type, style, value. + +Finally, **_OpenSpout 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, OpenSpout 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-4.x/composer.json b/upstream-4.x/composer.json new file mode 100644 index 0000000..fe7e7f2 --- /dev/null +++ b/upstream-4.x/composer.json @@ -0,0 +1,76 @@ +{ + "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": "~8.2.0 || ~8.3.0 || ~8.4.0", + "ext-dom": "*", + "ext-fileinfo": "*", + "ext-filter": "*", + "ext-libxml": "*", + "ext-xmlreader": "*", + "ext-zip": "*" + }, + "require-dev": { + "ext-zlib": "*", + "friendsofphp/php-cs-fixer": "^3.65.0", + "infection/infection": "^0.29.8", + "phpbench/phpbench": "^1.3.1", + "phpstan/phpstan": "^2.0.2", + "phpstan/phpstan-phpunit": "^2.0.1", + "phpstan/phpstan-strict-rules": "^2", + "phpunit/phpunit": "^11.4.3" + }, + "suggest": { + "ext-iconv": "To handle non UTF-8 CSV files (if \"php-mbstring\" is not already installed or is too limited)", + "ext-mbstring": "To handle non UTF-8 CSV files (if \"iconv\" is not already installed)" + }, + "autoload": { + "psr-4": { + "OpenSpout\\": "src/" + } + }, + "autoload-dev": { + "psr-4": { + "OpenSpout\\Benchmarks\\": "benchmarks/" + }, + "classmap": [ + "tests/" + ] + }, + "config": { + "allow-plugins": { + "infection/extension-installer": true + } + }, + "extra": { + "branch-alias": { + "dev-master": "3.3.x-dev" + } + } +} diff --git a/upstream-4.x/renovate.json b/upstream-4.x/renovate.json new file mode 100644 index 0000000..47bca6b --- /dev/null +++ b/upstream-4.x/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>Slamdunk/.github:renovate-config" + ] +} diff --git a/upstream-4.x/src/Common/Entity/Cell.php b/upstream-4.x/src/Common/Entity/Cell.php new file mode 100644 index 0000000..af76d4d --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell.php @@ -0,0 +1,65 @@ +setStyle($style); + } + + abstract public function getValue(): null|bool|DateInterval|DateTimeInterface|float|int|string; + + final public function setStyle(?Style $style): void + { + $this->style = $style ?? new Style(); + } + + final public function getStyle(): Style + { + return $this->style; + } + + final public static function fromValue(null|bool|DateInterval|DateTimeInterface|float|int|string $value, ?Style $style = null): self + { + if (\is_bool($value)) { + return new BooleanCell($value, $style); + } + if (null === $value || '' === $value) { + return new EmptyCell($value, $style); + } + if (\is_int($value) || \is_float($value)) { + return new NumericCell($value, $style); + } + if ($value instanceof DateTimeInterface) { + return new DateTimeCell($value, $style); + } + if ($value instanceof DateInterval) { + return new DateIntervalCell($value, $style); + } + if (isset($value[0]) && '=' === $value[0]) { + return new FormulaCell($value, $style, null); + } + + return new StringCell($value, $style); + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/BooleanCell.php b/upstream-4.x/src/Common/Entity/Cell/BooleanCell.php new file mode 100644 index 0000000..ecba8e8 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/BooleanCell.php @@ -0,0 +1,24 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): bool + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/DateIntervalCell.php b/upstream-4.x/src/Common/Entity/Cell/DateIntervalCell.php new file mode 100644 index 0000000..65d3086 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/DateIntervalCell.php @@ -0,0 +1,31 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): DateInterval + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/DateTimeCell.php b/upstream-4.x/src/Common/Entity/Cell/DateTimeCell.php new file mode 100644 index 0000000..bced400 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/DateTimeCell.php @@ -0,0 +1,25 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): DateTimeInterface + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/EmptyCell.php b/upstream-4.x/src/Common/Entity/Cell/EmptyCell.php new file mode 100644 index 0000000..72678b9 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/EmptyCell.php @@ -0,0 +1,24 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): ?string + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/ErrorCell.php b/upstream-4.x/src/Common/Entity/Cell/ErrorCell.php new file mode 100644 index 0000000..53a445e --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/ErrorCell.php @@ -0,0 +1,29 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): ?string + { + return null; + } + + public function getRawValue(): string + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/FormulaCell.php b/upstream-4.x/src/Common/Entity/Cell/FormulaCell.php new file mode 100644 index 0000000..e5f098b --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/FormulaCell.php @@ -0,0 +1,31 @@ +value; + } + + public function getComputedValue(): null|bool|DateInterval|DateTimeImmutable|float|int|string + { + return $this->computedValue; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/NumericCell.php b/upstream-4.x/src/Common/Entity/Cell/NumericCell.php new file mode 100644 index 0000000..ee5d0ea --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/NumericCell.php @@ -0,0 +1,24 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): float|int + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Cell/StringCell.php b/upstream-4.x/src/Common/Entity/Cell/StringCell.php new file mode 100644 index 0000000..8539794 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Cell/StringCell.php @@ -0,0 +1,24 @@ +value = $value; + parent::__construct($style); + } + + public function getValue(): string + { + return $this->value; + } +} diff --git a/upstream-4.x/src/Common/Entity/Comment/Comment.php b/upstream-4.x/src/Common/Entity/Comment/Comment.php new file mode 100644 index 0000000..47753df --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Comment/Comment.php @@ -0,0 +1,47 @@ +textRuns[] = $textRun; + } + + /** + * The TextRuns for this comment. + * + * @return TextRun[] + */ + public function getTextRuns(): array + { + return $this->textRuns; + } +} diff --git a/upstream-4.x/src/Common/Entity/Comment/TextRun.php b/upstream-4.x/src/Common/Entity/Comment/TextRun.php new file mode 100644 index 0000000..16bc23e --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Comment/TextRun.php @@ -0,0 +1,23 @@ +text = $text; + } +} diff --git a/upstream-4.x/src/Common/Entity/Row.php b/upstream-4.x/src/Common/Entity/Row.php new file mode 100644 index 0000000..6040503 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Row.php @@ -0,0 +1,169 @@ +setCells($cells) + ->setStyle($style) + ; + } + + /** + * @param list $cellValues + */ + public static function fromValues(array $cellValues = [], ?Style $rowStyle = null): self + { + $cells = array_map(static function (null|bool|DateInterval|DateTimeInterface|float|int|string $cellValue): Cell { + return Cell::fromValue($cellValue); + }, $cellValues); + + return new self($cells, $rowStyle); + } + + /** + * @param array $cellValues + * @param array $columnStyles + */ + public static function fromValuesWithStyles(array $cellValues = [], ?Style $rowStyle = null, array $columnStyles = []): self + { + $cells = array_map(static function (null|bool|DateInterval|DateTimeInterface|float|int|string $cellValue, int|string $key) use ($columnStyles): Cell { + return Cell::fromValue($cellValue, $columnStyles[$key] ?? null); + }, $cellValues, array_keys($cellValues)); + + return new self($cells, $rowStyle); + } + + /** + * @return Cell[] $cells + */ + public function getCells(): array + { + return $this->cells; + } + + /** + * @param Cell[] $cells + */ + public function setCells(array $cells): self + { + $this->cells = []; + foreach ($cells as $cell) { + $this->addCell($cell); + } + + return $this; + } + + public function setCellAtIndex(Cell $cell, int $cellIndex): self + { + $this->cells[$cellIndex] = $cell; + + return $this; + } + + public function getCellAtIndex(int $cellIndex): ?Cell + { + return $this->cells[$cellIndex] ?? null; + } + + public function addCell(Cell $cell): self + { + $this->cells[] = $cell; + + return $this; + } + + public function getNumCells(): int + { + // When using "setCellAtIndex", it's possible to + // have "$this->cells" contain holes. + if ([] === $this->cells) { + return 0; + } + + return max(array_keys($this->cells)) + 1; + } + + public function getStyle(): Style + { + return $this->style; + } + + public function setStyle(?Style $style): self + { + $this->style = $style ?? new Style(); + + return $this; + } + + /** + * Set row height. + */ + public function setHeight(float $height): self + { + $this->height = $height; + + return $this; + } + + /** + * Returns row height. + */ + public function getHeight(): float + { + return $this->height; + } + + /** + * @return list The row values, as array + */ + public function toArray(): array + { + return array_map(static function (Cell $cell): null|bool|DateInterval|DateTimeInterface|float|int|string { + return $cell->getValue(); + }, $this->cells); + } + + /** + * Detect whether a row is considered empty. + * An empty row has all of its cells empty. + */ + public function isEmpty(): bool + { + foreach ($this->cells as $cell) { + if (!$cell instanceof Cell\EmptyCell) { + return false; + } + } + + return true; + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/Border.php b/upstream-4.x/src/Common/Entity/Style/Border.php new file mode 100644 index 0000000..487edaa --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/Border.php @@ -0,0 +1,46 @@ + */ + private array $parts; + + public function __construct(BorderPart ...$borderParts) + { + foreach ($borderParts as $borderPart) { + $this->parts[$borderPart->getName()] = $borderPart; + } + } + + public function getPart(string $name): ?BorderPart + { + return $this->parts[$name] ?? null; + } + + /** + * @return array + */ + public function getParts(): array + { + return $this->parts; + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/BorderPart.php b/upstream-4.x/src/Common/Entity/Style/BorderPart.php new file mode 100644 index 0000000..712e82f --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/BorderPart.php @@ -0,0 +1,90 @@ +name = $name; + $this->color = $color; + $this->width = $width; + $this->style = $style; + } + + public function getName(): string + { + return $this->name; + } + + public function getStyle(): string + { + return $this->style; + } + + public function getColor(): string + { + return $this->color; + } + + public function getWidth(): string + { + return $this->width; + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/CellAlignment.php b/upstream-4.x/src/Common/Entity/Style/CellAlignment.php new file mode 100644 index 0000000..48d4da3 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/CellAlignment.php @@ -0,0 +1,31 @@ + 1, + self::RIGHT => 1, + self::CENTER => 1, + self::JUSTIFY => 1, + ]; + + /** + * @return bool Whether the given cell alignment is valid + */ + public static function isValid(string $cellAlignment): bool + { + return isset(self::VALID_ALIGNMENTS[$cellAlignment]); + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/CellVerticalAlignment.php b/upstream-4.x/src/Common/Entity/Style/CellVerticalAlignment.php new file mode 100644 index 0000000..2a38fdf --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/CellVerticalAlignment.php @@ -0,0 +1,37 @@ + 1, + self::BASELINE => 1, + self::BOTTOM => 1, + self::CENTER => 1, + self::DISTRIBUTED => 1, + self::JUSTIFY => 1, + self::TOP => 1, + ]; + + /** + * @return bool Whether the given cell vertical alignment is valid + */ + public static function isValid(string $cellVerticalAlignment): bool + { + return isset(self::VALID_ALIGNMENTS[$cellVerticalAlignment]); + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/Color.php b/upstream-4.x/src/Common/Entity/Style/Color.php new file mode 100644 index 0000000..e4ee1e7 --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/Color.php @@ -0,0 +1,88 @@ + 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" + */ + private static function convertColorComponentToHex(int $colorComponent): string + { + return str_pad(dechex($colorComponent), 2, '0', STR_PAD_LEFT); + } +} diff --git a/upstream-4.x/src/Common/Entity/Style/Style.php b/upstream-4.x/src/Common/Entity/Style/Style.php new file mode 100644 index 0000000..98d35ed --- /dev/null +++ b/upstream-4.x/src/Common/Entity/Style/Style.php @@ -0,0 +1,495 @@ +id); + + return $this->id; + } + + public function setId(int $id): self + { + $this->id = $id; + + return $this; + } + + public function getBorder(): ?Border + { + return $this->border; + } + + public function setBorder(Border $border): self + { + $this->border = $border; + $this->isEmpty = false; + + return $this; + } + + public function isFontBold(): bool + { + return $this->fontBold; + } + + public function setFontBold(): self + { + $this->fontBold = true; + $this->hasSetFontBold = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontBold(): bool + { + return $this->hasSetFontBold; + } + + public function isFontItalic(): bool + { + return $this->fontItalic; + } + + public function setFontItalic(): self + { + $this->fontItalic = true; + $this->hasSetFontItalic = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontItalic(): bool + { + return $this->hasSetFontItalic; + } + + public function isFontUnderline(): bool + { + return $this->fontUnderline; + } + + public function setFontUnderline(): self + { + $this->fontUnderline = true; + $this->hasSetFontUnderline = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontUnderline(): bool + { + return $this->hasSetFontUnderline; + } + + public function isFontStrikethrough(): bool + { + return $this->fontStrikethrough; + } + + public function setFontStrikethrough(): self + { + $this->fontStrikethrough = true; + $this->hasSetFontStrikethrough = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontStrikethrough(): bool + { + return $this->hasSetFontStrikethrough; + } + + public function getFontSize(): int + { + return $this->fontSize; + } + + /** + * @param int $fontSize Font size, in pixels + */ + public function setFontSize(int $fontSize): self + { + $this->fontSize = $fontSize; + $this->hasSetFontSize = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontSize(): bool + { + return $this->hasSetFontSize; + } + + public function getFontColor(): string + { + return $this->fontColor; + } + + /** + * Sets the font color. + * + * @param string $fontColor ARGB color (@see Color) + */ + public function setFontColor(string $fontColor): self + { + $this->fontColor = $fontColor; + $this->hasSetFontColor = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontColor(): bool + { + return $this->hasSetFontColor; + } + + public function getFontName(): string + { + return $this->fontName; + } + + /** + * @param string $fontName Name of the font to use + */ + public function setFontName(string $fontName): self + { + $this->fontName = $fontName; + $this->hasSetFontName = true; + $this->shouldApplyFont = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetFontName(): bool + { + return $this->hasSetFontName; + } + + public function getCellAlignment(): string + { + return $this->cellAlignment; + } + + public function getCellVerticalAlignment(): string + { + return $this->cellVerticalAlignment; + } + + /** + * @param string $cellAlignment The cell alignment + */ + public function setCellAlignment(string $cellAlignment): self + { + if (!CellAlignment::isValid($cellAlignment)) { + throw new InvalidArgumentException('Invalid cell alignment value'); + } + + $this->cellAlignment = $cellAlignment; + $this->hasSetCellAlignment = true; + $this->shouldApplyCellAlignment = true; + $this->isEmpty = false; + + return $this; + } + + /** + * @param string $cellVerticalAlignment The cell vertical alignment + */ + public function setCellVerticalAlignment(string $cellVerticalAlignment): self + { + if (!CellVerticalAlignment::isValid($cellVerticalAlignment)) { + throw new InvalidArgumentException('Invalid cell vertical alignment value'); + } + + $this->cellVerticalAlignment = $cellVerticalAlignment; + $this->hasSetCellVerticalAlignment = true; + $this->shouldApplyCellVerticalAlignment = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetCellAlignment(): bool + { + return $this->hasSetCellAlignment; + } + + public function hasSetCellVerticalAlignment(): bool + { + return $this->hasSetCellVerticalAlignment; + } + + /** + * @return bool Whether specific cell alignment should be applied + */ + public function shouldApplyCellAlignment(): bool + { + return $this->shouldApplyCellAlignment; + } + + public function shouldApplyCellVerticalAlignment(): bool + { + return $this->shouldApplyCellVerticalAlignment; + } + + public function shouldWrapText(): bool + { + return $this->shouldWrapText; + } + + /** + * @param bool $shouldWrap Should the text be wrapped + */ + public function setShouldWrapText(bool $shouldWrap = true): self + { + $this->shouldWrapText = $shouldWrap; + $this->hasSetWrapText = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetWrapText(): bool + { + return $this->hasSetWrapText; + } + + public function textRotation(): int + { + return $this->textRotation; + } + + /** + * @param int $rotation Rotate text + */ + public function setTextRotation(int $rotation): self + { + $this->textRotation = $rotation; + $this->hasSetTextRotation = true; + $this->isEmpty = false; + + return $this; + } + + public function hasSetTextRotation(): bool + { + return $this->hasSetTextRotation; + } + + /** + * @return bool Whether specific font properties should be applied + */ + public function shouldApplyFont(): bool + { + return $this->shouldApplyFont; + } + + /** + * Sets the background color. + * + * @param string $color ARGB color (@see Color) + */ + public function setBackgroundColor(string $color): self + { + $this->backgroundColor = $color; + $this->isEmpty = false; + + return $this; + } + + public function getBackgroundColor(): ?string + { + return $this->backgroundColor; + } + + /** + * Sets format. + */ + public function setFormat(string $format): self + { + $this->format = $format; + $this->isEmpty = false; + + return $this; + } + + public function getFormat(): ?string + { + return $this->format; + } + + public function isRegistered(): bool + { + return $this->isRegistered; + } + + public function markAsRegistered(?int $id): void + { + $this->setId($id); + $this->isRegistered = true; + } + + public function isEmpty(): bool + { + return $this->isEmpty; + } + + /** + * Sets should shrink to fit. + */ + public function setShouldShrinkToFit(bool $shrinkToFit = true): self + { + $this->hasSetShrinkToFit = true; + $this->shouldShrinkToFit = $shrinkToFit; + + return $this; + } + + /** + * @return bool Whether format should be applied + */ + public function shouldShrinkToFit(): bool + { + return $this->shouldShrinkToFit; + } + + public function hasSetShrinkToFit(): bool + { + return $this->hasSetShrinkToFit; + } +} diff --git a/upstream-4.x/src/Common/Exception/EncodingConversionException.php b/upstream-4.x/src/Common/Exception/EncodingConversionException.php new file mode 100644 index 0000000..d08d0ba --- /dev/null +++ b/upstream-4.x/src/Common/Exception/EncodingConversionException.php @@ -0,0 +1,7 @@ + Map representing the encodings supporting BOMs (key) and their associated BOM (value) */ + private array $supportedEncodingsWithBom; + + private bool $canUseIconv; + + private bool $canUseMbString; + + public function __construct(bool $canUseIconv, bool $canUseMbString) + { + $this->canUseIconv = $canUseIconv; + $this->canUseMbString = $canUseMbString; + + $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, + ]; + } + + public static function factory(): self + { + return new self( + \function_exists('iconv'), + \function_exists('mb_convert_encoding'), + ); + } + + /** + * 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, string $encoding): int + { + $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 + * + * @return string The converted, UTF-8 string + * + * @throws EncodingConversionException If conversion is not supported or if the conversion failed + */ + public function attemptConversionToUTF8(?string $string, string $sourceEncoding): ?string + { + 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 + * + * @return string The converted string, encoded with the given encoding + * + * @throws EncodingConversionException If conversion is not supported or if the conversion failed + */ + public function attemptConversionFromUTF8(?string $string, string $targetEncoding): ?string + { + 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 + */ + private function hasBOM($filePointer, string $encoding): bool + { + $hasBOM = false; + + rewind($filePointer); + + if (\array_key_exists($encoding, $this->supportedEncodingsWithBom)) { + $potentialBom = $this->supportedEncodingsWithBom[$encoding]; + $numBytesInBom = \strlen($potentialBom); + + $hasBOM = (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 + * + * @return string The converted string, encoded with the given encoding + * + * @throws EncodingConversionException If conversion is not supported or if the conversion failed + */ + private function attemptConversion(?string $string, string $sourceEncoding, string $targetEncoding): ?string + { + // if source and target encodings are the same, it's a no-op + if (null === $string || $sourceEncoding === $targetEncoding) { + return $string; + } + + $convertedString = null; + + if ($this->canUseIconv) { + set_error_handler(static function (): bool { + return true; + }); + + $convertedString = iconv($sourceEncoding, $targetEncoding, $string); + + restore_error_handler(); + } elseif ($this->canUseMbString) { + $errorMessage = null; + set_error_handler(static function ($nr, $message) use (&$errorMessage): bool { + $errorMessage = $message; // @codeCoverageIgnore + + return true; // @codeCoverageIgnore + }); + + try { + $convertedString = mb_convert_encoding($string, $targetEncoding, $sourceEncoding); + } catch (Error $error) { + $errorMessage = $error->getMessage(); + } + + restore_error_handler(); + if (null !== $errorMessage) { + $convertedString = false; + } + } else { + throw new EncodingConversionException("The conversion from {$sourceEncoding} to {$targetEncoding} is not supported. Please install \"iconv\" or \"mbstring\"."); + } + + if (false === $convertedString) { + throw new EncodingConversionException("The conversion from {$sourceEncoding} to {$targetEncoding} failed."); + } + + return $convertedString; + } +} diff --git a/upstream-4.x/src/Common/Helper/Escaper/EscaperInterface.php b/upstream-4.x/src/Common/Helper/Escaper/EscaperInterface.php new file mode 100644 index 0000000..7d551ee --- /dev/null +++ b/upstream-4.x/src/Common/Helper/Escaper/EscaperInterface.php @@ -0,0 +1,29 @@ +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 $string): 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. + */ + private function initIfNeeded(): void + { + 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 + */ + private function getEscapableControlCharactersPattern(): string + { + // 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[] + */ + private function getControlCharactersEscapingMap(): array + { + $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 (1 === 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 + */ + private function escapeControlCharacters(string $string): string + { + $escapedString = $this->escapeEscapeCharacter($string); + + // if no control characters + if (1 !== 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 + */ + private function escapeEscapeCharacter(string $string): 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 + */ + private function unescapeControlCharacters(string $string): 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 + */ + private function unescapeEscapeCharacter(string $string): string + { + return preg_replace('/_x005F(_x[\dA-F]{4}_)/', '$1', $string); + } +} diff --git a/upstream-4.x/src/Common/Helper/FileSystemHelper.php b/upstream-4.x/src/Common/Helper/FileSystemHelper.php new file mode 100644 index 0000000..ae8b2ed --- /dev/null +++ b/upstream-4.x/src/Common/Helper/FileSystemHelper.php @@ -0,0 +1,164 @@ +baseFolderRealPath = $realpath; + } + + public function getBaseFolderRealPath(): string + { + return $this->baseFolderRealPath; + } + + /** + * Creates an empty folder with the given name under the given parent folder. + * + * @param string $parentFolderPath The parent folder path under which the folder is going to be created + * @param string $folderName The name of the folder to create + * + * @return string Path of the created folder + * + * @throws IOException If unable to create the folder or if the folder path is not inside of the base folder + */ + public function createFolder(string $parentFolderPath, string $folderName): string + { + $this->throwIfOperationNotInBaseFolder($parentFolderPath); + + $folderPath = $parentFolderPath.\DIRECTORY_SEPARATOR.$folderName; + + $errorMessage = ''; + set_error_handler(static function ($nr, $message) use (&$errorMessage): bool { + $errorMessage = $message; + + return true; + }); + $wasCreationSuccessful = mkdir($folderPath, 0777, true); + restore_error_handler(); + + if (!$wasCreationSuccessful) { + throw new IOException("Unable to create folder: {$folderPath} - {$errorMessage}"); + } + + return $folderPath; + } + + /** + * Creates a file with the given name and content in the given folder. + * The parent folder must exist. + * + * @param string $parentFolderPath The parent folder path where the file is going to be created + * @param string $fileName The name of the file to create + * @param string $fileContents The contents of the file to create + * + * @return string Path of the created file + * + * @throws IOException If unable to create the file or if the file path is not inside of the base folder + */ + public function createFileWithContents(string $parentFolderPath, string $fileName, string $fileContents): string + { + $this->throwIfOperationNotInBaseFolder($parentFolderPath); + + $filePath = $parentFolderPath.\DIRECTORY_SEPARATOR.$fileName; + + $errorMessage = ''; + set_error_handler(static function ($nr, $message) use (&$errorMessage): bool { + $errorMessage = $message; + + return true; + }); + $wasCreationSuccessful = file_put_contents($filePath, $fileContents); + restore_error_handler(); + + if (false === $wasCreationSuccessful) { + throw new IOException("Unable to create file: {$filePath} - {$errorMessage}"); + } + + return $filePath; + } + + /** + * Delete the file at the given path. + * + * @param string $filePath Path of the file to delete + * + * @throws IOException If the file path is not inside of the base folder + */ + public function deleteFile(string $filePath): void + { + $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 IOException If the folder path is not inside of the base folder + */ + public function deleteFolderRecursively(string $folderPath): void + { + $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 IOException If the folder where the I/O operation should occur + * is not inside the base folder or the base folder does not exist + */ + private function throwIfOperationNotInBaseFolder(string $operationFolderPath): void + { + $operationFolderRealPath = realpath($operationFolderPath); + if (false === $operationFolderRealPath) { + throw new IOException("Folder not found: {$operationFolderRealPath}"); + } + $isInBaseFolder = str_starts_with($operationFolderRealPath, $this->baseFolderRealPath); + if (!$isInBaseFolder) { + throw new IOException("Cannot perform I/O operation outside of the base folder: {$this->baseFolderRealPath}"); + } + } +} diff --git a/upstream-4.x/src/Common/Helper/FileSystemHelperInterface.php b/upstream-4.x/src/Common/Helper/FileSystemHelperInterface.php new file mode 100644 index 0000000..390346e --- /dev/null +++ b/upstream-4.x/src/Common/Helper/FileSystemHelperInterface.php @@ -0,0 +1,57 @@ +hasMbstringSupport = $hasMbstringSupport; + } + + public static function factory(): self + { + return new self(\function_exists('mb_strlen')); + } + + /** + * Returns the length of the given string. + * It uses the multi-bytes function is available. + * + * @see strlen + * @see mb_strlen + */ + public function getStringLength(string $string): int + { + return $this->hasMbstringSupport + ? mb_strlen($string) + : \strlen($string); // @codeCoverageIgnore + } + + /** + * 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(string $char, string $string): int + { + $position = $this->hasMbstringSupport + ? mb_strpos($string, $char) + : strpos($string, $char); // @codeCoverageIgnore + + 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(string $char, string $string): int + { + $position = $this->hasMbstringSupport + ? mb_strrpos($string, $char) + : strrpos($string, $char); // @codeCoverageIgnore + + return (false !== $position) ? $position : -1; + } +} diff --git a/upstream-4.x/src/Common/TempFolderOptionTrait.php b/upstream-4.x/src/Common/TempFolderOptionTrait.php new file mode 100644 index 0000000..a8d03e4 --- /dev/null +++ b/upstream-4.x/src/Common/TempFolderOptionTrait.php @@ -0,0 +1,33 @@ +tempFolder = $tempFolder; + } + + final public function getTempFolder(): string + { + if (!isset($this->tempFolder)) { + $this->setTempFolder(sys_get_temp_dir()); + } + + return $this->tempFolder; + } +} diff --git a/upstream-4.x/src/Reader/AbstractReader.php b/upstream-4.x/src/Reader/AbstractReader.php new file mode 100644 index 0000000..3582345 --- /dev/null +++ b/upstream-4.x/src/Reader/AbstractReader.php @@ -0,0 +1,171 @@ + + */ +abstract class AbstractReader implements ReaderInterface +{ + /** @var bool Indicates whether the stream is currently open */ + private bool $isStreamOpened = false; + + /** + * 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 IOException If the file at the given path does not exist, is not readable or is corrupted + */ + public function open(string $filePath): void + { + 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 (!file_exists($filePath)) { + throw new IOException("Could not open {$filePath} for reading! File does not exist."); + } + if (!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 (ReaderException $exception) { + throw new IOException( + "Could not open {$filePath} for reading!", + 0, + $exception + ); + } + } + + /** + * Closes the reader, preventing any additional reading. + */ + final public function close(): void + { + if ($this->isStreamOpened) { + $this->closeReader(); + + $this->isStreamOpened = false; + } + } + + /** + * Returns whether stream wrappers are supported. + */ + abstract protected function doesSupportStreamWrapper(): bool; + + /** + * 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(string $filePath): void; + + /** + * Closes the reader. To be used after reading the file. + */ + abstract protected function closeReader(): void; + + final protected function ensureStreamOpened(): void + { + if (!$this->isStreamOpened) { + throw new ReaderNotOpenedException('Reader should be opened first.'); + } + } + + /** + * Returns the real path of the given path. + * If the given path is a valid stream wrapper, returns the path unchanged. + */ + private function getFileRealPath(string $filePath): string + { + if ($this->isSupportedStreamWrapper($filePath)) { + return $filePath; + } + + // Need to use realpath to fix "Can't open file" on some Windows setup + $realpath = realpath($filePath); + \assert(false !== $realpath); + + return $realpath; + } + + /** + * 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 + */ + private function getStreamWrapperScheme(string $filePath): ?string + { + $streamScheme = null; + if (1 === 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 + */ + private function isStreamWrapper(string $filePath): bool + { + 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 + */ + private function isSupportedStreamWrapper(string $filePath): bool + { + $streamScheme = $this->getStreamWrapperScheme($filePath); + + return null === $streamScheme || \in_array($streamScheme, stream_get_wrappers(), 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 + */ + private function isPhpStream(string $filePath): bool + { + $streamScheme = $this->getStreamWrapperScheme($filePath); + + return 'php' === $streamScheme; + } +} diff --git a/upstream-4.x/src/Reader/CSV/Options.php b/upstream-4.x/src/Reader/CSV/Options.php new file mode 100644 index 0000000..e470915 --- /dev/null +++ b/upstream-4.x/src/Reader/CSV/Options.php @@ -0,0 +1,15 @@ + + */ +final class Reader extends AbstractReader +{ + /** @var resource Pointer to the file to be written */ + private $filePointer; + + /** @var SheetIterator To iterator over the CSV unique "sheet" */ + private SheetIterator $sheetIterator; + + private readonly Options $options; + private readonly EncodingHelper $encodingHelper; + + public function __construct( + ?Options $options = null, + ?EncodingHelper $encodingHelper = null + ) { + $this->options = $options ?? new Options(); + $this->encodingHelper = $encodingHelper ?? EncodingHelper::factory(); + } + + public function getSheetIterator(): SheetIterator + { + $this->ensureStreamOpened(); + + return $this->sheetIterator; + } + + /** + * Returns whether stream wrappers are supported. + */ + protected function doesSupportStreamWrapper(): bool + { + 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 IOException + */ + protected function openReader(string $filePath): void + { + $resource = fopen($filePath, 'r'); + \assert(false !== $resource); + $this->filePointer = $resource; + + $this->sheetIterator = new SheetIterator( + new Sheet( + new RowIterator( + $this->filePointer, + $this->options, + $this->encodingHelper + ) + ) + ); + } + + /** + * Closes the reader. To be used after reading the file. + */ + protected function closeReader(): void + { + fclose($this->filePointer); + } +} diff --git a/upstream-4.x/src/Reader/CSV/RowIterator.php b/upstream-4.x/src/Reader/CSV/RowIterator.php new file mode 100644 index 0000000..938672d --- /dev/null +++ b/upstream-4.x/src/Reader/CSV/RowIterator.php @@ -0,0 +1,219 @@ +filePointer = $filePointer; + $this->options = $options; + $this->encodingHelper = $encodingHelper; + } + + /** + * Rewind the Iterator to the first element. + * + * @see http://php.net/manual/en/iterator.rewind.php + */ + 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 + */ + public function valid(): bool + { + return null !== $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 EncodingConversionException If unable to convert data to UTF-8 + */ + public function next(): void + { + $this->hasReachedEndOfFile = feof($this->filePointer); + + if (!$this->hasReachedEndOfFile) { + $this->readDataForNextRow(); + } + } + + /** + * Return the current element from the buffer. + * + * @see http://php.net/manual/en/iterator.current.php + */ + public function current(): ?Row + { + return $this->rowBuffer; + } + + /** + * Return the key of the current element. + * + * @see http://php.net/manual/en/iterator.key.php + */ + public function key(): int + { + return $this->numReadRows; + } + + /** + * 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. + */ + private function rewindAndSkipBom(): void + { + $byteOffsetToSkipBom = $this->encodingHelper->getBytesOffsetToSkipBOM($this->filePointer, $this->options->ENCODING); + + // sets the cursor after the BOM (0 means no BOM, so rewind it) + fseek($this->filePointer, $byteOffsetToSkipBom); + } + + /** + * @throws EncodingConversionException If unable to convert data to UTF-8 + */ + private function readDataForNextRow(): void + { + do { + $rowData = $this->getNextUTF8EncodedRow(); + } while ($this->shouldReadNextRow($rowData)); + + if (false !== $rowData) { + // array_map will replace NULL values by empty strings + $rowDataBufferAsArray = array_map('\strval', $rowData); + $this->rowBuffer = new Row(array_map(static function ($cellValue) { + return Cell::fromValue($cellValue); + }, $rowDataBufferAsArray), null); + ++$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 + */ + private function shouldReadNextRow($currentRowData): bool + { + $hasSuccessfullyFetchedRowData = (false !== $currentRowData); + $hasNowReachedEndOfFile = feof($this->filePointer); + $isEmptyLine = $this->isEmptyLine($currentRowData); + + return + (!$hasSuccessfullyFetchedRowData && !$hasNowReachedEndOfFile) + || (!$this->options->SHOULD_PRESERVE_EMPTY_ROWS && $isEmptyLine); + } + + /** + * Returns the next row, converted if necessary to UTF-8. + * As fgetcsv() does not manage correctly encoding for non UTF-8 data, + * we remove manually whitespace with ltrim or rtrim (depending on the order of the bytes). + * + * @return array|false The row for the current file pointer, encoded in UTF-8 or FALSE if nothing to read + * + * @throws EncodingConversionException If unable to convert data to UTF-8 + */ + private function getNextUTF8EncodedRow(): array|false + { + $encodedRowData = fgetcsv( + $this->filePointer, + self::MAX_READ_BYTES_PER_LINE, + $this->options->FIELD_DELIMITER, + $this->options->FIELD_ENCLOSURE, + '' + ); + if (false === $encodedRowData) { + return false; + } + + foreach ($encodedRowData as $cellIndex => $cellValue) { + switch ($this->options->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->options->ENCODING); + } + + return $encodedRowData; + } + + /** + * @param array|bool $lineData Array containing the cells value for the line + * + * @return bool Whether the given line is empty + */ + private function isEmptyLine($lineData): bool + { + return \is_array($lineData) && 1 === \count($lineData) && null === $lineData[0]; + } +} diff --git a/upstream-4.x/src/Reader/CSV/Sheet.php b/upstream-4.x/src/Reader/CSV/Sheet.php new file mode 100644 index 0000000..ea37e24 --- /dev/null +++ b/upstream-4.x/src/Reader/CSV/Sheet.php @@ -0,0 +1,53 @@ + + */ +final readonly class Sheet implements SheetInterface +{ + /** @var RowIterator To iterate over the CSV's rows */ + private RowIterator $rowIterator; + + /** + * @param RowIterator $rowIterator Corresponding row iterator + */ + public function __construct(RowIterator $rowIterator) + { + $this->rowIterator = $rowIterator; + } + + public function getRowIterator(): RowIterator + { + return $this->rowIterator; + } + + /** + * @return int Index of the sheet + */ + public function getIndex(): int + { + return 0; + } + + /** + * @return string Name of the sheet - empty string since CSV does not support that + */ + public function getName(): string + { + return ''; + } + + /** + * @return bool Always TRUE as there is only one sheet + */ + public function isActive(): bool + { + return true; + } +} diff --git a/upstream-4.x/src/Reader/CSV/SheetIterator.php b/upstream-4.x/src/Reader/CSV/SheetIterator.php new file mode 100644 index 0000000..5a2b915 --- /dev/null +++ b/upstream-4.x/src/Reader/CSV/SheetIterator.php @@ -0,0 +1,77 @@ + + */ +final class SheetIterator implements SheetIteratorInterface +{ + /** @var Sheet The CSV unique "sheet" */ + private readonly Sheet $sheet; + + /** @var bool Whether the unique "sheet" has already been read */ + private bool $hasReadUniqueSheet = false; + + /** + * @param Sheet $sheet Corresponding unique sheet + */ + public function __construct(Sheet $sheet) + { + $this->sheet = $sheet; + } + + /** + * Rewind the Iterator to the first element. + * + * @see http://php.net/manual/en/iterator.rewind.php + */ + public function rewind(): void + { + $this->hasReadUniqueSheet = false; + } + + /** + * Checks if current position is valid. + * + * @see http://php.net/manual/en/iterator.valid.php + */ + public function valid(): bool + { + return !$this->hasReadUniqueSheet; + } + + /** + * Move forward to next element. + * + * @see http://php.net/manual/en/iterator.next.php + */ + public function next(): void + { + $this->hasReadUniqueSheet = true; + } + + /** + * Return the current element. + * + * @see http://php.net/manual/en/iterator.current.php + */ + public function current(): Sheet + { + return $this->sheet; + } + + /** + * Return the key of the current element. + * + * @see http://php.net/manual/en/iterator.key.php + */ + public function key(): int + { + return 1; + } +} diff --git a/upstream-4.x/src/Reader/Common/ColumnWidth.php b/upstream-4.x/src/Reader/Common/ColumnWidth.php new file mode 100644 index 0000000..2e8aac3 --- /dev/null +++ b/upstream-4.x/src/Reader/Common/ColumnWidth.php @@ -0,0 +1,21 @@ + new CSVReader(), + 'xlsx' => new XLSXReader(), + 'ods' => new ODSReader(), + default => throw new UnsupportedTypeException('No readers supporting the given type: '.$extension), + }; + } + + /** + * Creates a reader by mime type. + * + * @param string $path the path to the spreadsheet file + * + * @throws UnsupportedTypeException + * @throws IOException + */ + public static function createFromFileByMimeType(string $path): ReaderInterface + { + if (!file_exists($path)) { + throw new IOException("Could not open {$path} for reading! File does not exist."); + } + + $mime_type = mime_content_type($path); + + return match ($mime_type) { + 'application/csv', 'text/csv', 'text/plain' => new CSVReader(), + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' => new XLSXReader(), + 'application/vnd.oasis.opendocument.spreadsheet' => new ODSReader(), + default => throw new UnsupportedTypeException('No readers supporting the given type: '.$mime_type), + }; + } +} diff --git a/upstream-4.x/src/Reader/Common/Manager/RowManager.php b/upstream-4.x/src/Reader/Common/Manager/RowManager.php new file mode 100644 index 0000000..a4f8080 --- /dev/null +++ b/upstream-4.x/src/Reader/Common/Manager/RowManager.php @@ -0,0 +1,51 @@ +getNumCells(); + + if (0 === $numCells) { + return; + } + + $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(Cell::fromValue(''), $cellIndex); + $needsSorting = true; + } + } + + if ($needsSorting) { + $rowCells = $row->getCells(); + ksort($rowCells); + $row->setCells($rowCells); + } + } +} diff --git a/upstream-4.x/src/Reader/Common/XMLProcessor.php b/upstream-4.x/src/Reader/Common/XMLProcessor.php new file mode 100644 index 0000000..b185ad6 --- /dev/null +++ b/upstream-4.x/src/Reader/Common/XMLProcessor.php @@ -0,0 +1,153 @@ + Registered callbacks */ + private array $callbacks = []; + + /** + * @param XMLReader $xmlReader XMLReader object + */ + public function __construct(XMLReader $xmlReader) + { + $this->xmlReader = $xmlReader; + } + + /** + * @param string $nodeName A callback may be triggered when a node with this name is read + * @param int $nodeType Type of the node [NODE_TYPE_START || NODE_TYPE_END] + * @param callable $callback Callback to execute when the read node has the given name and type + */ + public function registerCallback(string $nodeName, int $nodeType, $callback): self + { + $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 XMLProcessingException + */ + public function readUntilStopped(): void + { + 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(string $nodeName, int $nodeType): string + { + 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{reflectionMethod: ReflectionMethod, reflectionObject: object} Associative array containing the elements needed to invoke the callback using Reflection + */ + private function getInvokableCallbackData($callback): array + { + $callbackObject = $callback[0]; + $callbackMethodName = $callback[1]; + $reflectionMethod = new ReflectionMethod($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{reflectionMethod: ReflectionMethod, reflectionObject: object} 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(string $nodeNamePossiblyWithPrefix, string $nodeNameWithoutPrefix, int $nodeType): ?array + { + // 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{reflectionMethod: ReflectionMethod, reflectionObject: object} $callbackData Associative array containing data to invoke the callback using Reflection + * @param XMLReader[] $args Arguments to pass to the callback + * + * @return int Callback response + */ + private function invokeCallback(array $callbackData, array $args): int + { + $reflectionMethod = $callbackData[self::CALLBACK_REFLECTION_METHOD]; + $callbackObject = $callbackData[self::CALLBACK_REFLECTION_OBJECT]; + + return $reflectionMethod->invokeArgs($callbackObject, $args); + } +} diff --git a/upstream-4.x/src/Reader/Exception/InvalidValueException.php b/upstream-4.x/src/Reader/Exception/InvalidValueException.php new file mode 100644 index 0000000..4475e51 --- /dev/null +++ b/upstream-4.x/src/Reader/Exception/InvalidValueException.php @@ -0,0 +1,23 @@ +invalidValue = $invalidValue; + parent::__construct($message, $code, $previous); + } + + public function getInvalidValue(): string + { + return $this->invalidValue; + } +} diff --git a/upstream-4.x/src/Reader/Exception/IteratorNotRewindableException.php b/upstream-4.x/src/Reader/Exception/IteratorNotRewindableException.php new file mode 100644 index 0000000..b1d5198 --- /dev/null +++ b/upstream-4.x/src/Reader/Exception/IteratorNotRewindableException.php @@ -0,0 +1,7 @@ + ' ', + self::XML_NODE_TEXT_TAB => "\t", + self::XML_NODE_TEXT_LINE_BREAK => "\n", + ]; + + /** @var bool Whether date/time values should be returned as PHP objects or be formatted as strings */ + private bool $shouldFormatDates; + + /** @var ODS Used to unescape XML data */ + private ODS $escaper; + + /** + * @param bool $shouldFormatDates Whether date/time values should be returned as PHP objects or be formatted as strings + * @param ODS $escaper Used to unescape XML data + */ + public function __construct(bool $shouldFormatDates, ODS $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 + * + * @return bool|DateInterval|DateTimeImmutable|float|int|string The value associated with the cell, empty string if cell's type is void/undefined + * + * @throws InvalidValueException If the node value is not valid + */ + public function extractAndFormatNodeValue(DOMElement $node): bool|DateInterval|DateTimeImmutable|float|int|string + { + $cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE); + + return match ($cellType) { + self::CELL_TYPE_STRING => $this->formatStringCellValue($node), + self::CELL_TYPE_FLOAT => $this->formatFloatCellValue($node), + self::CELL_TYPE_BOOLEAN => $this->formatBooleanCellValue($node), + self::CELL_TYPE_DATE => $this->formatDateCellValue($node), + self::CELL_TYPE_TIME => $this->formatTimeCellValue($node), + self::CELL_TYPE_CURRENCY => $this->formatCurrencyCellValue($node), + self::CELL_TYPE_PERCENTAGE => $this->formatPercentageCellValue($node), + default => '', + }; + } + + /** + * Returns the cell String value. + * + * @return string The value associated with the cell + */ + private function formatStringCellValue(DOMElement $node): string + { + $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. + * + * @return float|int The value associated with the cell + */ + private function formatFloatCellValue(DOMElement $node): float|int + { + $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. + * + * @return bool The value associated with the cell + */ + private function formatBooleanCellValue(DOMElement $node): bool + { + return (bool) $node->getAttribute(self::XML_ATTRIBUTE_BOOLEAN_VALUE); + } + + /** + * Returns the cell Date value from the given node. + * + * @throws InvalidValueException If the value is not a valid date + */ + private function formatDateCellValue(DOMElement $node): DateTimeImmutable|string + { + // 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 DateTimeImmutable($nodeValue); + } catch (Exception $previous) { + throw new InvalidValueException($nodeValue, '', 0, $previous); + } + } + + return $cellValue; + } + + /** + * Returns the cell Time value from the given node. + * + * @return DateInterval|string The value associated with the cell + * + * @throws InvalidValueException If the value is not a valid time + */ + private function formatTimeCellValue(DOMElement $node): DateInterval|string + { + // 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 $previous) { + throw new InvalidValueException($nodeValue, '', 0, $previous); + } + } + + return $cellValue; + } + + /** + * Returns the cell Currency value from the given node. + * + * @return string The value associated with the cell (e.g. "100 USD" or "9.99 EUR") + */ + private function formatCurrencyCellValue(DOMElement $node): string + { + $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. + * + * @return float|int The value associated with the cell + */ + private function formatPercentageCellValue(DOMElement $node): float|int + { + // percentages are formatted like floats + return $this->formatFloatCellValue($node); + } + + private function extractTextValueFromNode(DOMNode $pNode): string + { + $textValue = ''; + + foreach ($pNode->childNodes as $childNode) { + if ($childNode instanceof DOMText) { + $textValue .= $childNode->nodeValue; + } elseif ($this->isWhitespaceNode($childNode->nodeName) && $childNode instanceof DOMElement) { + $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: + * - + * - + * - . + */ + private function isWhitespaceNode(string $nodeName): bool + { + 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(DOMElement $node): string + { + $countAttribute = $node->getAttribute(self::XML_ATTRIBUTE_C); // only defined for "" + $numWhitespaces = '' !== $countAttribute ? (int) $countAttribute : 1; + + return str_repeat(self::WHITESPACE_XML_NODES[$node->nodeName], $numWhitespaces); + } +} diff --git a/upstream-4.x/src/Reader/ODS/Helper/SettingsHelper.php b/upstream-4.x/src/Reader/ODS/Helper/SettingsHelper.php new file mode 100644 index 0000000..a3c9b9a --- /dev/null +++ b/upstream-4.x/src/Reader/ODS/Helper/SettingsHelper.php @@ -0,0 +1,54 @@ +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) { // @codeCoverageIgnore + // do nothing + } + + $xmlReader->close(); + + return $activeSheetName; + } +} diff --git a/upstream-4.x/src/Reader/ODS/Options.php b/upstream-4.x/src/Reader/ODS/Options.php new file mode 100644 index 0000000..f7641ea --- /dev/null +++ b/upstream-4.x/src/Reader/ODS/Options.php @@ -0,0 +1,11 @@ + + */ +final class Reader extends AbstractReader +{ + private ZipArchive $zip; + + private readonly Options $options; + + /** @var SheetIterator To iterator over the ODS sheets */ + private SheetIterator $sheetIterator; + + public function __construct(?Options $options = null) + { + $this->options = $options ?? new Options(); + } + + public function getSheetIterator(): SheetIterator + { + $this->ensureStreamOpened(); + + return $this->sheetIterator; + } + + /** + * Returns whether stream wrappers are supported. + */ + protected function doesSupportStreamWrapper(): bool + { + return false; + } + + /** + * Opens the file at the given file path to make it ready to be read. + * + * @param string $filePath Path of the file to be read + * + * @throws IOException If the file at the given path or its content cannot be read + * @throws NoSheetsFoundException If there are no sheets in the file + */ + protected function openReader(string $filePath): void + { + $this->zip = new ZipArchive(); + + if (true !== $this->zip->open($filePath)) { + throw new IOException("Could not open {$filePath} for reading."); + } + + $this->sheetIterator = new SheetIterator($filePath, $this->options, new ODS(), new SettingsHelper()); + } + + /** + * Closes the reader. To be used after reading the file. + */ + protected function closeReader(): void + { + $this->zip->close(); + } +} diff --git a/upstream-4.x/src/Reader/ODS/RowIterator.php b/upstream-4.x/src/Reader/ODS/RowIterator.php new file mode 100644 index 0000000..ca6c2bd --- /dev/null +++ b/upstream-4.x/src/Reader/ODS/RowIterator.php @@ -0,0 +1,343 @@ +cellValueFormatter = $cellValueFormatter; + + // 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']); + $this->options = $options; + } + + /** + * 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 IteratorNotRewindableException If the iterator is rewound more than once + */ + 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 + */ + 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 SharedStringNotFoundException If a shared string was not found + * @throws IOException If unable to read the sheet data XML + */ + 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 + */ + public function current(): Row + { + return $this->rowBuffer; + } + + /** + * Return the key of the current element. + * + * @see http://php.net/manual/en/iterator.key.php + */ + public function key(): int + { + return $this->lastRowIndexProcessed; + } + + /** + * Returns whether we need data for the next row to be processed. + * We DO need to read data if: + * - we have not read any rows yet + * OR + * - the next row to be processed immediately follows the last read row. + * + * @return bool whether we need data for the next row to be processed + */ + private function doesNeedDataForNextRowToBeProcessed(): bool + { + $hasReadAtLeastOneRow = (0 !== $this->lastRowIndexProcessed); + + return + !$hasReadAtLeastOneRow + || $this->lastRowIndexProcessed === $this->nextRowIndexToBeProcessed - 1; + } + + /** + * @throws SharedStringNotFoundException If a shared string was not found + * @throws IOException If unable to read the sheet data XML + */ + private function readDataForNextRow(): void + { + $this->currentlyProcessedRow = new Row([], null); + + $this->xmlProcessor->readUntilStopped(); + + $this->rowBuffer = $this->currentlyProcessedRow; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processRowStartingNode(XMLReader $xmlReader): int + { + // Reset data from current row + $this->hasAlreadyReadOneCellInCurrentRow = false; + $this->lastProcessedCell = null; + $this->numColumnsRepeated = 1; + $this->numRowsRepeated = $this->getNumRowsRepeatedForCurrentNode($xmlReader); + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processCellStartingNode(XMLReader $xmlReader): int + { + $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 + */ + private function processRowEndingNode(): int + { + $isEmptyRow = $this->isEmptyRow($this->currentlyProcessedRow, $this->lastProcessedCell); + + // if the fetched row is empty and we don't want to preserve it... + if (!$this->options->SHOULD_PRESERVE_EMPTY_ROWS && $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 + */ + private function processTableEndingNode(): int + { + // The closing "" marks the end of the file + $this->hasReachedEndOfFile = true; + + return XMLProcessor::PROCESSING_STOP; + } + + /** + * @param 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 + */ + private function getNumRowsRepeatedForCurrentNode(XMLReader $xmlReader): int + { + $numRowsRepeated = $xmlReader->getAttribute(self::XML_ATTRIBUTE_NUM_ROWS_REPEATED); + + return (null !== $numRowsRepeated) ? (int) $numRowsRepeated : 1; + } + + /** + * @param 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 + */ + private function getNumColumnsRepeatedForCurrentNode(XMLReader $xmlReader): int + { + $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. + * + * @return Cell The cell set with the associated with the cell + */ + private function getCell(DOMElement $node): Cell + { + try { + $cellValue = $this->cellValueFormatter->extractAndFormatNodeValue($node); + $cell = Cell::fromValue($cellValue); + } catch (InvalidValueException $exception) { + $cell = new Cell\ErrorCell($exception->getInvalidValue(), null); + } + + 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 null|Cell $lastReadCell The last read cell + * + * @return bool Whether the row is empty + */ + private function isEmptyRow(Row $currentRow, ?Cell $lastReadCell): bool + { + return + $currentRow->isEmpty() + && (null === $lastReadCell || $lastReadCell instanceof Cell\EmptyCell); + } +} diff --git a/upstream-4.x/src/Reader/ODS/Sheet.php b/upstream-4.x/src/Reader/ODS/Sheet.php new file mode 100644 index 0000000..718b359 --- /dev/null +++ b/upstream-4.x/src/Reader/ODS/Sheet.php @@ -0,0 +1,81 @@ + + */ +final readonly class Sheet implements SheetWithVisibilityInterface +{ + /** @var RowIterator To iterate over sheet's rows */ + private RowIterator $rowIterator; + + /** @var int Index of the sheet, based on order in the workbook (zero-based) */ + private int $index; + + /** @var string Name of the sheet */ + private string $name; + + /** @var bool Whether the sheet was the active one */ + private bool $isActive; + + /** @var bool Whether the sheet is visible */ + private bool $isVisible; + + /** + * @param RowIterator $rowIterator The corresponding row iterator + * @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 + */ + public function __construct(RowIterator $rowIterator, int $sheetIndex, string $sheetName, bool $isSheetActive, bool $isSheetVisible) + { + $this->rowIterator = $rowIterator; + $this->index = $sheetIndex; + $this->name = $sheetName; + $this->isActive = $isSheetActive; + $this->isVisible = $isSheetVisible; + } + + public function getRowIterator(): RowIterator + { + return $this->rowIterator; + } + + /** + * @return int Index of the sheet, based on order in the workbook (zero-based) + */ + public function getIndex(): int + { + return $this->index; + } + + /** + * @return string Name of the sheet + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return bool Whether the sheet was defined as active + */ + public function isActive(): bool + { + return $this->isActive; + } + + /** + * @return bool Whether the sheet is visible + */ + public function isVisible(): bool + { + return $this->isVisible; + } +} diff --git a/upstream-4.x/src/Reader/ODS/SheetIterator.php b/upstream-4.x/src/Reader/ODS/SheetIterator.php new file mode 100644 index 0000000..a6b34de --- /dev/null +++ b/upstream-4.x/src/Reader/ODS/SheetIterator.php @@ -0,0 +1,228 @@ + + */ +final class SheetIterator implements SheetIteratorInterface +{ + public const CONTENT_XML_FILE_PATH = 'content.xml'; + + public const XML_STYLE_NAMESPACE = 'urn:oasis:names:tc:opendocument:xmlns:style:1.0'; + + /** + * Definition of XML nodes name and attribute used to parse sheet data. + */ + public const XML_NODE_AUTOMATIC_STYLES = 'office:automatic-styles'; + public const XML_NODE_STYLE_TABLE_PROPERTIES = 'table-properties'; + public const XML_NODE_TABLE = 'table:table'; + public const XML_ATTRIBUTE_STYLE_NAME = 'style:name'; + public const XML_ATTRIBUTE_TABLE_NAME = 'table:name'; + public const XML_ATTRIBUTE_TABLE_STYLE_NAME = 'table:style-name'; + public const XML_ATTRIBUTE_TABLE_DISPLAY = 'table:display'; + + /** @var string Path of the file to be read */ + private readonly string $filePath; + + private readonly Options $options; + + /** @var XMLReader The XMLReader object that will help read sheet's XML data */ + private readonly XMLReader $xmlReader; + + /** @var ODS Used to unescape XML data */ + private readonly ODS $escaper; + + /** @var bool Whether there are still at least a sheet to be read */ + private bool $hasFoundSheet; + + /** @var int The index of the sheet being read (zero-based) */ + private int $currentSheetIndex; + + /** @var string The name of the sheet that was defined as active */ + private readonly ?string $activeSheetName; + + /** @var array Associative array [STYLE_NAME] => [IS_SHEET_VISIBLE] */ + private array $sheetsVisibility; + + public function __construct( + string $filePath, + Options $options, + ODS $escaper, + SettingsHelper $settingsHelper + ) { + $this->filePath = $filePath; + $this->options = $options; + $this->xmlReader = new XMLReader(); + $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 IOException If unable to open the XML file containing sheets' data + */ + public function rewind(): void + { + $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 + */ + public function valid(): bool + { + $valid = $this->hasFoundSheet; + if (!$valid) { + $this->xmlReader->close(); + } + + return $valid; + } + + /** + * Move forward to next element. + * + * @see http://php.net/manual/en/iterator.next.php + */ + public function next(): void + { + $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 + */ + public function current(): Sheet + { + $escapedSheetName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_NAME); + \assert(null !== $escapedSheetName); + $sheetName = $this->escaper->unescape($escapedSheetName); + + $isSheetActive = $this->isSheetActive($sheetName, $this->currentSheetIndex, $this->activeSheetName); + + $sheetStyleName = $this->xmlReader->getAttribute(self::XML_ATTRIBUTE_TABLE_STYLE_NAME); + \assert(null !== $sheetStyleName); + $isSheetVisible = $this->isSheetVisible($sheetStyleName); + + return new Sheet( + new RowIterator( + $this->options, + new CellValueFormatter($this->options->SHOULD_FORMAT_DATES, new ODS()), + new XMLProcessor($this->xmlReader) + ), + $this->currentSheetIndex, + $sheetName, + $isSheetActive, + $isSheetVisible + ); + } + + /** + * Return the key of the current element. + * + * @see http://php.net/manual/en/iterator.key.php + */ + public function key(): int + { + return $this->currentSheetIndex + 1; + } + + /** + * Extracts the visibility of the sheets. + * + * @return array Associative array [STYLE_NAME] => [IS_SHEET_VISIBLE] + */ + private function readSheetsVisibility(): array + { + $sheetsVisibility = []; + + $this->xmlReader->readUntilNodeFound(self::XML_NODE_AUTOMATIC_STYLES); + + $automaticStylesNode = $this->xmlReader->expand(); + \assert($automaticStylesNode instanceof DOMElement); + + $tableStyleNodes = $automaticStylesNode->getElementsByTagNameNS(self::XML_STYLE_NAMESPACE, self::XML_NODE_STYLE_TABLE_PROPERTIES); + + foreach ($tableStyleNodes as $tableStyleNode) { + $isSheetVisible = ('false' !== $tableStyleNode->getAttribute(self::XML_ATTRIBUTE_TABLE_DISPLAY)); + + $parentStyleNode = $tableStyleNode->parentNode; + \assert($parentStyleNode instanceof DOMElement); + $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(string $sheetName, int $sheetIndex, ?string $activeSheetName): bool + { + // 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(string $sheetStyleName): bool + { + return $this->sheetsVisibility[$sheetStyleName] ?? + true; + } +} diff --git a/upstream-4.x/src/Reader/ReaderInterface.php b/upstream-4.x/src/Reader/ReaderInterface.php new file mode 100644 index 0000000..691bdd6 --- /dev/null +++ b/upstream-4.x/src/Reader/ReaderInterface.php @@ -0,0 +1,37 @@ + + */ +interface RowIteratorInterface extends Iterator +{ + public function current(): ?Row; +} diff --git a/upstream-4.x/src/Reader/SheetInterface.php b/upstream-4.x/src/Reader/SheetInterface.php new file mode 100644 index 0000000..ebd41e3 --- /dev/null +++ b/upstream-4.x/src/Reader/SheetInterface.php @@ -0,0 +1,31 @@ + + */ +interface SheetIteratorInterface extends Iterator +{ + /** + * @return T of SheetInterface + */ + public function current(): SheetInterface; +} diff --git a/upstream-4.x/src/Reader/SheetWithMergeCellsInterface.php b/upstream-4.x/src/Reader/SheetWithMergeCellsInterface.php new file mode 100644 index 0000000..2367e2c --- /dev/null +++ b/upstream-4.x/src/Reader/SheetWithMergeCellsInterface.php @@ -0,0 +1,18 @@ + + */ +interface SheetWithMergeCellsInterface extends SheetInterface +{ + /** + * @return list Merge cells list ["C7:E7", "A9:D10"] + */ + public function getMergeCells(): array; +} diff --git a/upstream-4.x/src/Reader/SheetWithVisibilityInterface.php b/upstream-4.x/src/Reader/SheetWithVisibilityInterface.php new file mode 100644 index 0000000..e456604 --- /dev/null +++ b/upstream-4.x/src/Reader/SheetWithVisibilityInterface.php @@ -0,0 +1,18 @@ + + */ +interface SheetWithVisibilityInterface extends SheetInterface +{ + /** + * @return bool Whether the sheet is visible + */ + public function isVisible(): bool; +} diff --git a/upstream-4.x/src/Reader/Wrapper/XMLInternalErrorsHelper.php b/upstream-4.x/src/Reader/Wrapper/XMLInternalErrorsHelper.php new file mode 100644 index 0000000..76ccc20 --- /dev/null +++ b/upstream-4.x/src/Reader/Wrapper/XMLInternalErrorsHelper.php @@ -0,0 +1,77 @@ +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 XMLProcessingException + */ + private function resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured(): void + { + if ($this->hasXMLErrorOccured()) { + $this->resetXMLInternalErrorsSetting(); + + throw new XMLProcessingException($this->getLastXMLErrorMessage()); + } + + $this->resetXMLInternalErrorsSetting(); + } + + private function resetXMLInternalErrorsSetting(): void + { + 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(): bool + { + return false !== libxml_get_last_error(); + } + + /** + * Returns the error message for the last XML error that occured. + * + * @see libxml_get_last_error + * + * @return string Last XML error message or null if no error + */ + private function getLastXMLErrorMessage(): string + { + $errorMessage = ''; + $error = libxml_get_last_error(); + + if (false !== $error) { + $errorMessage = trim($error->message); + } + + return $errorMessage; + } +} diff --git a/upstream-4.x/src/Reader/Wrapper/XMLReader.php b/upstream-4.x/src/Reader/Wrapper/XMLReader.php new file mode 100644 index 0000000..ca375af --- /dev/null +++ b/upstream-4.x/src/Reader/Wrapper/XMLReader.php @@ -0,0 +1,187 @@ +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. + 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(string $zipFilePath, string $fileInsideZipPath): string + { + // The file path should not start with a '/', otherwise it won't be found + $fileInsideZipPathWithoutLeadingSlash = ltrim($fileInsideZipPath, '/'); + + $realpath = realpath($zipFilePath); + if (false === $realpath) { + throw new IOException("Could not open {$zipFilePath} for reading! File does not exist."); + } + + return self::ZIP_WRAPPER.$realpath.'#'.$fileInsideZipPathWithoutLeadingSlash; + } + + /** + * Move to next node in document. + * + * @see \XMLReader::read + * + * @throws XMLProcessingException If an error/warning occurred + */ + public function read(): bool + { + $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 + * + * @return bool TRUE on success or FALSE on failure + * + * @throws XMLProcessingException If an error/warning occurred + */ + public function readUntilNodeFound(string $nodeName): bool + { + 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 XMLProcessingException If an error/warning occurred + */ + public function next($localName = null): bool + { + $this->useXMLInternalErrors(); + + $wasNextSuccessful = parent::next($localName); + + $this->resetXMLInternalErrorsSettingAndThrowIfXMLErrorOccured(); + + return $wasNextSuccessful; + } + + /** + * @return bool Whether the XML Reader is currently positioned on the starting node with given name + */ + public function isPositionedOnStartingNode(string $nodeName): bool + { + return $this->isPositionedOnNode($nodeName, self::ELEMENT); + } + + /** + * @return bool Whether the XML Reader is currently positioned on the ending node with given name + */ + public function isPositionedOnEndingNode(string $nodeName): bool + { + return $this->isPositionedOnNode($nodeName, self::END_ELEMENT); + } + + /** + * @return string The name of the current node, un-prefixed + */ + public function getCurrentNodeName(): string + { + 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 + */ + private function fileExistsWithinZip(string $zipStreamURI): bool + { + $doesFileExists = false; + + $pattern = '/zip:\/\/([^#]+)#(.*)/'; + if (1 === 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; + } + + /** + * @return bool Whether the XML Reader is currently positioned on the node with given name and type + */ + private function isPositionedOnNode(string $nodeName, int $nodeType): bool + { + /** + * 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 = str_contains($nodeName, ':'); + $currentNodeName = ($hasPrefix) ? $this->name : $this->localName; + + return $this->nodeType === $nodeType && $currentNodeName === $nodeName; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Helper/CellHelper.php b/upstream-4.x/src/Reader/XLSX/Helper/CellHelper.php new file mode 100644 index 0000000..0b1fec2 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Helper/CellHelper.php @@ -0,0 +1,85 @@ + 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 InvalidArgumentException When the given cell index is invalid + */ + public static function getColumnIndexFromCellIndex(string $cellIndex): int + { + 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', ...) + */ + private static function isValidCellIndex(string $cellIndex): bool + { + return 1 === preg_match('/^[A-Z]{1,3}\d+$/', $cellIndex); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Helper/CellValueFormatter.php b/upstream-4.x/src/Reader/XLSX/Helper/CellValueFormatter.php new file mode 100644 index 0000000..596e4f6 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Helper/CellValueFormatter.php @@ -0,0 +1,344 @@ +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. + */ + public function extractAndFormatNodeValue(DOMElement $node): Cell + { + // Default cell type is "n" + $cellType = $node->getAttribute(self::XML_ATTRIBUTE_TYPE); + if ('' === $cellType) { + $cellType = self::CELL_TYPE_NUMERIC; + } + $vNodeValue = $this->getVNodeValue($node); + + $fNodeValue = $node->getElementsByTagName(self::XML_NODE_FORMULA)->item(0)?->nodeValue; + if (null !== $fNodeValue) { + $computedValue = $this->formatRawValueForCellType($cellType, $node, $vNodeValue); + + return new Cell\FormulaCell( + '='.$fNodeValue, + null, + $computedValue instanceof Cell\ErrorCell ? null : $computedValue + ); + } + + if ('' === $vNodeValue && self::CELL_TYPE_INLINE_STRING !== $cellType) { + return Cell::fromValue($vNodeValue); + } + + $rawValue = $this->formatRawValueForCellType($cellType, $node, $vNodeValue); + + if ($rawValue instanceof Cell) { + return $rawValue; + } + + return Cell::fromValue($rawValue); + } + + /** + * Returns the cell's string value from a node's nested value node. + * + * @return string The value associated with the cell + */ + private function getVNodeValue(DOMElement $node): string + { + // 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 (string) $vNode?->nodeValue; + } + + /** + * Returns the cell String value where string is inline. + * + * @return string The value associated with the cell + */ + private function formatInlineStringCellValue(DOMElement $node): string + { + // 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) { + $nodeValue = $tNodes->item($i)->nodeValue; + \assert(null !== $nodeValue); + $cellValue .= $this->escaper->unescape($nodeValue); + } + + return $cellValue; + } + + /** + * Returns the cell String value from shared-strings file using nodeValue index. + * + * @return string The value associated with the cell + */ + private function formatSharedStringCellValue(string $nodeValue): string + { + // 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. + * + * @return string The value associated with the cell + */ + private function formatStrCellValue(string $nodeValue): string + { + $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 int $cellStyleId 0 being the default style + */ + private function formatNumericCellValue(float|int|string $nodeValue, int $cellStyleId): DateInterval|DateTimeImmutable|float|int|string + { + // 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. + $formatCode = $this->styleManager->getNumberFormatCode($cellStyleId); + + if (DateIntervalFormatHelper::isDurationFormat($formatCode)) { + $cellValue = $this->formatExcelDateIntervalValue((float) $nodeValue, $formatCode); + } elseif ($this->styleManager->shouldFormatNumericValueAsDate($cellStyleId)) { + $cellValue = $this->formatExcelTimestampValue((float) $nodeValue, $cellStyleId); + } else { + $nodeIntValue = (int) $nodeValue; + $nodeFloatValue = (float) $nodeValue; + $cellValue = ((float) $nodeIntValue === $nodeFloatValue) ? $nodeIntValue : $nodeFloatValue; + } + + return $cellValue; + } + + private function formatExcelDateIntervalValue(float $nodeValue, string $excelFormat): DateInterval|string + { + $dateInterval = DateIntervalFormatHelper::createDateIntervalFromHours($nodeValue); + if ($this->shouldFormatDates) { + return DateIntervalFormatHelper::formatDateInterval($dateInterval, $excelFormat); + } + + return $dateInterval; + } + + /** + * 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. + * + * @param int $cellStyleId 0 being the default style + * + * @throws InvalidValueException If the value is not a valid timestamp + * + * @see ECMA-376 Part 1 - §18.17.4 + */ + private function formatExcelTimestampValue(float $nodeValue, int $cellStyleId): DateTimeImmutable|string + { + if (!$this->isValidTimestampValue($nodeValue)) { + throw new InvalidValueException((string) $nodeValue); + } + + return $this->formatExcelTimestampValueAsDateTimeValue($nodeValue, $cellStyleId); + } + + /** + * Returns whether the given timestamp is supported by SpreadsheetML. + * + * @see ECMA-376 Part 1 - §18.17.4 - this specifies the timestamp boundaries. + */ + private function isValidTimestampValue(float $timestampValue): bool + { + // @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 int $cellStyleId 0 being the default style + */ + private function formatExcelTimestampValueAsDateTimeValue(float $nodeValue, int $cellStyleId): DateTimeImmutable|string + { + $baseDate = $this->shouldUse1904Dates ? '1904-01-01' : '1899-12-30'; + + $daysSinceBaseDate = (int) $nodeValue; + $daysSign = '+'; + if ($daysSinceBaseDate < 0) { + $daysSinceBaseDate = abs($daysSinceBaseDate); + $daysSign = '-'; + } + $timeRemainder = fmod($nodeValue, 1); + $secondsRemainder = round($timeRemainder * self::NUM_SECONDS_IN_ONE_DAY, 0); + $secondsSign = '+'; + if ($secondsRemainder < 0) { + $secondsRemainder = abs($secondsRemainder); + $secondsSign = '-'; + } + + $dateObj = DateTimeImmutable::createFromFormat('|Y-m-d', $baseDate); + \assert(false !== $dateObj); + $dateObj = $dateObj->modify($daysSign.$daysSinceBaseDate.'days'); + \assert(false !== $dateObj); + $dateObj = $dateObj->modify($secondsSign.$secondsRemainder.'seconds'); + \assert(false !== $dateObj); + + 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. + * + * @return bool The value associated with the cell + */ + private function formatBooleanCellValue(string $nodeValue): bool + { + 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 + */ + private function formatDateCellValue(string $nodeValue): Cell\ErrorCell|DateTimeImmutable|string + { + // Mitigate thrown Exception on invalid date-time format (http://php.net/manual/en/datetime.construct.php) + try { + $cellValue = ($this->shouldFormatDates) ? $nodeValue : new DateTimeImmutable($nodeValue); + } catch (Exception) { + return new Cell\ErrorCell($nodeValue, null); + } + + return $cellValue; + } + + private function formatRawValueForCellType( + string $cellType, + DOMElement $node, + string $vNodeValue + ): bool|Cell\ErrorCell|DateInterval|DateTimeImmutable|float|int|string { + return match ($cellType) { + self::CELL_TYPE_INLINE_STRING => $this->formatInlineStringCellValue($node), + self::CELL_TYPE_SHARED_STRING => $this->formatSharedStringCellValue($vNodeValue), + self::CELL_TYPE_STR => $this->formatStrCellValue($vNodeValue), + self::CELL_TYPE_BOOLEAN => $this->formatBooleanCellValue($vNodeValue), + self::CELL_TYPE_NUMERIC => $this->formatNumericCellValue( + $vNodeValue, + (int) $node->getAttribute(self::XML_ATTRIBUTE_STYLE_ID) + ), + self::CELL_TYPE_DATE => $this->formatDateCellValue($vNodeValue), + default => new Cell\ErrorCell($vNodeValue, null), + }; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Helper/DateFormatHelper.php b/upstream-4.x/src/Reader/XLSX/Helper/DateFormatHelper.php new file mode 100644 index 0000000..5b6fba7 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Helper/DateFormatHelper.php @@ -0,0 +1,125 @@ + [ + // 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(string $excelDateFormat): string + { + // 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); + \assert(null !== $dateFormat); + + // 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('/"(.+?)"/', static function ($matches): string { + $stringToEscape = $matches[1]; + $letters = preg_split('//u', $stringToEscape, -1, PREG_SPLIT_NO_EMPTY); + \assert(false !== $letters); + + 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(string $excelDateFormat): bool + { + return false !== stripos($excelDateFormat, 'am/pm'); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Helper/DateIntervalFormatHelper.php b/upstream-4.x/src/Reader/XLSX/Helper/DateIntervalFormatHelper.php new file mode 100644 index 0000000..61d5d34 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Helper/DateIntervalFormatHelper.php @@ -0,0 +1,100 @@ + '%H', + 'h' => '%h', + 'mm' => '%I', + 'm' => '%i', + 'ss' => '%S', + 's' => '%s', + ]; + + /** + * Excel stores durations as fractions of days (24h = 1). + * + * Only fills hours/minutes/seconds because those are the only values that we can format back out again. + * Excel can also only handle those units as duration. + * PHP's DateInterval is also quite limited - it will not automatically convert unit overflow + * (60 seconds are not converted to 1 minute). + */ + public static function createDateIntervalFromHours(float $dayFractions): DateInterval + { + $time = abs($dayFractions) * 24; // convert to hours + $hours = floor($time); + $time = ($time - $hours) * 60; + $minutes = (int) floor($time); // must cast to int for type strict compare below + $time = ($time - $minutes) * 60; + $seconds = (int) round($time); // must cast to int for type strict compare below + + // Bubble up rounding gain if we ended up with 60 seconds - disadvantage of using fraction of days for small durations: + if (60 === $seconds) { + $seconds = 0; + ++$minutes; + } + if (60 === $minutes) { + $minutes = 0; + ++$hours; + } + + $interval = new DateInterval("P0DT{$hours}H{$minutes}M{$seconds}S"); + if ($dayFractions < 0) { + $interval->invert = 1; + } + + return $interval; + } + + public static function isDurationFormat(string $excelFormat): bool + { + // Only consider formats with leading brackets as valid duration formats (e.g. "[hh]:mm", "[mm]:ss", etc.): + return 1 === preg_match('/^(\[hh?](:mm(:ss)?)?|\[mm?](:ss)?|\[ss?])$/', $excelFormat); + } + + public static function toPHPDateIntervalFormat(string $excelDateFormat, string &$startUnit): string + { + $startUnitStarted = false; + $phpFormatParts = []; + $formatParts = explode(':', str_replace(['[', ']'], '', $excelDateFormat)); + foreach ($formatParts as $formatPart) { + if (false === $startUnitStarted) { + $startUnit = $formatPart; + $startUnitStarted = true; + } + $phpFormatParts[] = self::dateIntervalFormats[$formatPart]; + } + + // Add the minus sign for potential negative durations: + return '%r'.implode(':', $phpFormatParts); + } + + public static function formatDateInterval(DateInterval $dateInterval, string $excelDateFormat): string + { + $startUnit = ''; + $phpFormat = self::toPHPDateIntervalFormat($excelDateFormat, $startUnit); + + // We have to move the hours to minutes or hours+minutes to seconds if the format in Excel did the same: + $startUnit = $startUnit[0]; // only take the first char + $dateIntervalClone = clone $dateInterval; + if ('m' === $startUnit) { + $dateIntervalClone->i = $dateIntervalClone->i + $dateIntervalClone->h * 60; + $dateIntervalClone->h = 0; + } elseif ('s' === $startUnit) { + $dateIntervalClone->s = $dateIntervalClone->s + $dateIntervalClone->i * 60 + $dateIntervalClone->h * 3600; + $dateIntervalClone->i = 0; + $dateIntervalClone->h = 0; + } + + return $dateIntervalClone->format($phpFormat); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php new file mode 100644 index 0000000..65ca6bc --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactory.php @@ -0,0 +1,103 @@ + 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; + + private MemoryLimit $memoryLimit; + + public function __construct(MemoryLimit $memoryLimit) + { + $this->memoryLimit = $memoryLimit; + } + + /** + * 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 + * + * @return CachingStrategyInterface The best caching strategy + */ + public function createBestCachingStrategy(?int $sharedStringsUniqueCount, string $tempFolder): CachingStrategyInterface + { + if ($this->isInMemoryStrategyUsageSafe($sharedStringsUniqueCount)) { + return new InMemoryStrategy($sharedStringsUniqueCount); + } + + return new FileBasedStrategy($tempFolder, self::MAX_NUM_STRINGS_PER_TEMP_FILE); + } + + /** + * 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) + */ + private function isInMemoryStrategyUsageSafe(?int $sharedStringsUniqueCount): bool + { + // if the number of shared strings in unknown, do not use "in memory" strategy + if (null === $sharedStringsUniqueCount) { + return false; + } + + $memoryAvailable = $this->memoryLimit->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; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactoryInterface.php b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactoryInterface.php new file mode 100644 index 0000000..a506da7 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/CachingStrategyFactoryInterface.php @@ -0,0 +1,19 @@ +fileSystemHelper = new FileSystemHelper($tempFolder); + $this->tempFolder = $this->fileSystemHelper->createFolder($tempFolder, uniqid('sharedstrings')); + + $this->maxNumStringsPerTempFile = $maxNumStringsPerTempFile; + } + + /** + * 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(string $sharedString, int $sharedStringIndex): void + { + $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex); + + if ($this->writeMemoryTempFilePath !== $tempFilePath) { + if (null !== $this->tempFilePointer) { + fclose($this->tempFilePointer); + } + $resource = fopen($tempFilePath, 'w'); + \assert(false !== $resource); + $this->tempFilePointer = $resource; + $this->writeMemoryTempFilePath = $tempFilePath; + } + + // 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); + + 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(): void + { + // close pointer to the last temp file that was written + if (null !== $this->tempFilePointer) { + $this->writeMemoryTempFilePath = ''; + 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 + * + * @return string The shared string at the given index + * + * @throws SharedStringNotFoundException If no shared string found for the given index + */ + public function getStringAtIndex(int $sharedStringIndex): string + { + $tempFilePath = $this->getSharedStringTempFilePath($sharedStringIndex); + $indexInFile = $sharedStringIndex % $this->maxNumStringsPerTempFile; + + if ($this->readMemoryTempFilePath !== $tempFilePath) { + $contents = @file_get_contents($tempFilePath); + if (false === $contents) { + throw new SharedStringNotFoundException("Shared string temp file could not be read: {$tempFilePath} ; for index: {$sharedStringIndex}"); + } + $this->inMemoryTempFileContents = explode(PHP_EOL, $contents); + $this->readMemoryTempFilePath = $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(): void + { + $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 + */ + private function getSharedStringTempFilePath(int $sharedStringIndex): string + { + $numTempFile = (int) ($sharedStringIndex / $this->maxNumStringsPerTempFile); + + return $this->tempFolder.\DIRECTORY_SEPARATOR.'sharedstrings'.$numTempFile; + } + + /** + * Escapes the line feed characters (\n). + */ + private function escapeLineFeed(string $unescapedString): string + { + return str_replace("\n", self::ESCAPED_LINE_FEED_CHARACTER, $unescapedString); + } + + /** + * Unescapes the line feed characters (\n). + */ + private function unescapeLineFeed(string $escapedString): string + { + return str_replace(self::ESCAPED_LINE_FEED_CHARACTER, "\n", $escapedString); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php new file mode 100644 index 0000000..a3bcffc --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/InMemoryStrategy.php @@ -0,0 +1,81 @@ + Array used to cache the shared strings */ + private SplFixedArray $inMemoryCache; + + /** @var bool Whether the cache has been closed */ + private bool $isCacheClosed = false; + + /** + * @param int $sharedStringsUniqueCount Number of unique shared strings + */ + public function __construct(int $sharedStringsUniqueCount) + { + $this->inMemoryCache = new SplFixedArray($sharedStringsUniqueCount); + } + + /** + * 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(string $sharedString, int $sharedStringIndex): void + { + 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(): void + { + $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 + * + * @return string The shared string at the given index + * + * @throws SharedStringNotFoundException If no shared string found for the given index + */ + public function getStringAtIndex(int $sharedStringIndex): string + { + try { + return $this->inMemoryCache->offsetGet($sharedStringIndex); + } catch (RuntimeException) { + throw new SharedStringNotFoundException("Shared string not found for index: {$sharedStringIndex}"); + } + } + + /** + * Destroys the cache, freeing memory and removing any created artifacts. + */ + public function clearCache(): void + { + $this->inMemoryCache = new SplFixedArray(0); + $this->isCacheClosed = false; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/MemoryLimit.php b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/MemoryLimit.php new file mode 100644 index 0000000..a770250 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsCaching/MemoryLimit.php @@ -0,0 +1,50 @@ +memoryLimit = $memoryLimit; + } + + /** + * Returns the PHP "memory_limit" in Kilobytes. + */ + public function getMemoryLimitInKB(): float + { + $memoryLimitFormatted = strtolower(trim($this->memoryLimit)); + + // No memory limit + if ('-1' === $memoryLimitFormatted) { + return -1; + } + + if (1 === 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; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsManager.php b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsManager.php new file mode 100644 index 0000000..5a93b01 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SharedStringsManager.php @@ -0,0 +1,241 @@ +filePath = $filePath; + $this->options = $options; + $this->workbookRelationshipsManager = $workbookRelationshipsManager; + $this->cachingStrategyFactory = $cachingStrategyFactory; + } + + /** + * Returns whether the XLSX file contains a shared strings XML file. + */ + public function hasSharedStrings(): bool + { + 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 IOException If shared strings XML file can't be read + */ + public function extractSharedStrings(): void + { + $sharedStringsXMLFilePath = $this->workbookRelationshipsManager->getSharedStringsXMLFilePath(); + $xmlReader = new XMLReader(); + $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 + * + * @return string The shared string at the given index + * + * @throws SharedStringNotFoundException If no shared string found for the given index + */ + public function getStringAtIndex(int $sharedStringIndex): string + { + return $this->cachingStrategy->getStringAtIndex($sharedStringIndex); + } + + /** + * Destroys the cache, freeing memory and removing any created artifacts. + */ + public function cleanup(): void + { + if (isset($this->cachingStrategy)) { + $this->cachingStrategy->clearCache(); + } + } + + /** + * Returns the shared strings unique count, as specified in tag. + * + * @param XMLReader $xmlReader XMLReader instance + * + * @return null|int Number of unique shared strings in the sharedStrings.xml file + * + * @throws IOException If sharedStrings.xml is invalid and can't be read + */ + private function getSharedStringsUniqueCount(XMLReader $xmlReader): ?int + { + $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) + */ + private function getBestSharedStringsCachingStrategy(?int $sharedStringsUniqueCount): CachingStrategyInterface + { + return $this->cachingStrategyFactory + ->createBestCachingStrategy($sharedStringsUniqueCount, $this->options->getTempFolder()) + ; + } + + /** + * Processes the shared strings item XML node which the given XML reader is positioned on. + * + * @param XMLReader $xmlReader XML Reader positioned on a "" node + * @param int $sharedStringIndex Index of the processed shared strings item + */ + private function processSharedStringsItem(XMLReader $xmlReader, int $sharedStringIndex): void + { + $sharedStringValue = ''; + + // NOTE: expand() will automatically decode all XML entities of the child nodes + $siNode = $xmlReader->expand(); + \assert($siNode instanceof DOMElement); + $textNodes = $siNode->getElementsByTagName(self::XML_NODE_T); + + foreach ($textNodes as $textNode) { + if ($this->shouldExtractTextNodeValue($textNode)) { + $textNodeValue = $textNode->nodeValue; + \assert(null !== $textNodeValue); + $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 + */ + private function shouldExtractTextNodeValue(DOMElement $textNode): bool + { + $parentNode = $textNode->parentNode; + \assert(null !== $parentNode); + $parentTagName = $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 + */ + private function shouldPreserveWhitespace(DOMElement $textNode): bool + { + $spaceValue = $textNode->getAttribute(self::XML_ATTRIBUTE_XML_SPACE); + + return self::XML_ATTRIBUTE_VALUE_PRESERVE === $spaceValue; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/SheetManager.php b/upstream-4.x/src/Reader/XLSX/Manager/SheetManager.php new file mode 100644 index 0000000..8dffd22 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/SheetManager.php @@ -0,0 +1,295 @@ +filePath = $filePath; + $this->options = $options; + $this->sharedStringsManager = $sharedStringsManager; + $this->escaper = $escaper; + } + + /** + * 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(): array + { + $this->sheets = []; + $this->currentSheetIndex = 0; + $this->activeSheetIndex = 0; // By default, the first sheet is active + + $xmlReader = new XMLReader(); + $xmlProcessor = new XMLProcessor($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 XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processWorkbookPropertiesStartingNode(XMLReader $xmlReader): int + { + // 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->options->SHOULD_USE_1904_DATES = $shouldUse1904Dates; + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processWorkbookViewStartingNode(XMLReader $xmlReader): int + { + // 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 XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processSheetStartingNode(XMLReader $xmlReader): int + { + $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 + */ + private function processSheetsEndingNode(): int + { + 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 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 Sheet Sheet instance + */ + private function getSheetFromSheetXMLNode(XMLReader $xmlReaderOnSheetNode, int $sheetIndexZeroBased, bool $isSheetActive): Sheet + { + $sheetId = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_R_ID); + \assert(null !== $sheetId); + + $sheetState = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_STATE); + $isSheetVisible = (self::SHEET_STATE_HIDDEN !== $sheetState); + + $escapedSheetName = $xmlReaderOnSheetNode->getAttribute(self::XML_ATTRIBUTE_NAME); + \assert(null !== $escapedSheetName); + $sheetName = $this->escaper->unescape($escapedSheetName); + + $sheetDataXMLFilePath = $this->getSheetDataXMLFilePathForSheetId($sheetId); + + $mergeCells = []; + if ($this->options->SHOULD_LOAD_MERGE_CELLS) { + $mergeCells = (new SheetMergeCellsReader( + $this->filePath, + $sheetDataXMLFilePath, + $xmlReader = new XMLReader(), + new XMLProcessor($xmlReader) + ))->getMergeCells(); + } + + return new Sheet( + $this->createRowIterator($this->filePath, $sheetDataXMLFilePath, $this->options, $this->sharedStringsManager), + $this->createSheetHeaderReader($this->filePath, $sheetDataXMLFilePath), + $sheetIndexZeroBased, + $sheetName, + $isSheetActive, + $isSheetVisible, + $mergeCells + ); + } + + /** + * @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 + */ + private function getSheetDataXMLFilePathForSheetId(string $sheetId): string + { + $sheetDataXMLFilePath = ''; + + // find the file path of the sheet, by looking at the "workbook.xml.res" file + $xmlReader = new XMLReader(); + if ($xmlReader->openFileInZip($this->filePath, self::WORKBOOK_XML_RELS_FILE_PATH)) { + while ($xmlReader->read()) { + if ($xmlReader->isPositionedOnStartingNode(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); + \assert(null !== $sheetDataXMLFilePath); + + // sometimes, the sheet data file path already contains "/xl/"... + if (!str_starts_with($sheetDataXMLFilePath, '/xl/')) { + $sheetDataXMLFilePath = '/xl/'.$sheetDataXMLFilePath; + + break; + } + } + } + } + + $xmlReader->close(); + } + + return $sheetDataXMLFilePath; + } + + private function createRowIterator( + string $filePath, + string $sheetDataXMLFilePath, + Options $options, + SharedStringsManager $sharedStringsManager + ): RowIterator { + $workbookRelationshipsManager = new WorkbookRelationshipsManager($filePath); + $styleManager = new StyleManager( + $filePath, + $workbookRelationshipsManager->hasStylesXMLFile() + ? $workbookRelationshipsManager->getStylesXMLFilePath() + : null + ); + + $cellValueFormatter = new CellValueFormatter( + $sharedStringsManager, + $styleManager, + $options->SHOULD_FORMAT_DATES, + $options->SHOULD_USE_1904_DATES, + new XLSX() + ); + + return new RowIterator( + $filePath, + $sheetDataXMLFilePath, + $options->SHOULD_PRESERVE_EMPTY_ROWS, + $xmlReader = new XMLReader(), + new XMLProcessor($xmlReader), + $cellValueFormatter, + new RowManager() + ); + } + + private function createSheetHeaderReader( + string $filePath, + string $sheetDataXMLFilePath + ): SheetHeaderReader { + $xmlReader = new XMLReader(); + + return new SheetHeaderReader( + $filePath, + $sheetDataXMLFilePath, + $xmlReader, + new XMLProcessor($xmlReader) + ); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Manager/StyleManager.php b/upstream-4.x/src/Reader/XLSX/Manager/StyleManager.php new file mode 100644 index 0000000..b28d201 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Manager/StyleManager.php @@ -0,0 +1,325 @@ + '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 */ + private readonly string $filePath; + + /** @var null|string Path of the styles XML file */ + private readonly ?string $stylesXMLFilePath; + + /** @var array Array containing a mapping NUM_FMT_ID => FORMAT_CODE */ + private array $customNumberFormats; + + /** @var array> Array containing a mapping STYLE_ID => [STYLE_ATTRIBUTES] */ + private array $stylesAttributes; + + /** @var array Cache containing a mapping NUM_FMT_ID => IS_DATE_FORMAT. Used to avoid lots of recalculations */ + private array $numFmtIdToIsDateFormatCache = []; + + /** + * @param string $filePath Path of the XLSX file being read + */ + public function __construct(string $filePath, ?string $stylesXMLFilePath) + { + $this->filePath = $filePath; + $this->stylesXMLFilePath = $stylesXMLFilePath; + } + + public function shouldFormatNumericValueAsDate(int $styleId): bool + { + if (null === $this->stylesXMLFilePath) { + 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); + } + + public function getNumberFormatCode(int $styleId): string + { + if (null === $this->stylesXMLFilePath) { + return ''; + } + + $stylesAttributes = $this->getStylesAttributes(); + $styleAttributes = $stylesAttributes[$styleId]; + $numFmtId = $styleAttributes[self::XML_ATTRIBUTE_NUM_FMT_ID]; + \assert(\is_int($numFmtId)); + + if ($this->isNumFmtIdBuiltInDateFormat($numFmtId)) { + $numberFormatCode = self::builtinNumFmtIdToNumFormatMapping[$numFmtId]; + } else { + $customNumberFormats = $this->getCustomNumberFormats(); + $numberFormatCode = $customNumberFormats[$numFmtId] ?? ''; + } + + return $numberFormatCode; + } + + /** + * @return array The custom number formats + */ + protected function getCustomNumberFormats(): array + { + if (!isset($this->customNumberFormats)) { + $this->extractRelevantInfo(); + } + + return $this->customNumberFormats; + } + + /** + * @return array> The styles attributes + */ + protected function getStylesAttributes(): array + { + if (!isset($this->stylesAttributes)) { + $this->extractRelevantInfo(); + } + + return $this->stylesAttributes; + } + + /** + * Reads the styles.xml file and extract the relevant information from the file. + */ + private function extractRelevantInfo(): void + { + $this->customNumberFormats = []; + $this->stylesAttributes = []; + + $xmlReader = new XMLReader(); + + if ($xmlReader->openFileInZip($this->filePath, $this->stylesXMLFilePath)) { + while ($xmlReader->read()) { + if ($xmlReader->isPositionedOnStartingNode(self::XML_NODE_NUM_FMTS) + && '0' !== $xmlReader->getAttribute(self::XML_ATTRIBUTE_COUNT)) { + $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 XMLReader $xmlReader XML Reader positioned on the "numFmts" node + */ + private function extractNumberFormats(XMLReader $xmlReader): void + { + 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); + \assert(null !== $formatCode); + $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 XMLReader $xmlReader XML Reader positioned on the "cellXfs" node + */ + private function extractStyleAttributes(XMLReader $xmlReader): void + { + 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; + } + } + } + + /** + * @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 + */ + private function doesStyleIndicateDate(array $styleAttributes): bool + { + $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 || !\is_int($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. + * + * @return bool Whether the number format ID indicates that the number is a date + */ + private function doesNumFmtIdIndicateDate(int $numFmtId): bool + { + if (!isset($this->numFmtIdToIsDateFormatCache[$numFmtId])) { + $formatCode = $this->getFormatCodeForNumFmtId($numFmtId); + + $this->numFmtIdToIsDateFormatCache[$numFmtId] = ( + $this->isNumFmtIdBuiltInDateFormat($numFmtId) + || $this->isFormatCodeCustomDateFormat($formatCode) + ); + } + + return $this->numFmtIdToIsDateFormatCache[$numFmtId]; + } + + /** + * @return null|string The custom number format or NULL if none defined for the given numFmtId + */ + private function getFormatCodeForNumFmtId(int $numFmtId): ?string + { + $customNumberFormats = $this->getCustomNumberFormats(); + + // Using isset here because it is way faster than array_key_exists... + return $customNumberFormats[$numFmtId] ?? null; + } + + /** + * @return bool Whether the number format ID indicates that the number is a date + */ + private function isNumFmtIdBuiltInDateFormat(int $numFmtId): bool + { + return \array_key_exists($numFmtId, self::builtinNumFmtIdToNumFormatMapping); + } + + /** + * @return bool Whether the given format code indicates that the number is a date + */ + private function isFormatCodeCustomDateFormat(?string $formatCode): bool + { + // 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); + } + + /** + * @return bool Whether the given format code matches a date format pattern + */ + private function isFormatCodeMatchingDateFormatPattern(string $formatCode): bool + { + // Remove extra formatting (what's between [ ], the brackets should not be preceded by a "\") + $pattern = '((? Cache of the already read workbook relationships: [TYPE] => [FILE_NAME] */ + private array $cachedWorkbookRelationships; + + /** + * @param string $filePath Path of the XLSX file being read + */ + public function __construct(string $filePath) + { + $this->filePath = $filePath; + } + + /** + * @return string The path of the shared string XML file + */ + public function getSharedStringsXMLFilePath(): string + { + $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 = str_contains($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(): bool + { + $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(): bool + { + $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(): string + { + $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 = str_contains($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. + * + * @return array + * + * @throws IOException If workbook.xml.rels can't be read + */ + private function getWorkbookRelationships(): array + { + if (!isset($this->cachedWorkbookRelationships)) { + $xmlReader = new XMLReader(); + + 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. + */ + private function processWorkbookRelationship(XMLReader $xmlReader): void + { + $type = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TYPE); + $target = $xmlReader->getAttribute(self::XML_ATTRIBUTE_TARGET); + \assert(null !== $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-4.x/src/Reader/XLSX/Options.php b/upstream-4.x/src/Reader/XLSX/Options.php new file mode 100644 index 0000000..636d2a4 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Options.php @@ -0,0 +1,17 @@ + + */ +final class Reader extends AbstractReader +{ + private ZipArchive $zip; + + /** @var SharedStringsManager Manages shared strings */ + private SharedStringsManager $sharedStringsManager; + + /** @var SheetIterator To iterator over the XLSX sheets */ + private SheetIterator $sheetIterator; + + private readonly Options $options; + private readonly CachingStrategyFactoryInterface $cachingStrategyFactory; + + public function __construct( + ?Options $options = null, + ?CachingStrategyFactoryInterface $cachingStrategyFactory = null + ) { + $this->options = $options ?? new Options(); + + if (null === $cachingStrategyFactory) { + $memoryLimit = \ini_get('memory_limit'); + $cachingStrategyFactory = new CachingStrategyFactory(new MemoryLimit($memoryLimit)); + } + $this->cachingStrategyFactory = $cachingStrategyFactory; + } + + public function getSheetIterator(): SheetIterator + { + $this->ensureStreamOpened(); + + return $this->sheetIterator; + } + + /** + * Returns whether stream wrappers are supported. + */ + protected function doesSupportStreamWrapper(): bool + { + 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 IOException If the file at the given path or its content cannot be read + * @throws NoSheetsFoundException If there are no sheets in the file + */ + protected function openReader(string $filePath): void + { + $this->zip = new ZipArchive(); + + if (true !== $this->zip->open($filePath)) { + throw new IOException("Could not open {$filePath} for reading."); + } + + $this->sharedStringsManager = new SharedStringsManager( + $filePath, + $this->options, + new WorkbookRelationshipsManager($filePath), + $this->cachingStrategyFactory + ); + + if ($this->sharedStringsManager->hasSharedStrings()) { + // Extracts all the strings from the sheets for easy access in the future + $this->sharedStringsManager->extractSharedStrings(); + } + + $this->sheetIterator = new SheetIterator( + new SheetManager( + $filePath, + $this->options, + $this->sharedStringsManager, + new XLSX() + ) + ); + } + + /** + * Closes the reader. To be used after reading the file. + */ + protected function closeReader(): void + { + $this->zip->close(); + $this->sharedStringsManager->cleanup(); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/RowIterator.php b/upstream-4.x/src/Reader/XLSX/RowIterator.php new file mode 100644 index 0000000..a7ce9a0 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/RowIterator.php @@ -0,0 +1,398 @@ +filePath = $filePath; + $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath); + $this->shouldPreserveEmptyRows = $shouldPreserveEmptyRows; + $this->xmlReader = $xmlReader; + $this->cellValueFormatter = $cellValueFormatter; + $this->rowManager = $rowManager; + + // 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 IOException If the sheet data XML cannot be read + */ + 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 + */ + public function valid(): bool + { + $valid = !$this->hasReachedEndOfFile; + if (!$valid) { + $this->xmlReader->close(); + } + + return $valid; + } + + /** + * Move forward to next element. Reads data describing the next unprocessed row. + * + * @see http://php.net/manual/en/iterator.next.php + * + * @throws SharedStringNotFoundException If a shared string was not found + * @throws IOException If unable to read the sheet data XML + */ + 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 + */ + 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 = new Row([], null); + } + } + + \assert(null !== $rowToBeProcessed); + + return $rowToBeProcessed; + } + + /** + * Return the key of the current element. Here, the row index. + * + * @see http://php.net/manual/en/iterator.key.php + */ + 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; + } + + /** + * @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 + */ + private function normalizeSheetDataXMLFilePath(string $sheetDataXMLFilePath): string + { + 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 + */ + private function doesNeedDataForNextRowToBeProcessed(): bool + { + $hasReadAtLeastOneRow = (0 !== $this->lastRowIndexProcessed); + + return + !$hasReadAtLeastOneRow + || !$this->shouldPreserveEmptyRows + || $this->lastRowIndexProcessed < $this->nextRowIndexToBeProcessed; + } + + /** + * @throws SharedStringNotFoundException If a shared string was not found + * @throws IOException If unable to read the sheet data XML + */ + private function readDataForNextRow(): void + { + $this->currentlyProcessedRow = new Row([], null); + + $this->xmlProcessor->readUntilStopped(); + + $this->rowBuffer = $this->currentlyProcessedRow; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processDimensionStartingNode(XMLReader $xmlReader): int + { + // Read dimensions of the sheet + $dimensionRef = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); // returns 'A1:M13' for instance (or 'A1' for empty sheet) + \assert(null !== $dimensionRef); + if (1 === preg_match('/[A-Z]+\d+:([A-Z]+\d+)/', $dimensionRef, $matches)) { + $this->numColumns = CellHelper::getColumnIndexFromCellIndex($matches[1]) + 1; + } + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processRowStartingNode(XMLReader $xmlReader): int + { + // 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 (null !== $spans && '' !== $spans) { + [, $numberOfColumnsForRow] = explode(':', $spans); + $numberOfColumnsForRow = (int) $numberOfColumnsForRow; + } + + $cells = array_fill(0, $numberOfColumnsForRow, Cell::fromValue('')); + $this->currentlyProcessedRow->setCells($cells); + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processCellStartingNode(XMLReader $xmlReader): int + { + $currentColumnIndex = $this->getColumnIndex($xmlReader); + + // NOTE: expand() will automatically decode all XML entities of the child nodes + $node = $xmlReader->expand(); + \assert($node instanceof DOMElement); + $cell = $this->cellValueFormatter->extractAndFormatNodeValue($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 + */ + private function processRowEndingNode(): int + { + // if the fetched row is empty and we don't want to preserve it.., + if (!$this->shouldPreserveEmptyRows && $this->currentlyProcessedRow->isEmpty()) { + // ... skip it + return XMLProcessor::PROCESSING_CONTINUE; + } + + ++$this->numReadRows; + + // If needed, we fill the empty cells + if (0 === $this->numColumns) { + $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 + */ + private function processWorksheetEndingNode(): int + { + // The closing "" marks the end of the file + $this->hasReachedEndOfFile = true; + + return XMLProcessor::PROCESSING_STOP; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" node + * + * @return int Row index + * + * @throws InvalidArgumentException When the given cell index is invalid + */ + private function getRowIndex(XMLReader $xmlReader): int + { + // 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 XMLReader $xmlReader XMLReader object, positioned on a "" node + * + * @return int Column index + * + * @throws InvalidArgumentException When the given cell index is invalid + */ + private function getColumnIndex(XMLReader $xmlReader): int + { + // 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; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/Sheet.php b/upstream-4.x/src/Reader/XLSX/Sheet.php new file mode 100644 index 0000000..4b59f36 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/Sheet.php @@ -0,0 +1,116 @@ + + * @implements SheetWithMergeCellsInterface + */ +final readonly class Sheet implements SheetWithVisibilityInterface, SheetWithMergeCellsInterface +{ + /** @var RowIterator To iterate over sheet's rows */ + private RowIterator $rowIterator; + + /** @var SheetHeaderReader To read the header of the sheet, containing for instance the col widths */ + private SheetHeaderReader $headerReader; + + /** @var int Index of the sheet, based on order in the workbook (zero-based) */ + private int $index; + + /** @var string Name of the sheet */ + private string $name; + + /** @var bool Whether the sheet was the active one */ + private bool $isActive; + + /** @var bool Whether the sheet is visible */ + private bool $isVisible; + + /** @var list Merge cells list ["C7:E7", "A9:D10"] */ + private array $mergeCells; + + /** + * @param RowIterator $rowIterator The corresponding row iterator + * @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 list $mergeCells Merge cells list ["C7:E7", "A9:D10"] + */ + public function __construct( + RowIterator $rowIterator, + SheetHeaderReader $headerReader, + int $sheetIndex, + string $sheetName, + bool $isSheetActive, + bool $isSheetVisible, + array $mergeCells + ) { + $this->rowIterator = $rowIterator; + $this->headerReader = $headerReader; + $this->index = $sheetIndex; + $this->name = $sheetName; + $this->isActive = $isSheetActive; + $this->isVisible = $isSheetVisible; + $this->mergeCells = $mergeCells; + } + + public function getRowIterator(): RowIterator + { + return $this->rowIterator; + } + + /** + * @return ColumnWidth[] a list of column-widths + */ + public function getColumnWidths(): array + { + return $this->headerReader->getColumnWidths(); + } + + /** + * @return int Index of the sheet, based on order in the workbook (zero-based) + */ + public function getIndex(): int + { + return $this->index; + } + + /** + * @return string Name of the sheet + */ + public function getName(): string + { + return $this->name; + } + + /** + * @return bool Whether the sheet was defined as active + */ + public function isActive(): bool + { + return $this->isActive; + } + + /** + * @return bool Whether the sheet is visible + */ + public function isVisible(): bool + { + return $this->isVisible; + } + + /** + * @return list Merge cells list ["C7:E7", "A9:D10"] + */ + public function getMergeCells(): array + { + return $this->mergeCells; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/SheetHeaderReader.php b/upstream-4.x/src/Reader/XLSX/SheetHeaderReader.php new file mode 100644 index 0000000..5c6b429 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/SheetHeaderReader.php @@ -0,0 +1,119 @@ +filePath = $filePath; + $this->sheetDataXMLFilePath = $this->normalizeSheetDataXMLFilePath($sheetDataXMLFilePath); + $this->xmlReader = $xmlReader; + + // Register all callbacks to process different nodes when reading the XML file + $this->xmlProcessor = $xmlProcessor; + $this->xmlProcessor->registerCallback(self::XML_NODE_COL, XMLProcessor::NODE_TYPE_START, [$this, 'processColStartingNode']); + $this->xmlProcessor->registerCallback(self::XML_NODE_SHEETDATA, XMLProcessor::NODE_TYPE_START, [$this, 'processSheetDataStartingNode']); + + // The reader should be unused, but we close to be sure + $this->xmlReader->close(); + + if (false === $this->xmlReader->openFileInZip($this->filePath, $this->sheetDataXMLFilePath)) { + throw new IOException("Could not open \"{$this->sheetDataXMLFilePath}\"."); + } + + // Now read the entire header of the sheet, until we reach the element + $this->xmlProcessor->readUntilStopped(); + + // We don't need the reader anymore, so we close it + $this->xmlReader->close(); + } + + /** + * @internal + * + * @return ColumnWidth[] + */ + public function getColumnWidths(): array + { + return $this->columnWidths; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processColStartingNode(XMLReader $xmlReader): int + { + $min = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_MIN); + $max = (int) $xmlReader->getAttribute(self::XML_ATTRIBUTE_MAX); + $width = (float) $xmlReader->getAttribute(self::XML_ATTRIBUTE_WIDTH); + + \assert($min > 0); + \assert($max > 0); + + $columnwidth = new ColumnWidth($min, $max, $width); + $this->columnWidths[] = $columnwidth; + + return XMLProcessor::PROCESSING_CONTINUE; + } + + /** + * @return int A return code that indicates what action should the processor take next + */ + private function processSheetDataStartingNode(): int + { + // The opening "" marks the end of the file + return XMLProcessor::PROCESSING_STOP; + } + + /** + * @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 + */ + private function normalizeSheetDataXMLFilePath(string $sheetDataXMLFilePath): string + { + return ltrim($sheetDataXMLFilePath, '/'); + } +} diff --git a/upstream-4.x/src/Reader/XLSX/SheetIterator.php b/upstream-4.x/src/Reader/XLSX/SheetIterator.php new file mode 100644 index 0000000..33b26f9 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/SheetIterator.php @@ -0,0 +1,86 @@ + + */ +final class SheetIterator implements SheetIteratorInterface +{ + /** @var Sheet[] The list of sheet present in the file */ + private array $sheets; + + /** @var int The index of the sheet being read (zero-based) */ + private int $currentSheetIndex = 0; + + /** + * @param SheetManager $sheetManager Manages sheets + * + * @throws NoSheetsFoundException If there are no sheets in the file + */ + public function __construct(SheetManager $sheetManager) + { + // Fetch all available sheets + $this->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 + */ + public function rewind(): void + { + $this->currentSheetIndex = 0; + } + + /** + * Checks if current position is valid. + * + * @see http://php.net/manual/en/iterator.valid.php + */ + public function valid(): bool + { + return $this->currentSheetIndex < \count($this->sheets); + } + + /** + * Move forward to next element. + * + * @see http://php.net/manual/en/iterator.next.php + */ + public function next(): void + { + ++$this->currentSheetIndex; + } + + /** + * Return the current element. + * + * @see http://php.net/manual/en/iterator.current.php + */ + public function current(): Sheet + { + return $this->sheets[$this->currentSheetIndex]; + } + + /** + * Return the key of the current element. + * + * @see http://php.net/manual/en/iterator.key.php + */ + public function key(): int + { + return $this->currentSheetIndex + 1; + } +} diff --git a/upstream-4.x/src/Reader/XLSX/SheetMergeCellsReader.php b/upstream-4.x/src/Reader/XLSX/SheetMergeCellsReader.php new file mode 100644 index 0000000..e5ac387 --- /dev/null +++ b/upstream-4.x/src/Reader/XLSX/SheetMergeCellsReader.php @@ -0,0 +1,69 @@ + Merged cells list */ + private array $mergeCells = []; + + /** + * @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 XMLProcessor $xmlProcessor Helper to process XML files + */ + public function __construct( + string $filePath, + string $sheetDataXMLFilePath, + XMLReader $xmlReader, + XMLProcessor $xmlProcessor + ) { + $sheetDataXMLFilePath = ltrim($sheetDataXMLFilePath, '/'); + + // Register all callbacks to process different nodes when reading the XML file + $xmlProcessor->registerCallback(self::XML_NODE_MERGE_CELL, XMLProcessor::NODE_TYPE_START, [$this, 'processMergeCellsStartingNode']); + $xmlReader->close(); + + if (false === $xmlReader->openFileInZip($filePath, $sheetDataXMLFilePath)) { + throw new IOException("Could not open \"{$sheetDataXMLFilePath}\"."); + } + + // Now read the entire header of the sheet, until we reach the element + $xmlProcessor->readUntilStopped(); + $xmlReader->close(); + } + + /** + * @return list + */ + public function getMergeCells(): array + { + return $this->mergeCells; + } + + /** + * @param XMLReader $xmlReader XMLReader object, positioned on a "" starting node + * + * @return int A return code that indicates what action should the processor take next + */ + private function processMergeCellsStartingNode(XMLReader $xmlReader): int + { + $this->mergeCells[] = $xmlReader->getAttribute(self::XML_ATTRIBUTE_REF); + + return XMLProcessor::PROCESSING_CONTINUE; + } +} diff --git a/upstream-4.x/src/Writer/AbstractWriter.php b/upstream-4.x/src/Writer/AbstractWriter.php new file mode 100644 index 0000000..eef34fb --- /dev/null +++ b/upstream-4.x/src/Writer/AbstractWriter.php @@ -0,0 +1,169 @@ +outputFilePath = $outputFilePath; + + $errorMessage = null; + set_error_handler(static function ($nr, $message) use (&$errorMessage): bool { + $errorMessage = $message; + + return true; + }); + + $resource = fopen($this->outputFilePath, 'w'); + restore_error_handler(); + if (null !== $errorMessage) { + throw new IOException("Unable to open file {$this->outputFilePath}: {$errorMessage}"); + } + \assert(false !== $resource); + $this->filePointer = $resource; + + $this->openWriter(); + $this->isWriterOpened = true; + } + + /** + * @codeCoverageIgnore + * + * @param mixed $outputFileName + */ + final public function openToBrowser($outputFileName): void + { + $this->outputFilePath = basename($outputFileName); + + $resource = fopen('php://output', 'w'); + \assert(false !== $resource); + $this->filePointer = $resource; + + // Clear any previous output (otherwise the generated file will be corrupted) + // @see https://github.com/box/spout/issues/241 + if (ob_get_length() > 0) { + 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 + */ + header('Content-Type: '.static::$headerContentType); + 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 + */ + header('Cache-Control: max-age=0'); + header('Pragma: public'); + + $this->openWriter(); + $this->isWriterOpened = true; + } + + final public function addRow(Row $row): void + { + if (!$this->isWriterOpened) { + throw new WriterNotOpenedException('The writer needs to be opened before adding row.'); + } + + $this->addRowToWriter($row); + ++$this->writtenRowCount; + } + + final public function addRows(array $rows): void + { + foreach ($rows as $row) { + $this->addRow($row); + } + } + + final public function setCreator(string $creator): void + { + $this->creator = $creator; + } + + final public function getWrittenRowCount(): int + { + return $this->writtenRowCount; + } + + final public function close(): void + { + if (!$this->isWriterOpened) { + return; + } + + $this->closeWriter(); + + 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(): void; + + /** + * 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): void; + + /** + * Closes the streamer, preventing any additional writing. + */ + abstract protected function closeWriter(): void; +} diff --git a/upstream-4.x/src/Writer/AbstractWriterMultiSheets.php b/upstream-4.x/src/Writer/AbstractWriterMultiSheets.php new file mode 100644 index 0000000..c1bae86 --- /dev/null +++ b/upstream-4.x/src/Writer/AbstractWriterMultiSheets.php @@ -0,0 +1,121 @@ +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. + * + * @return Sheet The created sheet + * + * @throws IOException + * @throws WriterNotOpenedException If the writer has not been opened yet + */ + final public function addNewSheetAndMakeItCurrent(): Sheet + { + $this->throwIfWorkbookIsNotAvailable(); + $worksheet = $this->workbookManager->addNewSheetAndMakeItCurrent(); + + return $worksheet->getExternalSheet(); + } + + /** + * Returns the current sheet. + * + * @return Sheet The current sheet + * + * @throws WriterNotOpenedException If the writer has not been opened yet + */ + final public function getCurrentSheet(): Sheet + { + $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 + */ + final public function setCurrentSheet(Sheet $sheet): void + { + $this->throwIfWorkbookIsNotAvailable(); + $this->workbookManager->setCurrentSheet($sheet); + } + + abstract protected function createWorkbookManager(): WorkbookManagerInterface; + + protected function openWriter(): void + { + if (!isset($this->workbookManager)) { + $this->workbookManager = $this->createWorkbookManager(); + $this->workbookManager->addNewSheetAndMakeItCurrent(); + } + } + + /** + * @throws Exception\WriterException + */ + protected function addRowToWriter(Row $row): void + { + $this->throwIfWorkbookIsNotAvailable(); + $this->workbookManager->addRowToCurrentWorksheet($row); + } + + protected function closeWriter(): void + { + if (isset($this->workbookManager)) { + $this->workbookManager->close($this->filePointer); + } + } + + /** + * Checks if the workbook has been created. Throws an exception if not created yet. + * + * @throws WriterNotOpenedException If the workbook is not created yet + */ + private function throwIfWorkbookIsNotAvailable(): void + { + if (!isset($this->workbookManager)) { + throw new WriterNotOpenedException('The writer must be opened before performing this action.'); + } + } +} diff --git a/upstream-4.x/src/Writer/AutoFilter.php b/upstream-4.x/src/Writer/AutoFilter.php new file mode 100644 index 0000000..7682253 --- /dev/null +++ b/upstream-4.x/src/Writer/AutoFilter.php @@ -0,0 +1,21 @@ +options = $options ?? new Options(); + } + + public function getOptions(): Options + { + return $this->options; + } + + /** + * Opens the CSV streamer and makes it ready to accept data. + */ + protected function openWriter(): void + { + if ($this->options->SHOULD_ADD_BOM) { + // Adds UTF-8 BOM for Unicode compatibility + fwrite($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): void + { + $cells = array_map(static function (Cell\BooleanCell|Cell\DateIntervalCell|Cell\DateTimeCell|Cell\EmptyCell|Cell\FormulaCell|Cell\NumericCell|Cell\StringCell $value): string { + if ($value instanceof Cell\BooleanCell) { + return (string) (int) $value->getValue(); + } + if ($value instanceof Cell\DateTimeCell) { + return $value->getValue()->format(DATE_ATOM); + } + if ($value instanceof Cell\DateIntervalCell) { + return $value->getValue()->format('P%yY%mM%dDT%hH%iM%sS%fF'); + } + + return (string) $value->getValue(); + }, $row->getCells()); + + $wasWriteSuccessful = fputcsv( + $this->filePointer, + $cells, + $this->options->FIELD_DELIMITER, + $this->options->FIELD_ENCLOSURE, + '' + ); + if (false === $wasWriteSuccessful) { + throw new IOException('Unable to write data'); // @codeCoverageIgnore + } + + ++$this->lastWrittenRowIndex; + if (0 === $this->lastWrittenRowIndex % $this->options->FLUSH_THRESHOLD) { + 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(): void + { + $this->lastWrittenRowIndex = 0; + } +} diff --git a/upstream-4.x/src/Writer/Common/AbstractOptions.php b/upstream-4.x/src/Writer/Common/AbstractOptions.php new file mode 100644 index 0000000..0b6651d --- /dev/null +++ b/upstream-4.x/src/Writer/Common/AbstractOptions.php @@ -0,0 +1,67 @@ +DEFAULT_ROW_STYLE = new Style(); + } + + /** + * @param positive-int ...$columns One or more columns with this width + */ + final public function setColumnWidth(float $width, int ...$columns): void + { + // Gather sequences + $sequence = []; + foreach ($columns as $column) { + $sequenceLength = \count($sequence); + if ($sequenceLength > 0) { + $previousValue = $sequence[$sequenceLength - 1]; + if ($column !== $previousValue + 1) { + $this->setColumnWidthForRange($width, $sequence[0], $previousValue); + $sequence = []; + } + } + $sequence[] = $column; + } + $this->setColumnWidthForRange($width, $sequence[0], $sequence[\count($sequence) - 1]); + } + + /** + * @param float $width The width to set + * @param positive-int $start First column index of the range + * @param positive-int $end Last column index of the range + */ + final public function setColumnWidthForRange(float $width, int $start, int $end): void + { + $this->COLUMN_WIDTHS[] = new ColumnWidth($start, $end, $width); + } + + /** + * @internal + * + * @return ColumnWidth[] + */ + final public function getColumnWidths(): array + { + return $this->COLUMN_WIDTHS; + } +} diff --git a/upstream-4.x/src/Writer/Common/ColumnWidth.php b/upstream-4.x/src/Writer/Common/ColumnWidth.php new file mode 100644 index 0000000..2d771c7 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/ColumnWidth.php @@ -0,0 +1,21 @@ + new CSVWriter(), + 'xlsx' => new XLSXWriter(), + 'ods' => new ODSWriter(), + default => throw new UnsupportedTypeException('No writers supporting the given type: '.$extension), + }; + } +} diff --git a/upstream-4.x/src/Writer/Common/Entity/Sheet.php b/upstream-4.x/src/Writer/Common/Entity/Sheet.php new file mode 100644 index 0000000..f5ce8f0 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Entity/Sheet.php @@ -0,0 +1,240 @@ +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 0|positive-int Index of the sheet, based on order in the workbook (zero-based) + */ + public function getIndex(): int + { + return $this->index; + } + + public function getAssociatedWorkbookId(): string + { + return $this->associatedWorkbookId; + } + + /** + * @return string Name of the sheet + */ + public function getName(): string + { + 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 InvalidSheetNameException if the sheet's name is invalid + */ + public function setName(string $name): self + { + $this->sheetManager->throwIfNameIsInvalid($name, $this); + + $this->name = $name; + + $this->sheetManager->markSheetNameAsUsed($this); + + return $this; + } + + /** + * @return bool isVisible Visibility of the sheet + */ + public function isVisible(): bool + { + return $this->isVisible; + } + + /** + * @param bool $isVisible Visibility of the sheet + */ + public function setIsVisible(bool $isVisible): self + { + $this->isVisible = $isVisible; + + return $this; + } + + /** + * @return $this + */ + public function setSheetView(SheetView $sheetView): self + { + $this->sheetView = $sheetView; + + return $this; + } + + public function getSheetView(): ?SheetView + { + return $this->sheetView; + } + + /** + * @internal + */ + public function incrementWrittenRowCount(): void + { + ++$this->writtenRowCount; + } + + /** + * @return 0|positive-int + */ + public function getWrittenRowCount(): int + { + return $this->writtenRowCount; + } + + /** + * @return $this + */ + public function setAutoFilter(?AutoFilter $autoFilter): self + { + $this->autoFilter = $autoFilter; + + return $this; + } + + public function getAutoFilter(): ?AutoFilter + { + return $this->autoFilter; + } + + /** + * @param positive-int ...$columns One or more columns with this width + */ + public function setColumnWidth(float $width, int ...$columns): void + { + // Gather sequences + $sequence = []; + foreach ($columns as $column) { + $sequenceLength = \count($sequence); + if ($sequenceLength > 0) { + $previousValue = $sequence[$sequenceLength - 1]; + if ($column !== $previousValue + 1) { + $this->setColumnWidthForRange($width, $sequence[0], $previousValue); + $sequence = []; + } + } + $sequence[] = $column; + } + $this->setColumnWidthForRange($width, $sequence[0], $sequence[\count($sequence) - 1]); + } + + /** + * @param float $width The width to set + * @param positive-int $start First column index of the range + * @param positive-int $end Last column index of the range + */ + public function setColumnWidthForRange(float $width, int $start, int $end): void + { + $this->COLUMN_WIDTHS[] = new ColumnWidth($start, $end, $width); + } + + /** + * @internal + * + * @return ColumnWidth[] + */ + public function getColumnWidths(): array + { + return $this->COLUMN_WIDTHS; + } + + public function getPrintTitleRows(): ?string + { + return $this->printTitleRows; + } + + public function setPrintTitleRows(string $printTitleRows): void + { + $this->printTitleRows = $printTitleRows; + } + + /** + * @return $this + */ + public function setSheetProtection(SheetProtection $sheetProtection): self + { + $this->sheetProtection = $sheetProtection; + + return $this; + } + + public function getSheetProtection(): ?SheetProtection + { + return $this->sheetProtection; + } +} diff --git a/upstream-4.x/src/Writer/Common/Entity/Workbook.php b/upstream-4.x/src/Writer/Common/Entity/Workbook.php new file mode 100644 index 0000000..8595086 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Entity/Workbook.php @@ -0,0 +1,46 @@ +internalId = uniqid(); + } + + /** + * @return Worksheet[] + */ + public function getWorksheets(): array + { + return $this->worksheets; + } + + /** + * @param Worksheet[] $worksheets + */ + public function setWorksheets(array $worksheets): void + { + $this->worksheets = $worksheets; + } + + public function getInternalId(): string + { + return $this->internalId; + } +} diff --git a/upstream-4.x/src/Writer/Common/Entity/Worksheet.php b/upstream-4.x/src/Writer/Common/Entity/Worksheet.php new file mode 100644 index 0000000..f1db1cb --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Entity/Worksheet.php @@ -0,0 +1,92 @@ +filePath = $worksheetFilePath; + $this->externalSheet = $externalSheet; + } + + public function getFilePath(): string + { + return $this->filePath; + } + + /** + * @return resource + */ + public function getFilePointer() + { + \assert(null !== $this->filePointer); + + return $this->filePointer; + } + + /** + * @param resource $filePointer + */ + public function setFilePointer($filePointer): void + { + $this->filePointer = $filePointer; + } + + public function getExternalSheet(): Sheet + { + return $this->externalSheet; + } + + public function getMaxNumColumns(): int + { + return $this->maxNumColumns; + } + + public function setMaxNumColumns(int $maxNumColumns): void + { + $this->maxNumColumns = $maxNumColumns; + } + + public function getLastWrittenRowIndex(): int + { + return $this->lastWrittenRowIndex; + } + + public function setLastWrittenRowIndex(int $lastWrittenRowIndex): void + { + $this->lastWrittenRowIndex = $lastWrittenRowIndex; + } + + /** + * @return int The ID of the worksheet + */ + public function getId(): int + { + // sheet index is zero-based, while ID is 1-based + return $this->externalSheet->getIndex() + 1; + } +} diff --git a/upstream-4.x/src/Writer/Common/Helper/CellHelper.php b/upstream-4.x/src/Writer/Common/Helper/CellHelper.php new file mode 100644 index 0000000..e70978d --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Helper/CellHelper.php @@ -0,0 +1,47 @@ + Cache containing the mapping column index => column letters */ + private static array $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(int $columnIndexZeroBased): string + { + $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-4.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php b/upstream-4.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php new file mode 100644 index 0000000..0efd026 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Helper/FileSystemWithRootFolderHelperInterface.php @@ -0,0 +1,25 @@ +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): string + { + 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(ZipArchive $zip, string $rootFolderPath, string $localFilePath, string $existingFileMode = self::EXISTING_FILES_OVERWRITE): void + { + $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(ZipArchive $zip, string $rootFolderPath, string $localFilePath, string $existingFileMode = self::EXISTING_FILES_OVERWRITE): void + { + $this->addFileToArchiveWithCompressionMethod( + $zip, + $rootFolderPath, + $localFilePath, + $existingFileMode, + ZipArchive::CM_STORE + ); + } + + /** + * @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(ZipArchive $zip, string $folderPath, string $existingFileMode = self::EXISTING_FILES_OVERWRITE): void + { + $folderRealPath = $this->getNormalizedRealPath($folderPath).'/'; + $itemIterator = new RecursiveIteratorIterator( + new RecursiveDirectoryIterator($folderPath, RecursiveDirectoryIterator::SKIP_DOTS), + RecursiveIteratorIterator::SELF_FIRST + ); + + foreach ($itemIterator as $itemInfo) { + \assert($itemInfo instanceof SplFileInfo); + $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(ZipArchive $zip, $streamPointer): void + { + $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 + */ + private function addFileToArchiveWithCompressionMethod(ZipArchive $zip, string $rootFolderPath, string $localFilePath, string $existingFileMode, int $compressionMethod): void + { + $normalizedLocalFilePath = str_replace('\\', '/', $localFilePath); + if (!$this->shouldSkipFile($zip, $normalizedLocalFilePath, $existingFileMode)) { + $normalizedFullFilePath = $this->getNormalizedRealPath($rootFolderPath.'/'.$normalizedLocalFilePath); + $zip->addFile($normalizedFullFilePath, $normalizedLocalFilePath); + + $zip->setCompressionName($normalizedLocalFilePath, $compressionMethod); + } + } + + /** + * @return bool Whether the file should be added to the archive or skipped + */ + private function shouldSkipFile(ZipArchive $zip, string $itemLocalPath, string $existingFileMode): bool + { + // 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 + */ + private function getNormalizedRealPath(string $path): string + { + $realPath = realpath($path); + \assert(false !== $realPath); + + 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 + */ + private function copyZipToStream(string $zipFilePath, $pointer): void + { + $zipFilePointer = fopen($zipFilePath, 'r'); + \assert(false !== $zipFilePointer); + stream_copy_to_stream($zipFilePointer, $pointer); + fclose($zipFilePointer); + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/AbstractWorkbookManager.php b/upstream-4.x/src/Writer/Common/Manager/AbstractWorkbookManager.php new file mode 100644 index 0000000..dc3fbb1 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/AbstractWorkbookManager.php @@ -0,0 +1,290 @@ +workbook = $workbook; + $this->options = $options; + $this->worksheetManager = $worksheetManager; + $this->styleManager = $styleManager; + $this->styleMerger = $styleMerger; + $this->fileSystemHelper = $fileSystemHelper; + } + + /** + * 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 + */ + final public function addNewSheetAndMakeItCurrent(): Worksheet + { + $worksheet = $this->addNewSheet(); + $this->setCurrentWorksheet($worksheet); + + return $worksheet; + } + + /** + * @return Worksheet[] All the workbook's sheets + */ + final public function getWorksheets(): array + { + return $this->workbook->getWorksheets(); + } + + /** + * Returns the current sheet. + * + * @return Worksheet The current sheet + */ + final public function getCurrentWorksheet(): Worksheet + { + return $this->currentWorksheet; + } + + /** + * Sets the given sheet as the current one. New data will be written to this sheet. + * The writing will resume where it stopped (i.e. data won't be truncated). + * + * @param Sheet $sheet The "external" sheet to set as current + * + * @throws SheetNotFoundException If the given sheet does not exist in the workbook + */ + final public function setCurrentSheet(Sheet $sheet): void + { + $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 InvalidArgumentException + */ + final public function addRowToCurrentWorksheet(Row $row): void + { + $currentWorksheet = $this->getCurrentWorksheet(); + if ($this->hasCurrentWorksheetReachedMaxRows()) { + if (!$this->options->SHOULD_CREATE_NEW_SHEETS_AUTOMATICALLY) { + return; + } + + $currentWorksheet = $this->addNewSheetAndMakeItCurrent(); + } + + $this->addRowToWorksheet($currentWorksheet, $row); + $currentWorksheet->getExternalSheet()->incrementWrittenRowCount(); + } + + /** + * 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 + */ + final public function close($finalFilePointer): void + { + $this->closeAllWorksheets(); + $this->closeRemainingObjects(); + $this->writeAllFilesToDiskAndZipThem($finalFilePointer); + $this->cleanupTempFolder(); + } + + /** + * @return int Maximum number of rows/columns a sheet can contain + */ + abstract protected function getMaxRowsPerWorksheet(): int; + + /** + * Closes custom objects that are still opened. + */ + protected function closeRemainingObjects(): void + { + // 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): void; + + /** + * @return string The file path where the data for the given sheet will be stored + */ + private function getWorksheetFilePath(Sheet $sheet): string + { + $sheetsContentTempFolder = $this->fileSystemHelper->getSheetsContentTempFolder(); + + return $sheetsContentTempFolder.\DIRECTORY_SEPARATOR.'sheet'.(1 + $sheet->getIndex()).'.xml'; + } + + /** + * Deletes the root folder created in the temp folder and all its contents. + */ + private function cleanupTempFolder(): void + { + $rootFolder = $this->fileSystemHelper->getRootFolder(); + $this->fileSystemHelper->deleteFolderRecursively($rootFolder); + } + + /** + * Creates a new sheet in the workbook. The current sheet remains unchanged. + * + * @return Worksheet The created sheet + * + * @throws IOException If unable to open the sheet for writing + */ + private function addNewSheet(): Worksheet + { + $worksheets = $this->getWorksheets(); + + $newSheetIndex = \count($worksheets); + $sheetManager = new SheetManager(StringHelper::factory()); + $sheet = new Sheet($newSheetIndex, $this->workbook->getInternalId(), $sheetManager); + + $worksheetFilePath = $this->getWorksheetFilePath($sheet); + $worksheet = new Worksheet($worksheetFilePath, $sheet); + + $this->worksheetManager->startSheet($worksheet); + + $worksheets[] = $worksheet; + $this->workbook->setWorksheets($worksheets); + + return $worksheet; + } + + private function setCurrentWorksheet(Worksheet $worksheet): void + { + $this->currentWorksheet = $worksheet; + } + + /** + * Returns the worksheet associated to the given external sheet. + * + * @return null|Worksheet the worksheet associated to the given external sheet or null if not found + */ + private function getWorksheetFromExternalSheet(Sheet $sheet): ?Worksheet + { + $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(): bool + { + $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 InvalidArgumentException + */ + private function addRowToWorksheet(Worksheet $worksheet, Row $row): void + { + $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): void + { + $mergedStyle = $this->styleMerger->merge( + $row->getStyle(), + $this->options->DEFAULT_ROW_STYLE + ); + $row->setStyle($mergedStyle); + } + + /** + * Closes all workbook's associated sheets. + */ + private function closeAllWorksheets(): void + { + $worksheets = $this->getWorksheets(); + + foreach ($worksheets as $worksheet) { + $this->worksheetManager->close($worksheet); + } + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/RegisteredStyle.php b/upstream-4.x/src/Writer/Common/Manager/RegisteredStyle.php new file mode 100644 index 0000000..7178de7 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/RegisteredStyle.php @@ -0,0 +1,35 @@ +style = $style; + $this->isMatchingRowStyle = $isMatchingRowStyle; + } + + public function getStyle(): Style + { + return $this->style; + } + + public function isMatchingRowStyle(): bool + { + return $this->isMatchingRowStyle; + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/SheetManager.php b/upstream-4.x/src/Writer/Common/Manager/SheetManager.php new file mode 100644 index 0000000..9a35555 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/SheetManager.php @@ -0,0 +1,134 @@ +> Associative array [WORKBOOK_ID] => [[SHEET_INDEX] => [SHEET_NAME]] keeping track of sheets' name to enforce uniqueness per workbook */ + private static array $SHEETS_NAME_USED = []; + + private readonly StringHelper $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 Sheet $sheet The sheet whose future name is checked + * + * @throws InvalidSheetNameException if the sheet's name is invalid + */ + public function throwIfNameIsInvalid(string $name, Sheet $sheet): void + { + $failedRequirements = []; + $nameLength = $this->stringHelper->getStringLength($name); + + if (!$this->isNameUnique($name, $sheet)) { + $failedRequirements[] = 'It should be unique'; + } elseif (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(string $workbookId): void + { + if (!isset(self::$SHEETS_NAME_USED[$workbookId])) { + self::$SHEETS_NAME_USED[$workbookId] = []; + } + } + + public function markSheetNameAsUsed(Sheet $sheet): void + { + self::$SHEETS_NAME_USED[$sheet->getAssociatedWorkbookId()][$sheet->getIndex()] = $sheet->getName(); + } + + /** + * Returns whether the given name contains at least one invalid character. + * + * @return bool TRUE if the name contains invalid characters, FALSE otherwise + */ + private function doesContainInvalidCharacters(string $name): bool + { + return str_replace(self::INVALID_CHARACTERS_IN_SHEET_NAME, '', $name) !== $name; + } + + /** + * Returns whether the given name starts or ends with a single quote. + * + * @return bool TRUE if the name starts or ends with a single quote, FALSE otherwise + */ + private function doesStartOrEndWithSingleQuote(string $name): bool + { + $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 Sheet $sheet The sheet whose future name is checked + * + * @return bool TRUE if the name is unique, FALSE otherwise + */ + private function isNameUnique(string $name, Sheet $sheet): bool + { + foreach (self::$SHEETS_NAME_USED[$sheet->getAssociatedWorkbookId()] as $sheetIndex => $sheetName) { + if ($sheetIndex !== $sheet->getIndex() && $sheetName === $name) { + return false; + } + } + + return true; + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleManager.php b/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleManager.php new file mode 100644 index 0000000..f9c1624 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleManager.php @@ -0,0 +1,84 @@ +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 + */ + final public function registerStyle(Style $style): 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 + */ + final public function applyExtraStylesIfNeeded(Cell $cell): PossiblyUpdatedStyle + { + return $this->applyWrapTextIfCellContainsNewLine($cell); + } + + /** + * Returns the default style. + * + * @return Style Default style + */ + final protected function getDefaultStyle(): Style + { + // 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 + */ + private function applyWrapTextIfCellContainsNewLine(Cell $cell): PossiblyUpdatedStyle + { + $cellStyle = $cell->getStyle(); + + // if the "wrap text" option is already set, no-op + if (!$cellStyle->hasSetWrapText() && $cell instanceof Cell\StringCell && str_contains($cell->getValue(), "\n")) { + $cellStyle->setShouldWrapText(); + + return new PossiblyUpdatedStyle($cellStyle, true); + } + + return new PossiblyUpdatedStyle($cellStyle, false); + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleRegistry.php b/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleRegistry.php new file mode 100644 index 0000000..433c6f5 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/Style/AbstractStyleRegistry.php @@ -0,0 +1,96 @@ + [SERIALIZED_STYLE] => [STYLE_ID] mapping table, keeping track of the registered styles */ + private array $serializedStyleToStyleIdMappingTable = []; + + /** @var array [STYLE_ID] => [STYLE] mapping table, keeping track of the registered styles */ + private array $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): 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 + */ + final public function getRegisteredStyles(): array + { + return array_values($this->styleIdToStyleMappingTable); + } + + final public function getStyleFromStyleId(int $styleId): Style + { + 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 + */ + final public function serialize(Style $style): string + { + return serialize($style); + } + + /** + * Returns whether the serialized style has already been registered. + * + * @param string $serializedStyle The serialized style + */ + private function hasSerializedStyleAlreadyBeenRegistered(string $serializedStyle): bool + { + // 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 + */ + private function getStyleFromSerializedStyle(string $serializedStyle): Style + { + $styleId = $this->serializedStyleToStyleIdMappingTable[$serializedStyle]; + + return $this->styleIdToStyleMappingTable[$styleId]; + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php b/upstream-4.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php new file mode 100644 index 0000000..9454671 --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/Style/PossiblyUpdatedStyle.php @@ -0,0 +1,32 @@ +style = $style; + $this->isUpdated = $isUpdated; + } + + public function getStyle(): Style + { + return $this->style; + } + + public function isUpdated(): bool + { + return $this->isUpdated; + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php b/upstream-4.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php new file mode 100644 index 0000000..a2f401d --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/Style/StyleManagerInterface.php @@ -0,0 +1,32 @@ +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): void + { + 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): void + { + 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): void + { + if (!$style->hasSetWrapText() && $baseStyle->hasSetWrapText()) { + $styleToUpdate->setShouldWrapText($baseStyle->shouldWrapText()); + } + if (!$style->hasSetTextRotation() && $baseStyle->hasSetTextRotation()) { + $styleToUpdate->setTextRotation($baseStyle->textRotation()); + } + if (!$style->hasSetShrinkToFit() && $baseStyle->shouldShrinkToFit()) { + $styleToUpdate->setShouldShrinkToFit(); + } + if (!$style->hasSetCellAlignment() && $baseStyle->shouldApplyCellAlignment()) { + $styleToUpdate->setCellAlignment($baseStyle->getCellAlignment()); + } + if (!$style->hasSetCellVerticalAlignment() && $baseStyle->shouldApplyCellVerticalAlignment()) { + $styleToUpdate->setCellVerticalAlignment($baseStyle->getCellVerticalAlignment()); + } + if (null === $style->getBorder() && null !== ($border = $baseStyle->getBorder())) { + $styleToUpdate->setBorder($border); + } + if (null === $style->getFormat() && null !== ($format = $baseStyle->getFormat())) { + $styleToUpdate->setFormat($format); + } + if (null === $style->getBackgroundColor() && null !== ($bgColor = $baseStyle->getBackgroundColor())) { + $styleToUpdate->setBackgroundColor($bgColor); + } + } +} diff --git a/upstream-4.x/src/Writer/Common/Manager/WorkbookManagerInterface.php b/upstream-4.x/src/Writer/Common/Manager/WorkbookManagerInterface.php new file mode 100644 index 0000000..aecde4f --- /dev/null +++ b/upstream-4.x/src/Writer/Common/Manager/WorkbookManagerInterface.php @@ -0,0 +1,71 @@ + + * + * @internal + */ +final class BorderHelper +{ + /** + * Width mappings. + */ + public const widthMap = [ + Border::WIDTH_THIN => '0.75pt', + Border::WIDTH_MEDIUM => '1.75pt', + Border::WIDTH_THICK => '2.5pt', + ]; + + /** + * Style mapping. + */ + public const styleMap = [ + Border::STYLE_SOLID => 'solid', + Border::STYLE_DASHED => 'dashed', + Border::STYLE_DOTTED => 'dotted', + Border::STYLE_DOUBLE => 'double', + ]; + + public static function serializeBorderPart(BorderPart $borderPart): string + { + $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-4.x/src/Writer/ODS/Helper/FileSystemHelper.php b/upstream-4.x/src/Writer/ODS/Helper/FileSystemHelper.php new file mode 100644 index 0000000..63402e5 --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Helper/FileSystemHelper.php @@ -0,0 +1,328 @@ +baseFileSystemHelper = new CommonFileSystemHelper($baseFolderPath); + $this->baseFolderRealPath = $this->baseFileSystemHelper->getBaseFolderRealPath(); + $this->zipHelper = $zipHelper; + $this->creator = $creator; + } + + public function createFolder(string $parentFolderPath, string $folderName): string + { + return $this->baseFileSystemHelper->createFolder($parentFolderPath, $folderName); + } + + public function createFileWithContents(string $parentFolderPath, string $fileName, string $fileContents): string + { + return $this->baseFileSystemHelper->createFileWithContents($parentFolderPath, $fileName, $fileContents); + } + + public function deleteFile(string $filePath): void + { + $this->baseFileSystemHelper->deleteFile($filePath); + } + + public function deleteFolderRecursively(string $folderPath): void + { + $this->baseFileSystemHelper->deleteFolderRecursively($folderPath); + } + + public function getRootFolder(): string + { + return $this->rootFolder; + } + + public function getSheetsContentTempFolder(): string + { + return $this->sheetsContentTempFolder; + } + + /** + * Creates all the folders needed to create a ODS file, as well as the files that won't change. + * + * @throws IOException If unable to create at least one of the base folders + */ + public function createBaseFilesAndFolders(): void + { + $this + ->createRootFolder() + ->createMetaInfoFolderAndFile() + ->createSheetsContentTempFolder() + ->createMetaFile() + ->createMimetypeFile() + ; + } + + /** + * Creates the "content.xml" file under the root folder. + * + * @param Worksheet[] $worksheets + */ + public function createContentFile(WorksheetManager $worksheetManager, StyleManager $styleManager, array $worksheets): self + { + $contentXmlFileContents = <<<'EOD' + + + EOD; + + $contentXmlFileContents .= $styleManager->getContentXmlFontFaceSectionContent(); + $contentXmlFileContents .= $styleManager->getContentXmlAutomaticStylesSectionContent($worksheets); + + $contentXmlFileContents .= ''; + + $topContentTempFile = uniqid(self::CONTENT_XML_FILE_NAME); + $this->createFileWithContents($this->rootFolder, $topContentTempFile, $contentXmlFileContents); + + // Append sheets content to "content.xml" + $contentXmlFilePath = $this->rootFolder.\DIRECTORY_SEPARATOR.self::CONTENT_XML_FILE_NAME; + $contentXmlHandle = fopen($contentXmlFilePath, 'w'); + \assert(false !== $contentXmlHandle); + + $topContentTempPathname = $this->rootFolder.\DIRECTORY_SEPARATOR.$topContentTempFile; + $topContentTempHandle = fopen($topContentTempPathname, 'r'); + \assert(false !== $topContentTempHandle); + stream_copy_to_stream($topContentTempHandle, $contentXmlHandle); + fclose($topContentTempHandle); + unlink($topContentTempPathname); + + 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, ''); + } + + // add AutoFilter + $databaseRanges = ''; + foreach ($worksheets as $worksheet) { + $databaseRanges .= $worksheetManager->getTableDatabaseRangeElementAsString($worksheet); + } + if ('' !== $databaseRanges) { + fwrite($contentXmlHandle, ''); + fwrite($contentXmlHandle, $databaseRanges); + fwrite($contentXmlHandle, ''); + } + + $contentXmlFileContents = ''; + + fwrite($contentXmlHandle, $contentXmlFileContents); + fclose($contentXmlHandle); + + return $this; + } + + /** + * Deletes the temporary folder where sheets content was stored. + */ + public function deleteWorksheetTempFolder(): self + { + $this->deleteFolderRecursively($this->sheetsContentTempFolder); + + return $this; + } + + /** + * Creates the "styles.xml" file under the root folder. + * + * @param int $numWorksheets Number of created worksheets + */ + public function createStylesFile(StyleManager $styleManager, int $numWorksheets): self + { + $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): void + { + $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 IOException If unable to create the folder + */ + private function createRootFolder(): self + { + $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 IOException If unable to create the folder or the "manifest.xml" file + */ + private function createMetaInfoFolderAndFile(): self + { + $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 IOException If unable to create the file + */ + private function createManifestFile(): self + { + $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 IOException If unable to create the folder + */ + private function createSheetsContentTempFolder(): self + { + $this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, 'worksheets-temp'); + + return $this; + } + + /** + * Creates the "meta.xml" file under the root folder. + * + * @throws IOException If unable to create the file + */ + private function createMetaFile(): self + { + $createdDate = (new DateTimeImmutable())->format(DateTimeImmutable::W3C); + + $metaXmlFileContents = << + + + {$this->creator} + {$createdDate} + {$createdDate} + + + EOD; + + $this->createFileWithContents($this->rootFolder, self::META_XML_FILE_NAME, $metaXmlFileContents); + + return $this; + } + + /** + * Creates the "mimetype" file under the root folder. + * + * @throws IOException If unable to create the file + */ + private function createMimetypeFile(): self + { + $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 + */ + private function copyFileContentsToTarget(string $sourceFilePath, $targetResource): void + { + $sourceHandle = fopen($sourceFilePath, 'r'); + \assert(false !== $sourceHandle); + stream_copy_to_stream($sourceHandle, $targetResource); + fclose($sourceHandle); + } +} diff --git a/upstream-4.x/src/Writer/ODS/Manager/Style/StyleManager.php b/upstream-4.x/src/Writer/ODS/Manager/Style/StyleManager.php new file mode 100644 index 0000000..dc0af0b --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Manager/Style/StyleManager.php @@ -0,0 +1,436 @@ +options = $options; + } + + /** + * Returns the content of the "styles.xml" file, given a list of styles. + * + * @param int $numWorksheets Number of worksheets created + */ + public function getStylesXMLFileContent(int $numWorksheets): string + { + $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. + */ + public function getContentXmlFontFaceSectionContent(): string + { + $content = ''; + foreach ($this->styleRegistry->getUsedFonts() as $fontName) { + $content .= ''; + } + $content .= ''; + + return $content; + } + + /** + * Returns the contents of the "" section, inside "content.xml" file. + * + * @param Worksheet[] $worksheets + */ + public function getContentXmlAutomaticStylesSectionContent(array $worksheets): string + { + $content = ''; + + foreach ($this->styleRegistry->getRegisteredStyles() as $style) { + $content .= $this->getStyleSectionContent($style); + } + + $useOptimalRowHeight = null === $this->options->DEFAULT_ROW_HEIGHT ? 'true' : 'false'; + $defaultRowHeight = null === $this->options->DEFAULT_ROW_HEIGHT ? '15pt' : "{$this->options->DEFAULT_ROW_HEIGHT}pt"; + $defaultColumnWidth = null === $this->options->DEFAULT_COLUMN_WIDTH ? '' : "style:column-width=\"{$this->options->DEFAULT_COLUMN_WIDTH}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 + $columnWidths = $this->options->getColumnWidths(); + usort($columnWidths, static function (ColumnWidth $a, ColumnWidth $b): int { + return $a->start <=> $b->start; + }); + $content .= $this->getTableColumnStylesXMLContent(); + + $content .= ''; + + return $content; + } + + public function getTableColumnStylesXMLContent(): string + { + if ([] === $this->options->getColumnWidths()) { + return ''; + } + + $content = ''; + foreach ($this->options->getColumnWidths() as $styleIndex => $columnWidth) { + $content .= << + + + EOD; + } + + return $content; + } + + public function getStyledTableColumnXMLContent(int $maxNumColumns): string + { + if ([] === $this->options->getColumnWidths()) { + return ''; + } + + $content = ''; + foreach ($this->options->getColumnWidths() as $styleIndex => $columnWidth) { + $numCols = $columnWidth->end - $columnWidth->start + 1; + $content .= << + EOD; + } + \assert(isset($columnWidth)); + // 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. + */ + private function getStylesSectionContent(): string + { + $defaultStyle = $this->getDefaultStyle(); + + return << + + + + + + + + + EOD; + } + + /** + * Returns the content of the "" section, inside "styles.xml" file. + * + * @param int $numWorksheets Number of worksheets created + */ + private function getMasterStylesSectionContent(int $numWorksheets): string + { + $content = ''; + + for ($i = 1; $i <= $numWorksheets; ++$i) { + $content .= << + + + + + + EOD; + } + + $content .= ''; + + return $content; + } + + /** + * Returns the content of the "" section, inside "styles.xml" file. + */ + private function getFontFaceSectionContent(): string + { + $content = ''; + foreach ($this->styleRegistry->getUsedFonts() as $fontName) { + $content .= ''; + } + $content .= ''; + + return $content; + } + + /** + * Returns the content of the "" section, inside "styles.xml" file. + * + * @param int $numWorksheets Number of worksheets created + */ + private function getAutomaticStylesSectionContent(int $numWorksheets): string + { + $content = ''; + + for ($i = 1; $i <= $numWorksheets; ++$i) { + $content .= << + + + + + EOD; + } + + $content .= ''; + + return $content; + } + + /** + * Returns the contents of the "" section, inside "" section. + */ + private function getStyleSectionContent(Style $style): string + { + $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. + */ + private function getTextPropertiesSectionContent(Style $style): string + { + if (!$style->shouldApplyFont()) { + return ''; + } + + return 'getFontSectionContent($style) + .'/>'; + } + + /** + * Returns the contents of the fonts definition section, inside "" section. + */ + private function getFontSectionContent(Style $style): string + { + $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. + */ + private function getParagraphPropertiesSectionContent(Style $style): string + { + if (!$style->shouldApplyCellAlignment() && !$style->shouldApplyCellVerticalAlignment()) { + return ''; + } + + return 'getCellAlignmentSectionContent($style) + .$this->getCellVerticalAlignmentSectionContent($style) + .'/>'; + } + + /** + * Returns the contents of the cell alignment definition for the "" section. + */ + private function getCellAlignmentSectionContent(Style $style): string + { + if (!$style->hasSetCellAlignment()) { + return ''; + } + + return \sprintf( + ' fo:text-align="%s" ', + $this->transformCellAlignment($style->getCellAlignment()) + ); + } + + /** + * Returns the contents of the cell vertical alignment definition for the "" section. + */ + private function getCellVerticalAlignmentSectionContent(Style $style): string + { + if (!$style->hasSetCellVerticalAlignment()) { + return ''; + } + + return \sprintf( + ' fo:vertical-align="%s" ', + $this->transformCellVerticalAlignment($style->getCellVerticalAlignment()) + ); + } + + /** + * 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. + */ + private function transformCellAlignment(string $cellAlignment): string + { + return match ($cellAlignment) { + CellAlignment::LEFT => 'start', + CellAlignment::RIGHT => 'end', + default => $cellAlignment, + }; + } + + /** + * Spec uses 'middle' rather than 'center' + * http://docs.oasis-open.org/office/v1.2/os/OpenDocument-v1.2-os-part1.html#__RefHeading__1420236_253892949. + */ + private function transformCellVerticalAlignment(string $cellVerticalAlignment): string + { + return (CellVerticalAlignment::CENTER === $cellVerticalAlignment) + ? 'middle' + : $cellVerticalAlignment; + } + + /** + * Returns the contents of the "" section, inside "" section. + */ + private function getTableCellPropertiesSectionContent(Style $style): string + { + $content = 'hasSetWrapText()) { + $content .= $this->getWrapTextXMLContent($style->shouldWrapText()); + } + + if (null !== ($border = $style->getBorder())) { + $content .= $this->getBorderXMLContent($border); + } + + if (null !== ($bgColor = $style->getBackgroundColor())) { + $content .= $this->getBackgroundColorXMLContent($bgColor); + } + + $content .= '/>'; + + return $content; + } + + /** + * Returns the contents of the wrap text definition for the "" section. + */ + private function getWrapTextXMLContent(bool $shouldWrapText): string + { + return ' fo:wrap-option="'.($shouldWrapText ? '' : 'no-').'wrap" style:vertical-align="automatic" '; + } + + /** + * Returns the contents of the borders definition for the "" section. + */ + private function getBorderXMLContent(Border $border): string + { + $borders = array_map(static function (BorderPart $borderPart) { + return BorderHelper::serializeBorderPart($borderPart); + }, $border->getParts()); + + return \sprintf(' %s ', implode(' ', $borders)); + } + + /** + * Returns the contents of the background color definition for the "" section. + */ + private function getBackgroundColorXMLContent(string $bgColor): string + { + return \sprintf(' fo:background-color="#%s" ', $bgColor); + } +} diff --git a/upstream-4.x/src/Writer/ODS/Manager/Style/StyleRegistry.php b/upstream-4.x/src/Writer/ODS/Manager/Style/StyleRegistry.php new file mode 100644 index 0000000..cdba608 --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Manager/Style/StyleRegistry.php @@ -0,0 +1,45 @@ + [FONT_NAME] => [] Map whose keys contain all the fonts used */ + private array $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): 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(): array + { + return array_keys($this->usedFontsSet); + } +} diff --git a/upstream-4.x/src/Writer/ODS/Manager/WorkbookManager.php b/upstream-4.x/src/Writer/ODS/Manager/WorkbookManager.php new file mode 100644 index 0000000..750e0ca --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Manager/WorkbookManager.php @@ -0,0 +1,73 @@ +getWorksheets(); + $numWorksheets = \count($worksheets); + + $this->fileSystemHelper + ->createContentFile($this->worksheetManager, $this->styleManager, $worksheets) + ->deleteWorksheetTempFolder() + ->createStylesFile($this->styleManager, $numWorksheets) + ->zipRootFolderAndCopyToStream($finalFilePointer) + ; + } +} diff --git a/upstream-4.x/src/Writer/ODS/Manager/WorksheetManager.php b/upstream-4.x/src/Writer/ODS/Manager/WorksheetManager.php new file mode 100644 index 0000000..19566ab --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Manager/WorksheetManager.php @@ -0,0 +1,273 @@ +styleManager = $styleManager; + $this->styleMerger = $styleMerger; + $this->stringsEscaper = $stringsEscaper; + } + + /** + * Prepares the worksheet to accept data. + * + * @param Worksheet $worksheet The worksheet to start + * + * @throws IOException If the sheet data file cannot be opened for writing + */ + public function startSheet(Worksheet $worksheet): void + { + $sheetFilePointer = fopen($worksheet->getFilePath(), 'w'); + \assert(false !== $sheetFilePointer); + + $worksheet->setFilePointer($sheetFilePointer); + } + + /** + * Returns the table XML root node as string. + * + * @return string "" node as string + */ + public function getTableElementStartAsString(Worksheet $worksheet): string + { + $externalSheet = $worksheet->getExternalSheet(); + $escapedSheetName = $this->stringsEscaper->escape($externalSheet->getName()); + $tableStyleName = 'ta'.($externalSheet->getIndex() + 1); + + $tableElement = ''; + $tableElement .= $this->styleManager->getStyledTableColumnXMLContent($worksheet->getMaxNumColumns()); + + return $tableElement; + } + + /** + * Returns the table:database-range XML node for AutoFilter as string. + */ + public function getTableDatabaseRangeElementAsString(Worksheet $worksheet): string + { + $externalSheet = $worksheet->getExternalSheet(); + $escapedSheetName = $this->stringsEscaper->escape($externalSheet->getName()); + $databaseRange = ''; + + if (null !== $autofilter = $externalSheet->getAutoFilter()) { + $rangeAddress = \sprintf( + '\'%s\'.%s%s:\'%s\'.%s%s', + $escapedSheetName, + CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex), + $autofilter->fromRow, + $escapedSheetName, + CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex), + $autofilter->toRow + ); + $databaseRange = ''; + } + + return $databaseRange; + } + + /** + * 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): void + { + $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): void + { + fclose($worksheet->getFilePointer()); + } + + /** + * 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 + * + * @return string The cell XML content + * + * @throws InvalidArgumentException If a cell value's type is not supported + */ + private function getCellXML(Cell $cell, int $styleIndex, int $numTimesValueRepeated): string + { + $data = 'getValue()); + foreach ($cellValueLines as $cellValueLine) { + $data .= ''.$this->stringsEscaper->escape($cellValueLine).''; + } + + $data .= ''; + } elseif ($cell instanceof Cell\BooleanCell) { + $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 instanceof Cell\NumericCell) { + $cellValue = $cell->getValue(); + $data .= ' office:value-type="float" calcext:value-type="float" office:value="'.$cellValue.'">'; + $data .= ''.$cellValue.''; + $data .= ''; + } elseif ($cell instanceof Cell\DateTimeCell) { + $datevalue = substr((new DateTimeImmutable('@'.$cell->getValue()->getTimestamp()))->format(DateTimeInterface::W3C), 0, -6); + $data .= ' office:value-type="date" calcext:value-type="date" office:date-value="'.$datevalue.'Z">'; + $data .= ''.$datevalue.'Z'; + $data .= ''; + } elseif ($cell instanceof Cell\DateIntervalCell) { + // 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, $cell->getValue()->format('P%yY%mM%dDT%hH%iM%sS')), 'PT') ?: 'PT0S'; + $data .= ' office:value-type="time" office:time-value="'.$value.'">'; + $data .= ''.$value.''; + $data .= ''; + } elseif ($cell instanceof Cell\ErrorCell) { + // only writes the error value if it's a string + $data .= ' office:value-type="string" calcext:value-type="error" office:value="">'; + $data .= ''.$cell->getRawValue().''; + $data .= ''; + } elseif ($cell instanceof Cell\EmptyCell) { + $data .= '/>'; + } + + return $data; + } +} diff --git a/upstream-4.x/src/Writer/ODS/Options.php b/upstream-4.x/src/Writer/ODS/Options.php new file mode 100644 index 0000000..ab26415 --- /dev/null +++ b/upstream-4.x/src/Writer/ODS/Options.php @@ -0,0 +1,9 @@ +options = $options ?? new Options(); + } + + public function getOptions(): Options + { + return $this->options; + } + + protected function createWorkbookManager(): WorkbookManager + { + $workbook = new Workbook(); + + $fileSystemHelper = new FileSystemHelper($this->options->getTempFolder(), new ZipHelper(), $this->creator); + $fileSystemHelper->createBaseFilesAndFolders(); + + $styleMerger = new StyleMerger(); + $styleManager = new StyleManager(new StyleRegistry($this->options->DEFAULT_ROW_STYLE), $this->options); + $worksheetManager = new WorksheetManager($styleManager, $styleMerger, new ODS()); + + return new WorkbookManager( + $workbook, + $this->options, + $worksheetManager, + $styleManager, + $styleMerger, + $fileSystemHelper + ); + } +} diff --git a/upstream-4.x/src/Writer/WriterInterface.php b/upstream-4.x/src/Writer/WriterInterface.php new file mode 100644 index 0000000..272eba9 --- /dev/null +++ b/upstream-4.x/src/Writer/WriterInterface.php @@ -0,0 +1,71 @@ +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 positive-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'); + } + + $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(). + ''; + } + + private function getSheetViewAttributes(): string + { + return $this->generateAttributes([ + 'showFormulas' => $this->showFormulas, + 'showGridLines' => $this->showGridLines, + 'showRowColHeaders' => $this->showRowColHeaders, + 'showZeroes' => $this->showZeroes, + 'rightToLeft' => $this->rightToLeft, + 'tabSelected' => $this->tabSelected, + 'showOutlineSymbols' => $this->showOutlineSymbols, + 'defaultGridColor' => $this->defaultGridColor, + 'view' => $this->view, + 'topLeftCell' => $this->topLeftCell, + 'colorId' => $this->colorId, + 'zoomScale' => $this->zoomScale, + 'zoomScaleNormal' => $this->zoomScaleNormal, + 'zoomScalePageLayoutView' => $this->zoomScalePageLayoutView, + 'workbookViewId' => $this->workbookViewId, + ]); + } + + private 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 + */ + private function generateAttributes(array $data): string + { + // Create attribute for each key + $attributes = array_map(static function (string $key, bool|int|string $value): string { + 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-4.x/src/Writer/XLSX/Helper/BorderHelper.php b/upstream-4.x/src/Writer/XLSX/Helper/BorderHelper.php new file mode 100644 index 0000000..c2ce1d8 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Helper/BorderHelper.php @@ -0,0 +1,70 @@ + [ + 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', + ], + ]; + + public static function serializeBorderPart(?BorderPart $borderPart): string + { + if (null === $borderPart) { + return ''; + } + + $borderStyle = self::getBorderStyle($borderPart); + + $colorEl = \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. + */ + private static function getBorderStyle(BorderPart $borderPart): string + { + return self::xlsxStyleMap[$borderPart->getStyle()][$borderPart->getWidth()]; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Helper/DateHelper.php b/upstream-4.x/src/Writer/XLSX/Helper/DateHelper.php new file mode 100644 index 0000000..8fc4d34 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Helper/DateHelper.php @@ -0,0 +1,57 @@ +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 = 1; + if ((1900 === $year) && ($month <= 2)) { + $excel1900isLeapYear = 0; + } + $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 $excelDate + $excelTime; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Helper/DateIntervalHelper.php b/upstream-4.x/src/Writer/XLSX/Helper/DateIntervalHelper.php new file mode 100644 index 0000000..5fc5017 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Helper/DateIntervalHelper.php @@ -0,0 +1,41 @@ +y * 365.25 + + $interval->m * 30.437 + + $interval->d + + $interval->h / 24 + + $interval->i / 24 / 60 + + $interval->s / 24 / 60 / 60; + + if (1 === $interval->invert) { + $days *= -1; + } + + return $days; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Helper/FileSystemHelper.php b/upstream-4.x/src/Writer/XLSX/Helper/FileSystemHelper.php new file mode 100644 index 0000000..c9803d1 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Helper/FileSystemHelper.php @@ -0,0 +1,755 @@ + + + EOD; + + private readonly string $baseFolderRealPath; + private readonly CommonFileSystemHelper $baseFileSystemHelper; + + /** @var ZipHelper Helper to perform tasks with Zip archive */ + private readonly ZipHelper $zipHelper; + + /** @var string document creator */ + private readonly string $creator; + + /** @var XLSX Used to escape XML data */ + private readonly XLSX $escaper; + + /** @var string Path to the root folder inside the temp folder where the files to create the XLSX will be stored */ + private string $rootFolder; + + /** @var string Path to the "_rels" folder inside the root folder */ + private string $relsFolder; + + /** @var string Path to the "docProps" folder inside the root folder */ + private string $docPropsFolder; + + /** @var string Path to the "xl" folder inside the root folder */ + private string $xlFolder; + + /** @var string Path to the "_rels" folder inside the "xl" folder */ + private string $xlRelsFolder; + + /** @var string Path to the "worksheets" folder inside the "xl" folder */ + private string $xlWorksheetsFolder; + + /** @var string Path to the temp folder, inside the root folder, where specific sheets content will be written to */ + private string $sheetsContentTempFolder; + + /** + * @param string $baseFolderPath The path of the base folder where all the I/O can occur + * @param ZipHelper $zipHelper Helper to perform tasks with Zip archive + * @param XLSX $escaper Used to escape XML data + * @param string $creator document creator + */ + public function __construct(string $baseFolderPath, ZipHelper $zipHelper, XLSX $escaper, string $creator) + { + $this->baseFileSystemHelper = new CommonFileSystemHelper($baseFolderPath); + $this->baseFolderRealPath = $this->baseFileSystemHelper->getBaseFolderRealPath(); + $this->zipHelper = $zipHelper; + $this->escaper = $escaper; + $this->creator = $creator; + } + + public function createFolder(string $parentFolderPath, string $folderName): string + { + return $this->baseFileSystemHelper->createFolder($parentFolderPath, $folderName); + } + + public function createFileWithContents(string $parentFolderPath, string $fileName, string $fileContents): string + { + return $this->baseFileSystemHelper->createFileWithContents($parentFolderPath, $fileName, $fileContents); + } + + public function deleteFile(string $filePath): void + { + $this->baseFileSystemHelper->deleteFile($filePath); + } + + public function deleteFolderRecursively(string $folderPath): void + { + $this->baseFileSystemHelper->deleteFolderRecursively($folderPath); + } + + public function getRootFolder(): string + { + return $this->rootFolder; + } + + public function getXlFolder(): string + { + return $this->xlFolder; + } + + public function getXlWorksheetsFolder(): string + { + return $this->xlWorksheetsFolder; + } + + public function getSheetsContentTempFolder(): string + { + return $this->sheetsContentTempFolder; + } + + /** + * Creates all the folders needed to create a XLSX file, as well as the files that won't change. + * + * @throws IOException If unable to create at least one of the base folders + */ + public function createBaseFilesAndFolders(): void + { + $this + ->createRootFolder() + ->createRelsFolderAndFile() + ->createDocPropsFolderAndFiles() + ->createXlFolderAndSubFolders() + ->createSheetsContentTempFolder() + ; + } + + /** + * Creates the "[Content_Types].xml" file under the root folder. + * + * @param Worksheet[] $worksheets + */ + public function createContentTypesFile(array $worksheets): self + { + $contentTypesXmlFileContents = <<<'EOD' + + + + + + + EOD; + + /** @var Worksheet $worksheet */ + foreach ($worksheets as $worksheet) { + $contentTypesXmlFileContents .= ''; + $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 + */ + public function createWorkbookFile(Options $options, array $worksheets): self + { + $workbookXmlFileContents = <<<'EOD' + + + EOD; + + if (null !== $options->getWorkbookProtection()) { + $workbookXmlFileContents .= $options->getWorkbookProtection()->getXml(); + } + + $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; + + $definedNames = ''; + + /** @var Worksheet $worksheet */ + foreach ($worksheets as $worksheet) { + $sheet = $worksheet->getExternalSheet(); + if (null !== $autofilter = $sheet->getAutoFilter()) { + $worksheetName = $sheet->getName(); + $name = \sprintf( + '\'%s\'!$%s$%s:$%s$%s', + $this->escaper->escape($worksheetName), + CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex), + $autofilter->fromRow, + CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex), + $autofilter->toRow + ); + $definedNames .= ''; + } + if (null !== $printTitleRows = $sheet->getPrintTitleRows()) { + $definedNames .= ''.$this->escaper->escape($sheet->getName()).'!'.$printTitleRows.''; + } + } + if ('' !== $definedNames) { + $workbookXmlFileContents .= ''.$definedNames.''; + } + + $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 + */ + public function createWorkbookRelsFile(array $worksheets): self + { + $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; + } + + /** + * Create the "rels" file for a given worksheet. This contains relations to the comments.xml and drawing.vml files for this worksheet. + * + * @param Worksheet[] $worksheets + */ + public function createWorksheetRelsFiles(array $worksheets): self + { + $this->createFolder($this->getXlWorksheetsFolder(), self::RELS_FOLDER_NAME); + + foreach ($worksheets as $worksheet) { + $worksheetId = $worksheet->getId(); + $worksheetRelsContent = ' + + + + '; + + $folder = $this->getXlWorksheetsFolder().\DIRECTORY_SEPARATOR.'_rels'; + $filename = 'sheet'.$worksheetId.'.xml.rels'; + + $this->createFileWithContents($folder, $filename, $worksheetRelsContent); + } + + return $this; + } + + /** + * Creates the "styles.xml" file under the "xl" folder. + */ + public function createStylesFile(StyleManager $styleManager): self + { + $stylesXmlFileContents = $styleManager->getStylesXMLFileContent(); + $this->createFileWithContents($this->xlFolder, self::STYLES_XML_FILE_NAME, $stylesXmlFileContents); + + return $this; + } + + /** + * Creates the "content.xml" file under the root folder. + * + * @param Worksheet[] $worksheets + */ + public function createContentFiles(Options $options, array $worksheets): self + { + $allMergeCells = $options->getMergeCells(); + $pageSetup = $options->getPageSetup(); + foreach ($worksheets as $worksheet) { + $contentXmlFilePath = $this->getXlWorksheetsFolder().\DIRECTORY_SEPARATOR.basename($worksheet->getFilePath()); + $worksheetFilePointer = fopen($contentXmlFilePath, 'w'); + \assert(false !== $worksheetFilePointer); + + $sheet = $worksheet->getExternalSheet(); + fwrite($worksheetFilePointer, self::SHEET_XML_FILE_HEADER); + + // AutoFilter tags + if (null !== $autofilter = $sheet->getAutoFilter()) { + if (isset($pageSetup) && $pageSetup->fitToPage) { + fwrite($worksheetFilePointer, ''); + } else { + fwrite($worksheetFilePointer, ''); + } + } elseif (isset($pageSetup) && $pageSetup->fitToPage) { + fwrite($worksheetFilePointer, ''); + } + $sheetRange = \sprintf( + '%s%s:%s%s', + CellHelper::getColumnLettersFromColumnIndex(0), + 1, + CellHelper::getColumnLettersFromColumnIndex($worksheet->getMaxNumColumns() - 1), + $worksheet->getLastWrittenRowIndex() + ); + fwrite($worksheetFilePointer, \sprintf('', $sheetRange)); + if (null !== ($sheetView = $sheet->getSheetView())) { + fwrite($worksheetFilePointer, ''.$sheetView->getXml().''); + } + fwrite($worksheetFilePointer, $this->getXMLFragmentForDefaultCellSizing($options)); + fwrite($worksheetFilePointer, $this->getXMLFragmentForColumnWidths($options, $sheet)); + fwrite($worksheetFilePointer, ''); + + $worksheetFilePath = $worksheet->getFilePath(); + $this->copyFileContentsToTarget($worksheetFilePath, $worksheetFilePointer); + fwrite($worksheetFilePointer, ''); + + // AutoFilter tag + if (null !== $autofilter) { + $autoFilterRange = \sprintf( + '%s%s:%s%s', + CellHelper::getColumnLettersFromColumnIndex($autofilter->fromColumnIndex), + $autofilter->fromRow, + CellHelper::getColumnLettersFromColumnIndex($autofilter->toColumnIndex), + $autofilter->toRow + ); + fwrite($worksheetFilePointer, \sprintf('', $autoFilterRange)); + } + + // create nodes for merge cells + $mergeCells = array_filter( + $allMergeCells, + static fn (MergeCell $c) => $c->sheetIndex === $worksheet->getExternalSheet()->getIndex(), + ); + if ([] !== $mergeCells) { + $mergeCellString = ''; + foreach ($mergeCells as $mergeCell) { + $topLeft = CellHelper::getColumnLettersFromColumnIndex($mergeCell->topLeftColumn).$mergeCell->topLeftRow; + $bottomRight = CellHelper::getColumnLettersFromColumnIndex($mergeCell->bottomRightColumn).$mergeCell->bottomRightRow; + $mergeCellString .= \sprintf( + '', + $topLeft, + $bottomRight + ); + } + $mergeCellString .= ''; + fwrite($worksheetFilePointer, $mergeCellString); + } + + $this->getXMLFragmentForPageMargin($worksheetFilePointer, $options); + + $this->getXMLFragmentForPageSetup($worksheetFilePointer, $options); + + $this->getXMLFragmentForHeaderFooter($worksheetFilePointer, $options); + + // Add the legacy drawing for comments + fwrite($worksheetFilePointer, ''); + + if (null !== $sheet->getSheetProtection()) { + fwrite($worksheetFilePointer, $sheet->getSheetProtection()->getXml()); + } + + fwrite($worksheetFilePointer, ''); + fclose($worksheetFilePointer); + } + + return $this; + } + + /** + * Deletes the temporary folder where sheets content was stored. + */ + public function deleteWorksheetTempFolder(): self + { + $this->deleteFolderRecursively($this->sheetsContentTempFolder); + + 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): void + { + $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.\DIRECTORY_SEPARATOR.self::WORKBOOK_XML_FILE_NAME); + $this->zipHelper->addFileToArchive($zip, $this->rootFolder, self::XL_FOLDER_NAME.\DIRECTORY_SEPARATOR.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); + } + + /** + * @param resource $targetResource + */ + private function getXMLFragmentForPageMargin($targetResource, Options $options): void + { + $pageMargin = $options->getPageMargin(); + if (null === $pageMargin) { + return; + } + + fwrite($targetResource, "top}\" right=\"{$pageMargin->right}\" bottom=\"{$pageMargin->bottom}\" left=\"{$pageMargin->left}\" header=\"{$pageMargin->header}\" footer=\"{$pageMargin->footer}\"/>"); + } + + /** + * @param resource $targetResource + */ + private function getXMLFragmentForHeaderFooter($targetResource, Options $options): void + { + $headerFooter = $options->getHeaderFooter(); + if (null === $headerFooter) { + return; + } + + $xml = 'differentOddEven) { + $xml .= " differentOddEven=\"{$headerFooter->differentOddEven}\""; + } + + $xml .= '>'; + + if (null !== $headerFooter->oddHeader) { + $xml .= "{$headerFooter->oddHeader}"; + } + + if (null !== $headerFooter->oddFooter) { + $xml .= "{$headerFooter->oddFooter}"; + } + + if ($headerFooter->differentOddEven) { + if (null !== $headerFooter->evenHeader) { + $xml .= "{$headerFooter->evenHeader}"; + } + + if (null !== $headerFooter->evenFooter) { + $xml .= "{$headerFooter->evenFooter}"; + } + } + + $xml .= ''; + + fwrite($targetResource, $xml); + } + + /** + * @param resource $targetResource + */ + private function getXMLFragmentForPageSetup($targetResource, Options $options): void + { + $pageSetup = $options->getPageSetup(); + if (null === $pageSetup) { + return; + } + + $xml = 'pageOrientation) { + $xml .= " orientation=\"{$pageSetup->pageOrientation->value}\""; + } + + if (null !== $pageSetup->paperSize) { + $xml .= " paperSize=\"{$pageSetup->paperSize->value}\""; + } + + if (null !== $pageSetup->fitToHeight) { + $xml .= " fitToHeight=\"{$pageSetup->fitToHeight}\""; + } + + if (null !== $pageSetup->fitToWidth) { + $xml .= " fitToWidth=\"{$pageSetup->fitToWidth}\""; + } + + $xml .= '/>'; + + fwrite($targetResource, $xml); + } + + /** + * Construct column width references xml to inject into worksheet xml file. + */ + private function getXMLFragmentForColumnWidths(Options $options, Sheet $sheet): string + { + if ([] !== $sheet->getColumnWidths()) { + $widths = $sheet->getColumnWidths(); + } elseif ([] !== $options->getColumnWidths()) { + $widths = $options->getColumnWidths(); + } else { + return ''; + } + + $xml = ''; + + foreach ($widths as $columnWidth) { + $xml .= ''; + } + $xml .= ''; + + return $xml; + } + + /** + * Constructs default row height and width xml to inject into worksheet xml file. + */ + private function getXMLFragmentForDefaultCellSizing(Options $options): string + { + $rowHeightXml = null === $options->DEFAULT_ROW_HEIGHT ? '' : " defaultRowHeight=\"{$options->DEFAULT_ROW_HEIGHT}\""; + $colWidthXml = null === $options->DEFAULT_COLUMN_WIDTH ? '' : " defaultColWidth=\"{$options->DEFAULT_COLUMN_WIDTH}\""; + if ('' === $colWidthXml && '' === $rowHeightXml) { + return ''; + } + // Ensure that the required defaultRowHeight is set + $rowHeightXml = '' === $rowHeightXml ? ' defaultRowHeight="0"' : $rowHeightXml; + + return ""; + } + + /** + * Creates the folder that will be used as root. + * + * @throws IOException If unable to create the folder + */ + private function createRootFolder(): self + { + $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 IOException If unable to create the folder or the ".rels" file + */ + private function createRelsFolderAndFile(): self + { + $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 IOException If unable to create the file + */ + private function createRelsFile(): self + { + $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 IOException If unable to create the folder or one of the files + */ + private function createDocPropsFolderAndFiles(): self + { + $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 IOException If unable to create the file + */ + private function createAppXmlFile(): self + { + $appXmlFileContents = << + + {$this->creator} + 0 + + EOD; + + $this->createFileWithContents($this->docPropsFolder, self::APP_XML_FILE_NAME, $appXmlFileContents); + + return $this; + } + + /** + * Creates the "core.xml" file under the "docProps" folder. + * + * @throws IOException If unable to create the file + */ + private function createCoreXmlFile(): self + { + $createdDate = (new DateTimeImmutable())->format(DateTimeImmutable::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 IOException If unable to create at least one of the folders + */ + private function createXlFolderAndSubFolders(): self + { + $this->xlFolder = $this->createFolder($this->rootFolder, self::XL_FOLDER_NAME); + $this->createXlRelsFolder(); + $this->createXlWorksheetsFolder(); + $this->createDrawingsFolder(); + + 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 IOException If unable to create the folder + */ + private function createSheetsContentTempFolder(): self + { + $this->sheetsContentTempFolder = $this->createFolder($this->rootFolder, 'worksheets-temp'); + + return $this; + } + + /** + * Creates the "_rels" folder under the "xl" folder. + * + * @throws IOException If unable to create the folder + */ + private function createXlRelsFolder(): self + { + $this->xlRelsFolder = $this->createFolder($this->xlFolder, self::RELS_FOLDER_NAME); + + return $this; + } + + /** + * Creates the "drawings" folder under the "xl" folder. + * + * @throws IOException If unable to create the folder + */ + private function createDrawingsFolder(): self + { + $this->createFolder($this->getXlFolder(), self::DRAWINGS_FOLDER_NAME); + + return $this; + } + + /** + * Creates the "worksheets" folder under the "xl" folder. + * + * @throws IOException If unable to create the folder + */ + private function createXlWorksheetsFolder(): self + { + $this->xlWorksheetsFolder = $this->createFolder($this->xlFolder, self::WORKSHEETS_FOLDER_NAME); + + 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 + */ + private function copyFileContentsToTarget(string $sourceFilePath, $targetResource): void + { + $sourceHandle = fopen($sourceFilePath, 'r'); + \assert(false !== $sourceHandle); + stream_copy_to_stream($sourceHandle, $targetResource); + fclose($sourceHandle); + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Helper/PasswordHashHelper.php b/upstream-4.x/src/Writer/XLSX/Helper/PasswordHashHelper.php new file mode 100644 index 0000000..2872792 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Helper/PasswordHashHelper.php @@ -0,0 +1,30 @@ += 0; --$i) { + $intermediate1 = (($verifier & 0x4000) === 0) ? 0 : 1; + $intermediate2 = 2 * $verifier; + $intermediate2 &= 0x7FFF; + $intermediate3 = $intermediate1 | $intermediate2; + $verifier = $intermediate3 ^ \ord($passwordArray[$i]); + } + + $verifier ^= 0xCE4B; + + return strtoupper(dechex($verifier)); + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Manager/CommentsManager.php b/upstream-4.x/src/Writer/XLSX/Manager/CommentsManager.php new file mode 100644 index 0000000..b86c1a4 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/CommentsManager.php @@ -0,0 +1,225 @@ + + + Unknown + + EOD; + + public const COMMENTS_XML_FILE_FOOTER = <<<'EOD' + + + EOD; + + public const DRAWINGS_VML_FILE_HEADER = <<<'EOD' + + + + + + + + + + EOD; + + public const DRAWINGS_VML_FILE_FOOTER = <<<'EOD' + + EOD; + + /** + * File-pointers to the commentsX.xml files, where the index is the id of the worksheet. + * + * @var resource[] + */ + private array $commentsFilePointers = []; + + /** + * File-pointers to the vmlDrawingX.vml files, where the index is the id of the worksheet. + * + * @var resource[] + */ + private array $drawingFilePointers = []; + + private readonly string $xlFolder; + + private int $shapeId = 1024; + + private readonly Escaper\XLSX $stringsEscaper; + + /** + * @param string $xlFolder Path to the "xl" folder + */ + public function __construct(string $xlFolder, Escaper\XLSX $stringsEscaper) + { + $this->xlFolder = $xlFolder; + $this->stringsEscaper = $stringsEscaper; + } + + /** + * Create the two comment-files for the given worksheet. + */ + public function createWorksheetCommentFiles(Worksheet $sheet): void + { + $sheetId = $sheet->getId(); + $commentFp = fopen($this->getCommentsFilePath($sheet), 'w'); + \assert(false !== $commentFp); + + $drawingFp = fopen($this->getDrawingFilePath($sheet), 'w'); + \assert(false !== $drawingFp); + + fwrite($commentFp, self::COMMENTS_XML_FILE_HEADER); + fwrite($drawingFp, self::DRAWINGS_VML_FILE_HEADER); + + $this->commentsFilePointers[$sheetId] = $commentFp; + $this->drawingFilePointers[$sheetId] = $drawingFp; + } + + /** + * Close the two comment-files for the given worksheet. + */ + public function closeWorksheetCommentFiles(Worksheet $sheet): void + { + $sheetId = $sheet->getId(); + + $commentFp = $this->commentsFilePointers[$sheetId]; + $drawingFp = $this->drawingFilePointers[$sheetId]; + + fwrite($commentFp, self::COMMENTS_XML_FILE_FOOTER); + fwrite($drawingFp, self::DRAWINGS_VML_FILE_FOOTER); + + fclose($commentFp); + fclose($drawingFp); + } + + public function addComments(Worksheet $worksheet, Row $row): void + { + $rowIndexZeroBased = 0 + $worksheet->getLastWrittenRowIndex(); + foreach ($row->getCells() as $columnIndexZeroBased => $cell) { + if (null === $cell->comment) { + continue; + } + + $this->addXmlComment($worksheet->getId(), $rowIndexZeroBased, $columnIndexZeroBased, $cell->comment); + $this->addVmlComment($worksheet->getId(), $rowIndexZeroBased, $columnIndexZeroBased, $cell->comment); + } + } + + /** + * @return string The file path where the comments for the given sheet will be stored + */ + private function getCommentsFilePath(Worksheet $sheet): string + { + return $this->xlFolder.\DIRECTORY_SEPARATOR.'comments'.$sheet->getId().'.xml'; + } + + /** + * @return string The file path where the VML comments for the given sheet will be stored + */ + private function getDrawingFilePath(Worksheet $sheet): string + { + return $this->xlFolder.\DIRECTORY_SEPARATOR.'drawings'.\DIRECTORY_SEPARATOR.'vmlDrawing'.$sheet->getId().'.vml'; + } + + /** + * Add a comment to the commentsX.xml file. + * + * @param int $sheetId The id of the sheet (starting with 1) + * @param int $rowIndexZeroBased The row index, starting at 0, of the cell with the comment + * @param int $columnIndexZeroBased The column index, starting at 0, of the cell with the comment + * @param Comment $comment The actual comment + */ + private function addXmlComment(int $sheetId, int $rowIndexZeroBased, int $columnIndexZeroBased, Comment $comment): void + { + $commentsFilePointer = $this->commentsFilePointers[$sheetId]; + $rowIndexOneBased = $rowIndexZeroBased + 1; + $columnLetters = CellHelper::getColumnLettersFromColumnIndex($columnIndexZeroBased); + + $commentxml = ''; + foreach ($comment->getTextRuns() as $line) { + $commentxml .= ''; + $commentxml .= ' '; + if ($line->bold) { + $commentxml .= ' '; + } + if ($line->italic) { + $commentxml .= ' '; + } + $commentxml .= ' '; + $commentxml .= ' '; + $commentxml .= ' '; + $commentxml .= ' '; + $commentxml .= ' '; + $commentxml .= ' '.$this->stringsEscaper->escape($line->text).''; + $commentxml .= ''; + } + $commentxml .= ''; + + fwrite($commentsFilePointer, $commentxml); + } + + /** + * Add a comment to the vmlDrawingX.vml file. + * + * @param int $sheetId The id of the sheet (starting with 1) + * @param int $rowIndexZeroBased The row index, starting at 0, of the cell with the comment + * @param int $columnIndexZeroBased The column index, starting at 0, of the cell with the comment + * @param Comment $comment The actual comment + */ + private function addVmlComment(int $sheetId, int $rowIndexZeroBased, int $columnIndexZeroBased, Comment $comment): void + { + $drawingFilePointer = $this->drawingFilePointers[$sheetId]; + ++$this->shapeId; + + $style = 'position:absolute;z-index:1'; + $style .= ';margin-left:'.$comment->marginLeft; + $style .= ';margin-top:'.$comment->marginTop; + $style .= ';width:'.$comment->width; + $style .= ';height:'.$comment->height; + if (!$comment->visible) { + $style .= ';visibility:hidden'; + } + + $drawingVml = ''; + $drawingVml .= ''; + $drawingVml .= ''; + $drawingVml .= ''; + $drawingVml .= ''; + $drawingVml .= '
'; + $drawingVml .= ''; + $drawingVml .= ''; + $drawingVml .= ' '; + $drawingVml .= ' '; + $drawingVml .= ' False'; + $drawingVml .= ' '.$rowIndexZeroBased.''; + $drawingVml .= ' '.$columnIndexZeroBased.''; + $drawingVml .= ''; + $drawingVml .= ''; + + fwrite($drawingFilePointer, $drawingVml); + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Manager/SharedStringsManager.php b/upstream-4.x/src/Writer/XLSX/Manager/SharedStringsManager.php new file mode 100644 index 0000000..c47c45e --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/SharedStringsManager.php @@ -0,0 +1,86 @@ + + sharedStringsFilePointer = $resource; + + // 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. + * + * @return int ID of the written shared string + */ + public function writeString(string $string): int + { + 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(): void + { + fwrite($this->sharedStringsFilePointer, ''); + + // Replace the default strings count with the actual number of shared strings in the file header + $firstPartHeaderLength = \strlen(self::SHARED_STRINGS_XML_FILE_FIRST_PART_HEADER); + $defaultStringsCountPartLength = \strlen(self::DEFAULT_STRINGS_COUNT_PART); + + // Adding 1 to take into account the space between the last xml attribute and "count" + fseek($this->sharedStringsFilePointer, $firstPartHeaderLength + 1); + fwrite($this->sharedStringsFilePointer, \sprintf("%-{$defaultStringsCountPartLength}s", 'count="'.$this->numSharedStrings.'" uniqueCount="'.$this->numSharedStrings.'"')); + + fclose($this->sharedStringsFilePointer); + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Manager/Style/StyleManager.php b/upstream-4.x/src/Writer/XLSX/Manager/Style/StyleManager.php new file mode 100644 index 0000000..1ea8add --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/Style/StyleManager.php @@ -0,0 +1,327 @@ +stringsEscaper = $stringsEscaper; + } + + /** + * For empty cells, we can specify a style or not. If no style are specified, + * then the software default will be applied. But sometimes, it may be useful + * to override this default style, for instance if the cell should have a + * background color different than the default one or some borders + * (fonts property don't really matter here). + * + * @return bool Whether the cell should define a custom style + */ + public function shouldApplyStyleOnEmptyCell(?int $styleId): bool + { + if (null === $styleId) { + return false; + } + $associatedFillId = $this->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. + */ + public function getStylesXMLFileContent(): string + { + $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. + */ + private function getFormatsSectionContent(): string + { + $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. + */ + private function getFontsSectionContent(): string + { + $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. + */ + private function getFillsSectionContent(): string + { + $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. + */ + private function getBordersSectionContent(): string + { + $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) { + $style = $this->styleRegistry->getStyleFromStyleId($styleId); + $border = $style->getBorder(); + \assert(null !== $border); + $content .= ''; + + // @see https://github.com/box/spout/issues/271 + foreach (BorderPart::allowedNames as $partName) { + $content .= BorderHelper::serializeBorderPart($border->getPart($partName)); + } + + $content .= ''; + } + + $content .= ''; + + return $content; + } + + /** + * Returns the content of the "" section. + */ + private function getCellStyleXfsSectionContent(): string + { + return <<<'EOD' + + + + EOD; + } + + /** + * Returns the content of the "" section. + */ + private function getCellXfsSectionContent(): string + { + $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"', (bool) $style->getBorder()); + + if ($style->shouldApplyCellAlignment() || $style->shouldApplyCellVerticalAlignment() || $style->hasSetWrapText() || $style->shouldShrinkToFit() || $style->hasSetTextRotation()) { + $content .= ' applyAlignment="1">'; + $content .= 'shouldApplyCellAlignment()) { + $content .= \sprintf(' horizontal="%s"', $style->getCellAlignment()); + } + if ($style->shouldApplyCellVerticalAlignment()) { + $content .= \sprintf(' vertical="%s"', $style->getCellVerticalAlignment()); + } + if ($style->hasSetWrapText()) { + $content .= ' wrapText="'.($style->shouldWrapText() ? '1' : '0').'"'; + } + if ($style->shouldShrinkToFit()) { + $content .= ' shrinkToFit="true"'; + } + if ($style->hasSetTextRotation()) { + $content .= \sprintf(' textRotation="%s"', $style->textRotation()); + } + + $content .= '/>'; + $content .= ''; + } else { + $content .= '/>'; + } + } + + $content .= ''; + + return $content; + } + + /** + * Returns the content of the "" section. + */ + private function getCellStylesSectionContent(): string + { + return <<<'EOD' + + + + EOD; + } + + /** + * Returns the fill ID associated to the given style ID. + * For the default style, we don't a fill. + */ + private function getFillIdForStyleId(int $styleId): int + { + // 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. + */ + private function getBorderIdForStyleId(int $styleId): int + { + // 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. + */ + private function getFormatIdForStyleId(int $styleId): int + { + // 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-4.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php b/upstream-4.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php new file mode 100644 index 0000000..374638d --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/Style/StyleRegistry.php @@ -0,0 +1,254 @@ + 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 */ + private array $registeredFormats = []; + + /** @var array [STYLE_ID] => [FORMAT_ID] maps a style to a format declaration */ + private array $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 + */ + private int $formatIndex = 164; + + /** @var array */ + private array $registeredFills = []; + + /** @var array [STYLE_ID] => [FILL_ID] maps a style to a fill declaration */ + private array $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 + */ + private int $fillIndex = 2; + + /** @var array */ + private array $registeredBorders = []; + + /** @var array [STYLE_ID] => [BORDER_ID] maps a style to a border declaration */ + private array $styleIdToBorderMappingTable = []; + + /** + * XLSX specific operations on the registered styles. + */ + public function registerStyle(Style $style): Style + { + if ($style->isRegistered()) { + return $style; + } + + $registeredStyle = parent::registerStyle($style); + $this->registerFill($registeredStyle); + $this->registerFormat($registeredStyle); + $this->registerBorder($registeredStyle); + + return $registeredStyle; + } + + /** + * @return null|int Format ID associated to the given style ID + */ + public function getFormatIdForStyleId(int $styleId): ?int + { + return $this->styleIdToFormatsMappingTable[$styleId] ?? null; + } + + /** + * @return null|int Fill ID associated to the given style ID + */ + public function getFillIdForStyleId(int $styleId): ?int + { + return $this->styleIdToFillMappingTable[$styleId] ?? null; + } + + /** + * @return null|int Fill ID associated to the given style ID + */ + public function getBorderIdForStyleId(int $styleId): ?int + { + return $this->styleIdToBorderMappingTable[$styleId] ?? null; + } + + /** + * @return array + */ + public function getRegisteredFills(): array + { + return $this->registeredFills; + } + + /** + * @return array + */ + public function getRegisteredBorders(): array + { + return $this->registeredBorders; + } + + /** + * @return array + */ + public function getRegisteredFormats(): array + { + return $this->registeredFormats; + } + + /** + * Register a format definition. + */ + private function registerFormat(Style $style): void + { + $styleId = $style->getId(); + + $format = $style->getFormat(); + if (null !== $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): void + { + $styleId = $style->getId(); + + // Currently - only solid backgrounds are supported + // so $backgroundColor is a scalar value (RGB Color) + $backgroundColor = $style->getBackgroundColor(); + + if (null !== $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): void + { + $styleId = $style->getId(); + + if (null !== ($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-4.x/src/Writer/XLSX/Manager/WorkbookManager.php b/upstream-4.x/src/Writer/XLSX/Manager/WorkbookManager.php new file mode 100644 index 0000000..5a625fc --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/WorkbookManager.php @@ -0,0 +1,85 @@ +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): void + { + $worksheets = $this->getWorksheets(); + + $this->fileSystemHelper + ->createContentFiles($this->options, $worksheets) + ->deleteWorksheetTempFolder() + ->createContentTypesFile($worksheets) + ->createWorkbookFile($this->options, $worksheets) + ->createWorkbookRelsFile($worksheets) + ->createWorksheetRelsFiles($worksheets) + ->createStylesFile($this->styleManager) + ->zipRootFolderAndCopyToStream($finalFilePointer) + ; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Manager/WorksheetManager.php b/upstream-4.x/src/Writer/XLSX/Manager/WorksheetManager.php new file mode 100644 index 0000000..e45e910 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Manager/WorksheetManager.php @@ -0,0 +1,246 @@ +options = $options; + $this->styleManager = $styleManager; + $this->styleMerger = $styleMerger; + $this->commentsManager = $commentsManager; + $this->sharedStringsManager = $sharedStringsManager; + $this->stringsEscaper = $stringsEscaper; + $this->stringHelper = $stringHelper; + } + + public function getSharedStringsManager(): SharedStringsManager + { + return $this->sharedStringsManager; + } + + public function startSheet(Worksheet $worksheet): void + { + $sheetFilePointer = fopen($worksheet->getFilePath(), 'w'); + \assert(false !== $sheetFilePointer); + + $worksheet->setFilePointer($sheetFilePointer); + $this->commentsManager->createWorksheetCommentFiles($worksheet); + } + + public function addRow(Worksheet $worksheet, Row $row): void + { + if (!$row->isEmpty()) { + $this->addNonEmptyRow($worksheet, $row); + $this->commentsManager->addComments($worksheet, $row); + } + + $worksheet->setLastWrittenRowIndex($worksheet->getLastWrittenRowIndex() + 1); + } + + public function close(Worksheet $worksheet): void + { + $this->commentsManager->closeWorksheetCommentFiles($worksheet); + fclose($worksheet->getFilePointer()); + } + + /** + * 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): void + { + $sheetFilePointer = $worksheet->getFilePointer(); + $rowStyle = $row->getStyle(); + $rowIndexOneBased = $worksheet->getLastWrittenRowIndex() + 1; + $numCells = $row->getNumCells(); + + $rowHeight = $row->getHeight(); + $hasCustomHeight = ($this->options->DEFAULT_ROW_HEIGHT > 0 || $rowHeight > 0) ? '1' : '0'; + $rowXML = " 0 ? "ht=\"{$rowHeight}\" " : '')."customHeight=\"{$hasCustomHeight}\">"; + + 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. + * + * @throws InvalidArgumentException If the given value cannot be processed + */ + private function getCellXML(int $rowIndexOneBased, int $columnIndexZeroBased, Cell $cell, ?int $styleId): string + { + $columnLetters = CellHelper::getColumnLettersFromColumnIndex($columnIndexZeroBased); + $cellXML = 'getCellXMLFragmentForNonEmptyString($cell->getValue()); + } elseif ($cell instanceof Cell\BooleanCell) { + $cellXML .= ' t="b">'.(int) $cell->getValue().''; + } elseif ($cell instanceof Cell\NumericCell) { + $cellXML .= '>'.$cell->getValue().''; + } elseif ($cell instanceof Cell\FormulaCell) { + $cellXML .= '>'.$this->stringsEscaper->escape(substr($cell->getValue(), 1)).''; + } elseif ($cell instanceof Cell\DateTimeCell) { + $cellXML .= '>'.DateHelper::toExcel($cell->getValue()).''; + } elseif ($cell instanceof Cell\DateIntervalCell) { + $cellXML .= '>'.DateIntervalHelper::toExcel($cell->getValue()).''; + } elseif ($cell instanceof Cell\ErrorCell) { + // only writes the error value if it's a string + $cellXML .= ' t="e">'.$this->stringsEscaper->escape($cell->getRawValue()).''; + } elseif ($cell instanceof Cell\EmptyCell) { + 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 = ''; + } + } + + return $cellXML; + } + + /** + * Returns the XML fragment for a cell containing a non empty string. + * + * @param string $cellValue The cell value + * + * @return string The XML fragment representing the cell + * + * @throws InvalidArgumentException If the string exceeds the maximum number of characters allowed per cell + */ + private function getCellXMLFragmentForNonEmptyString(string $cellValue): string + { + 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->options->SHOULD_USE_INLINE_STRINGS) { + $cellXMLFragment = ' t="inlineStr">'.$this->stringsEscaper->escape($cellValue).''; + } else { + $sharedStringId = $this->sharedStringsManager->writeString($cellValue); + $cellXMLFragment = ' t="s">'.$sharedStringId.''; + } + + return $cellXMLFragment; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/MergeCell.php b/upstream-4.x/src/Writer/XLSX/MergeCell.php new file mode 100644 index 0000000..1d8cd88 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/MergeCell.php @@ -0,0 +1,26 @@ +setFontSize(self::DEFAULT_FONT_SIZE); + $defaultRowStyle->setFontName(self::DEFAULT_FONT_NAME); + + $this->DEFAULT_ROW_STYLE = $defaultRowStyle; + } + + /** + * Row coordinates are indexed from 1, columns from 0 (A = 0), + * so a merge B2:G2 looks like $writer->mergeCells(1, 2, 6, 2);. + * + * @param 0|positive-int $topLeftColumn + * @param positive-int $topLeftRow + * @param 0|positive-int $bottomRightColumn + * @param positive-int $bottomRightRow + * @param 0|positive-int $sheetIndex + */ + public function mergeCells( + int $topLeftColumn, + int $topLeftRow, + int $bottomRightColumn, + int $bottomRightRow, + int $sheetIndex = 0, + ): void { + $this->MERGE_CELLS[] = new MergeCell( + $sheetIndex, + $topLeftColumn, + $topLeftRow, + $bottomRightColumn, + $bottomRightRow + ); + } + + /** + * @return MergeCell[] + * + * @internal + */ + public function getMergeCells(): array + { + return $this->MERGE_CELLS; + } + + public function setPageMargin(PageMargin $pageMargin): void + { + $this->pageMargin = $pageMargin; + } + + public function getPageMargin(): ?PageMargin + { + return $this->pageMargin; + } + + public function setPageSetup(PageSetup $pageSetup): void + { + $this->pageSetup = $pageSetup; + } + + public function getPageSetup(): ?PageSetup + { + return $this->pageSetup; + } + + public function setHeaderFooter(HeaderFooter $headerFooter): void + { + $this->headerFooter = $headerFooter; + } + + public function getHeaderFooter(): ?HeaderFooter + { + return $this->headerFooter; + } + + public function getWorkbookProtection(): ?WorkbookProtection + { + return $this->workbookProtection; + } + + public function setWorkbookProtection(WorkbookProtection $workbookProtection): void + { + $this->workbookProtection = $workbookProtection; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Options/HeaderFooter.php b/upstream-4.x/src/Writer/XLSX/Options/HeaderFooter.php new file mode 100644 index 0000000..8a71360 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Options/HeaderFooter.php @@ -0,0 +1,16 @@ +fitToPage = null !== $fitToHeight || null !== $fitToWidth; + } +} diff --git a/upstream-4.x/src/Writer/XLSX/Options/PaperSize.php b/upstream-4.x/src/Writer/XLSX/Options/PaperSize.php new file mode 100644 index 0000000..2eb2491 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Options/PaperSize.php @@ -0,0 +1,34 @@ +getSheetViewAttributes().'>'; + } + + private function getSheetViewAttributes(): string + { + return $this->generateAttributes([ + 'password' => null !== $this->password ? PasswordHashHelper::make($this->password) : '', + 'sheet' => $this->lockSheet, + 'objects' => $this->lockObjects, + 'scenarios' => $this->lockScenarios, + 'formatCells' => $this->lockCellFormatting, + 'formatColumns' => $this->lockColumnFormatting, + 'formatRows' => $this->lockRowFormatting, + 'insertColumns' => $this->lockColumnInsert, + 'insertRows' => $this->lockRowInsert, + 'deleteColumns' => $this->lockColumnDelete, + 'deleteRows' => $this->lockRowDelete, + 'selectLockedCells' => $this->lockLockedCellSelection, + 'selectUnlockedCells' => $this->lockUnlockedCellsSelection, + 'autoFilter' => $this->lockAutoFilter, + 'sort' => $this->lockSort, + 'hyperlink' => $this->lockHyperlinkInsert, + 'pivotTables' => $this->lockPivotTables, + ]); + } + + /** + * @param array $data with key containing the attribute name and value containing the attribute value + */ + private function generateAttributes(array $data): string + { + // Create attribute for each key + $attributes = array_map(static function (string $key, bool|string $value): string { + 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-4.x/src/Writer/XLSX/Options/WorkbookProtection.php b/upstream-4.x/src/Writer/XLSX/Options/WorkbookProtection.php new file mode 100644 index 0000000..872dc53 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Options/WorkbookProtection.php @@ -0,0 +1,50 @@ +getSheetViewAttributes().'/>'; + } + + private function getSheetViewAttributes(): string + { + return $this->generateAttributes([ + 'workbookPassword' => null !== $this->password ? PasswordHashHelper::make($this->password) : '', + 'lockStructure' => $this->lockStructure, + 'lockWindows' => $this->lockWindows, + 'lockRevisions' => $this->lockRevisions, + ]); + } + + /** + * @param array $data with key containing the attribute name and value containing the attribute value + */ + private function generateAttributes(array $data): string + { + // Create attribute for each key + $attributes = array_map(static function (string $key, bool|string $value): string { + 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-4.x/src/Writer/XLSX/Writer.php b/upstream-4.x/src/Writer/XLSX/Writer.php new file mode 100644 index 0000000..73cfa51 --- /dev/null +++ b/upstream-4.x/src/Writer/XLSX/Writer.php @@ -0,0 +1,82 @@ +options = $options ?? new Options(); + } + + public function getOptions(): Options + { + return $this->options; + } + + protected function createWorkbookManager(): WorkbookManager + { + $workbook = new Workbook(); + + $fileSystemHelper = new FileSystemHelper( + $this->options->getTempFolder(), + new ZipHelper(), + new XLSX(), + $this->creator + ); + $fileSystemHelper->createBaseFilesAndFolders(); + + $xlFolder = $fileSystemHelper->getXlFolder(); + $sharedStringsManager = new SharedStringsManager($xlFolder, new XLSX()); + + $styleMerger = new StyleMerger(); + $escaper = new XLSX(); + + $styleManager = new StyleManager( + new StyleRegistry($this->options->DEFAULT_ROW_STYLE), + $escaper + ); + + $commentsManager = new CommentsManager($xlFolder, new XLSX()); + + $worksheetManager = new WorksheetManager( + $this->options, + $styleManager, + $styleMerger, + $commentsManager, + $sharedStringsManager, + $escaper, + StringHelper::factory() + ); + + return new WorkbookManager( + $workbook, + $this->options, + $worksheetManager, + $styleManager, + $styleMerger, + $fileSystemHelper + ); + } +}