From 02223083f6b9836e17a00890804cea7e9910f61e Mon Sep 17 00:00:00 2001 From: James Richie Date: Sun, 18 Oct 2026 19:27:17 -0400 Subject: [PATCH] Codex's updates... --- .gitignore | 4 + .htaccess | 10 + AUTHENTICATION.md | 155 ++ DATABASE-VERIFICATION.md | 68 + README.md | 5 + bin/cleanup-auth.php | 7 + bin/dev-router.php | 15 + bin/migrate-auth.php | 26 + classes/AuthService.php | 216 +++ classes/Authentication.php | 415 +----- classes/Autoloader.php | 24 +- classes/Base.php | 423 +++--- classes/Email.php | 71 +- classes/SignIn.php | 122 +- classes/SignUp.php | 117 +- classes/Users.php | 197 +-- composer.json | 14 + composer.lock | 246 +--- css/{signin.css => login.css} | 0 js/addUser.js | 6 +- js/base.js | 14 + js/editUser.js | 6 +- js/login.js | 2 +- main.php | 31 +- migrations/001_auth.sql | 18 + reports/Print_All_PermitsByBuilder.php | 2 +- tables/_createSystemTable.php | 46 +- tables/_createTable.php | 12 +- tables/users.php | 3 +- tests/auth_http.py | 127 ++ tests/auth_integration.php | 134 ++ tests/http_fixture.php | 33 + vendor/composer/InstalledVersions.php | 4 +- vendor/composer/LICENSE | 2 + vendor/composer/autoload_psr4.php | 16 - vendor/composer/autoload_static.php | 81 -- vendor/composer/installed.json | 1242 +---------------- vendor/composer/installed.php | 183 +-- vendor/composer/pcre/composer.json | 14 +- .../PregMatchTypeSpecifyingExtension.php | 52 +- vendor/composer/pcre/src/Preg.php | 4 +- .../zipstream-php/.php-cs-fixer.dist.php | 1 + vendor/maennchen/zipstream-php/.tool-versions | 2 +- vendor/maennchen/zipstream-php/composer.json | 2 +- .../zipstream-php/guides/LargeFilesToS3.rst | 245 ++++ .../maennchen/zipstream-php/guides/index.rst | 1 + vendor/maennchen/zipstream-php/src/File.php | 18 +- vendor/maennchen/zipstream-php/src/Time.php | 12 +- .../Zip64/ExtendedInformationExtraField.php | 8 +- .../maennchen/zipstream-php/src/ZipStream.php | 6 +- .../zipstream-php/test/CallbackOutputTest.php | 21 +- .../test/CentralDirectoryFileHeaderTest.php | 40 +- .../zipstream-php/test/DataDescriptorTest.php | 8 +- .../test/EndOfCentralDirectoryTest.php | 18 +- .../zipstream-php/test/EndlessCycleStream.php | 2 +- .../test/LocalFileHeaderTest.php | 26 +- .../maennchen/zipstream-php/test/Tempfile.php | 2 +- .../test/Zip64/DataDescriptorTest.php | 8 +- .../EndOfCentralDirectoryLocatorTest.php | 8 +- .../test/Zip64/EndOfCentralDirectoryTest.php | 22 +- .../ExtendedInformationExtraFieldTest.php | 16 +- .../zipstream-php/test/ZipStreamTest.php | 33 +- .../Zs/ExtendedInformationExtraFieldTest.php | 4 +- vendor/phpoffice/phpspreadsheet/CHANGELOG.md | 428 +++--- .../phpoffice/phpspreadsheet/CONTRIBUTING.md | 8 +- vendor/phpoffice/phpspreadsheet/LICENSE | 2 +- vendor/phpoffice/phpspreadsheet/README.md | 5 +- vendor/phpoffice/phpspreadsheet/composer.json | 22 +- .../Calculation/BinaryComparison.php | 13 +- .../Calculation/Calculation.php | 241 +++- .../Calculation/CalculationParserOnly.php | 43 + .../Calculation/Database/DatabaseAbstract.php | 4 +- .../Calculation/DateTimeExcel/Date.php | 2 +- .../Calculation/DateTimeExcel/DateValue.php | 19 +- .../Calculation/DateTimeExcel/Days.php | 8 +- .../Calculation/DateTimeExcel/Days360.php | 2 +- .../Calculation/DateTimeExcel/Helpers.php | 47 +- .../Calculation/DateTimeExcel/TimeParts.php | 17 + .../Calculation/DateTimeExcel/TimeValue.php | 8 +- .../Calculation/Engine/Logger.php | 4 +- .../Engine/Operands/StructuredReference.php | 10 +- .../Engineering/ConvertDecimal.php | 2 +- .../Calculation/Engineering/ErfC.php | 2 +- .../CashFlow/Constant/Periodic/Interest.php | 2 +- .../Periodic/InterestAndPrincipal.php | 44 - .../CashFlow/Variable/NonPeriodic.php | 2 +- .../Calculation/FormulaParser.php | 21 +- .../Calculation/FormulaToken.php | 3 + .../Calculation/FunctionArray.php | 27 +- .../PhpSpreadsheet/Calculation/Functions.php | 44 + .../Calculation/Information/Info.php | 44 + .../Calculation/Information/Value.php | 8 +- .../Calculation/Logical/Conditional.php | 2 +- .../Calculation/LookupRef/ChooseRowsEtc.php | 2 +- .../Calculation/LookupRef/ExcelMatch.php | 4 +- .../Calculation/LookupRef/Filter.php | 2 +- .../Calculation/LookupRef/Hstack.php | 2 +- .../Calculation/LookupRef/Hyperlink.php | 20 +- .../Calculation/LookupRef/Indirect.php | 4 +- .../Calculation/LookupRef/Lookup.php | 11 +- .../LookupRef/LookupRefValidations.php | 2 +- .../Calculation/LookupRef/Matrix.php | 10 +- .../Calculation/LookupRef/Offset.php | 6 +- .../LookupRef/RowColumnInformation.php | 7 +- .../Calculation/LookupRef/Selection.php | 2 +- .../Calculation/LookupRef/Sort.php | 78 +- .../Calculation/LookupRef/TorowTocol.php | 2 +- .../Calculation/LookupRef/Unique.php | 4 +- .../Calculation/LookupRef/Vstack.php | 2 +- .../Calculation/LookupRef/XLookup.php | 275 ++++ .../Calculation/MathTrig/Arabic.php | 2 +- .../Calculation/MathTrig/Round.php | 4 +- .../Calculation/MathTrig/Subtotal.php | 26 +- .../Calculation/MathTrig/Sum.php | 3 +- .../Calculation/Statistical/Averages.php | 5 +- .../Calculation/Statistical/Conditional.php | 10 +- .../Statistical/Distributions/Beta.php | 11 +- .../Statistical/Distributions/Binomial.php | 2 +- .../Statistical/Distributions/ChiSquared.php | 88 +- .../Statistical/Distributions/F.php | 19 +- .../Statistical/Distributions/Gamma.php | 9 +- .../Statistical/Distributions/GammaBase.php | 132 +- .../Statistical/Distributions/Normal.php | 2 +- .../Statistical/Distributions/StudentT.php | 266 +++- .../Calculation/Statistical/Maximum.php | 4 +- .../Calculation/Statistical/Minimum.php | 4 +- .../Calculation/Statistical/Percentiles.php | 2 + .../Calculation/TextData/CharacterConvert.php | 91 +- .../Calculation/TextData/Concatenate.php | 23 +- .../Calculation/TextData/Format.php | 21 +- .../Calculation/TextData/Search.php | 4 +- .../Calculation/TextData/Text.php | 4 +- .../Calculation/TextData/Thai.php | 139 ++ .../Calculation/Web/Service.php | 52 +- .../Calculation/locale/Translations.xlsx | Bin 140431 -> 99195 bytes .../Calculation/locale/nl/functions | 1 + .../src/PhpSpreadsheet/Cell/AddressHelper.php | 2 +- .../src/PhpSpreadsheet/Cell/AddressRange.php | 9 +- .../Cell/AdvancedValueBinder.php | 2 +- .../src/PhpSpreadsheet/Cell/Cell.php | 188 ++- .../src/PhpSpreadsheet/Cell/Coordinate.php | 58 +- .../src/PhpSpreadsheet/Cell/DataType.php | 1 + .../Cell/DefaultValueBinder.php | 16 +- .../src/PhpSpreadsheet/Cell/Hyperlink.php | 23 + .../src/PhpSpreadsheet/Cell/IgnoredErrors.php | 14 + .../src/PhpSpreadsheet/Chart/Axis.php | 2 +- .../src/PhpSpreadsheet/Chart/Chart.php | 119 +- .../src/PhpSpreadsheet/Chart/ChartColor.php | 2 +- .../src/PhpSpreadsheet/Chart/DataSeries.php | 2 +- .../src/PhpSpreadsheet/Chart/DataTable.php | 62 + .../src/PhpSpreadsheet/Chart/Layout.php | 86 +- .../src/PhpSpreadsheet/Chart/Legend.php | 4 +- .../src/PhpSpreadsheet/Chart/PlotArea.php | 20 + .../src/PhpSpreadsheet/Chart/Properties.php | 14 +- .../PhpSpreadsheet/Chart/Renderer/JpGraph.php | 2 +- .../Chart/Renderer/JpGraphRendererBase.php | 2 +- .../src/PhpSpreadsheet/Collection/Cells.php | 36 +- .../src/PhpSpreadsheet/Helper/Downloader.php | 22 +- .../src/PhpSpreadsheet/Helper/Handler.php | 11 +- .../src/PhpSpreadsheet/Helper/Sample.php | 23 +- .../src/PhpSpreadsheet/Helper/TextGrid.php | 45 +- .../Helper/TextGridRightAlign.php | 10 + .../src/PhpSpreadsheet/IOFactory.php | 82 +- .../src/PhpSpreadsheet/Reader/BaseReader.php | 68 +- .../src/PhpSpreadsheet/Reader/Csv.php | 112 +- .../PhpSpreadsheet/Reader/Csv/Delimiter.php | 14 +- .../src/PhpSpreadsheet/Reader/CsvNoEscape.php | 51 + .../Reader/EmptyWorksheetInfo.php | 31 + .../src/PhpSpreadsheet/Reader/Gnumeric.php | 53 +- .../PhpSpreadsheet/Reader/Gnumeric/Styles.php | 24 +- .../src/PhpSpreadsheet/Reader/Html.php | 152 +- .../src/PhpSpreadsheet/Reader/IReader2.php | 21 + .../src/PhpSpreadsheet/Reader/Ods.php | 1136 +++++++++++++-- .../Reader/Security/XmlScanner.php | 7 +- .../src/PhpSpreadsheet/Reader/Slk.php | 4 +- .../src/PhpSpreadsheet/Reader/Xls.php | 209 +-- .../src/PhpSpreadsheet/Reader/Xls/Biff8.php | 4 +- .../Reader/Xls/DataValidationHelper.php | 5 +- .../Reader/Xls/ListFunctions.php | 4 +- .../Reader/Xls/LoadSpreadsheet.php | 25 +- .../src/PhpSpreadsheet/Reader/Xls/RC4.php | 2 +- .../src/PhpSpreadsheet/Reader/XlsBase.php | 2 +- .../src/PhpSpreadsheet/Reader/Xlsx.php | 893 +++++++++--- .../PhpSpreadsheet/Reader/Xlsx/AutoFilter.php | 2 +- .../src/PhpSpreadsheet/Reader/Xlsx/Chart.php | 107 +- .../Reader/Xlsx/ColumnAndRowAttributes.php | 12 +- .../Reader/Xlsx/ConditionalStyles.php | 7 +- .../PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php | 17 +- .../PhpSpreadsheet/Reader/Xlsx/Namespaces.php | 22 + .../Reader/Xlsx/PivotTableReader.php | 141 ++ .../PhpSpreadsheet/Reader/Xlsx/Sparklines.php | 163 +++ .../src/PhpSpreadsheet/Reader/Xlsx/Styles.php | 8 +- .../Reader/Xlsx/TableReader.php | 2 +- .../Reader/Xlsx/WorkbookView.php | 2 +- .../src/PhpSpreadsheet/Reader/Xml.php | 78 +- .../Reader/Xml/PageSettings.php | 21 +- .../Reader/Xml/Style/Border.php | 50 +- .../PhpSpreadsheet/Reader/Xml/Style/Fill.php | 17 +- .../src/PhpSpreadsheet/ReferenceHelper.php | 96 +- .../src/PhpSpreadsheet/Settings.php | 42 +- .../src/PhpSpreadsheet/Shared/CodePage.php | 2 +- .../src/PhpSpreadsheet/Shared/Date.php | 150 +- .../src/PhpSpreadsheet/Shared/Drawing.php | 20 +- .../DgContainer/SpgrContainer/SpContainer.php | 6 +- .../Shared/Escher/DggContainer.php | 2 +- .../src/PhpSpreadsheet/Shared/File.php | 43 +- .../src/PhpSpreadsheet/Shared/Font.php | 13 +- .../src/PhpSpreadsheet/Shared/OLE.php | 94 +- .../Shared/OLE/ChainedBlockStream.php | 71 +- .../PhpSpreadsheet/Shared/OLE/PPS/Root.php | 56 +- .../src/PhpSpreadsheet/Shared/OLERead.php | 21 +- .../PhpSpreadsheet/Shared/StringHelper.php | 217 ++- .../PhpSpreadsheet/Shared/Trend/BestFit.php | 12 +- .../Shared/Trend/LinearBestFit.php | 2 +- .../Shared/Trend/PolynomialBestFit.php | 23 +- .../src/PhpSpreadsheet/Shared/XMLWriter.php | 10 +- .../src/PhpSpreadsheet/Shared/Xls.php | 18 +- .../Shared/Xlsx/AgileEncryption.php | 842 +++++++++++ .../src/PhpSpreadsheet/Spreadsheet.php | 132 +- .../src/PhpSpreadsheet/Style/Alignment.php | 6 +- .../ConditionalFormatting/CellMatcher.php | 2 +- .../ConditionalColorScale.php | 36 +- .../ConditionalFormattingRuleExtension.php | 214 --- .../ConditionalFormatting/MergedCellStyle.php | 135 ++ .../Wizard/CellValue.php | 25 +- .../Wizard/Duplicates.php | 2 +- .../Wizard/TextValue.php | 15 +- .../src/PhpSpreadsheet/Style/Fill.php | 2 +- .../src/PhpSpreadsheet/Style/Font.php | 19 +- .../src/PhpSpreadsheet/Style/NumberFormat.php | 25 +- .../Style/NumberFormat/DateFormatter.php | 30 +- .../Style/NumberFormat/Formatter.php | 33 +- .../Style/NumberFormat/NumberFormatter.php | 8 +- .../NumberFormat/PercentageFormatter.php | 2 +- .../Style/NumberFormat/Wizard/Date.php | 6 +- .../Style/NumberFormat/Wizard/DateTime.php | 4 +- .../NumberFormat/Wizard/DateTimeWizard.php | 4 +- .../Style/NumberFormat/Wizard/Duration.php | 2 +- .../Style/NumberFormat/Wizard/Time.php | 2 +- .../src/PhpSpreadsheet/Style/RgbTint.php | 2 +- .../src/PhpSpreadsheet/Style/Style.php | 54 +- .../PhpSpreadsheet/Worksheet/AutoFilter.php | 103 +- .../Worksheet/AutoFilter/Column/Rule.php | 7 +- .../PhpSpreadsheet/Worksheet/BaseDrawing.php | 54 +- .../src/PhpSpreadsheet/Worksheet/Drawing.php | 41 +- .../PhpSpreadsheet/Worksheet/HeaderFooter.php | 2 +- .../Worksheet/MemoryDrawing.php | 22 +- .../PhpSpreadsheet/Worksheet/PageSetup.php | 14 +- .../PivotTable/PivotCacheDefinition.php | 160 +++ .../Worksheet/PivotTable/PivotField.php | 140 ++ .../Worksheet/PivotTable/PivotFieldGroup.php | 136 ++ .../Worksheet/PivotTable/PivotTable.php | 207 +++ .../PivotTable/PivotTableBuilder.php | 280 ++++ .../Worksheet/RowCellIterator.php | 42 +- .../PhpSpreadsheet/Worksheet/RowDimension.php | 14 + .../Worksheet/Sparkline/Sparkline.php | 84 ++ .../Worksheet/Sparkline/SparklineGroup.php | 403 ++++++ .../Worksheet/Sparkline/SparklineType.php | 22 + .../src/PhpSpreadsheet/Worksheet/Table.php | 6 +- .../PhpSpreadsheet/Worksheet/Worksheet.php | 364 ++++- .../src/PhpSpreadsheet/Writer/Csv.php | 6 +- .../src/PhpSpreadsheet/Writer/Html.php | 569 +++++--- .../src/PhpSpreadsheet/Writer/Ods.php | 21 + .../PhpSpreadsheet/Writer/Ods/Cell/Style.php | 929 +++++++++++- .../src/PhpSpreadsheet/Writer/Ods/Content.php | 217 ++- .../src/PhpSpreadsheet/Writer/Ods/Drawing.php | 153 ++ .../src/PhpSpreadsheet/Writer/Ods/Formula.php | 39 +- .../src/PhpSpreadsheet/Writer/Ods/MetaInf.php | 28 + .../src/PhpSpreadsheet/Writer/Ods/Styles.php | 16 +- .../src/PhpSpreadsheet/Writer/Pdf/Dompdf.php | 5 + .../src/PhpSpreadsheet/Writer/Pdf/Mpdf.php | 6 +- .../src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php | 8 +- .../src/PhpSpreadsheet/Writer/Xls.php | 17 +- .../PhpSpreadsheet/Writer/Xls/BIFFwriter.php | 38 +- .../src/PhpSpreadsheet/Writer/Xls/Parser.php | 48 +- .../Writer/Xls/Style/CellBorder.php | 11 +- .../Writer/Xls/Style/CellFill.php | 13 +- .../PhpSpreadsheet/Writer/Xls/Workbook.php | 43 +- .../PhpSpreadsheet/Writer/Xls/Worksheet.php | 117 +- .../src/PhpSpreadsheet/Writer/Xls/Xf.php | 41 +- .../src/PhpSpreadsheet/Writer/Xlsx.php | 318 ++++- .../src/PhpSpreadsheet/Writer/Xlsx/Chart.php | 106 +- .../Writer/Xlsx/ContentTypes.php | 46 +- .../PhpSpreadsheet/Writer/Xlsx/Drawing.php | 45 +- .../Writer/Xlsx/FeaturePropertyBag.php | 66 + .../PhpSpreadsheet/Writer/Xlsx/Metadata.php | 205 +-- .../PhpSpreadsheet/Writer/Xlsx/PivotTable.php | 449 ++++++ .../src/PhpSpreadsheet/Writer/Xlsx/Rels.php | 104 +- .../Writer/Xlsx/RichDataDrawing.php | 191 +++ .../src/PhpSpreadsheet/Writer/Xlsx/Style.php | 12 + .../src/PhpSpreadsheet/Writer/Xlsx/Table.php | 46 +- .../PhpSpreadsheet/Writer/Xlsx/Workbook.php | 33 + .../PhpSpreadsheet/Writer/Xlsx/Worksheet.php | 211 ++- vendor/psr/http-client/CHANGELOG.md | 31 - vendor/psr/http-client/LICENSE | 19 - vendor/psr/http-client/README.md | 12 - vendor/psr/http-client/composer.json | 30 - .../src/ClientExceptionInterface.php | 10 - .../psr/http-client/src/ClientInterface.php | 20 - .../src/NetworkExceptionInterface.php | 24 - .../src/RequestExceptionInterface.php | 24 - vendor/psr/http-factory/LICENSE | 21 - vendor/psr/http-factory/README.md | 12 - vendor/psr/http-factory/composer.json | 38 - .../src/RequestFactoryInterface.php | 18 - .../src/ResponseFactoryInterface.php | 18 - .../src/ServerRequestFactoryInterface.php | 24 - .../src/StreamFactoryInterface.php | 45 - .../src/UploadedFileFactoryInterface.php | 34 - .../http-factory/src/UriFactoryInterface.php | 17 - vendor/psr/http-message/CHANGELOG.md | 36 - vendor/psr/http-message/LICENSE | 19 - vendor/psr/http-message/README.md | 16 - vendor/psr/http-message/composer.json | 26 - .../psr/http-message/docs/PSR7-Interfaces.md | 130 -- vendor/psr/http-message/docs/PSR7-Usage.md | 159 --- .../psr/http-message/src/MessageInterface.php | 187 --- .../psr/http-message/src/RequestInterface.php | 130 -- .../http-message/src/ResponseInterface.php | 68 - .../src/ServerRequestInterface.php | 261 ---- .../psr/http-message/src/StreamInterface.php | 158 --- .../src/UploadedFileInterface.php | 123 -- vendor/psr/http-message/src/UriInterface.php | 324 ----- views/createAllViews.php | 20 +- views/createView.php | 9 +- web.config | 44 + xml/settings.example.xml | 14 + xml/settings.xml | 35 +- xsl/addUser.xsl | 4 +- xsl/changePassword.xsl | 48 +- xsl/content.xsl | 126 +- xsl/createAccount.xsl | 13 +- xsl/editUser.xsl | 8 +- xsl/emailNewUserCredentials.xsl | 47 +- xsl/emailPasswordResetLink.xsl | 59 +- xsl/login.xsl | 15 +- xsl/resetPassword.xsl | 67 + xsl/setupUsers.xsl | 8 +- 338 files changed, 14757 insertions(+), 7986 deletions(-) create mode 100644 .gitignore create mode 100644 .htaccess create mode 100644 AUTHENTICATION.md create mode 100644 DATABASE-VERIFICATION.md create mode 100644 bin/cleanup-auth.php create mode 100644 bin/dev-router.php create mode 100644 bin/migrate-auth.php create mode 100644 classes/AuthService.php rename css/{signin.css => login.css} (100%) create mode 100644 migrations/001_auth.sql create mode 100644 tests/auth_http.py create mode 100644 tests/auth_integration.php create mode 100644 tests/http_fixture.php create mode 100644 vendor/maennchen/zipstream-php/guides/LargeFilesToS3.rst create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/CalculationParserOnly.php delete mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/InterestAndPrincipal.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Info.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Thai.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataTable.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGridRightAlign.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/CsvNoEscape.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/EmptyWorksheetInfo.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/IReader2.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/PivotTableReader.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Sparklines.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xlsx/AgileEncryption.php delete mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/MergedCellStyle.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotCacheDefinition.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotField.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotFieldGroup.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTable.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTableBuilder.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/Sparkline.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineGroup.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineType.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Drawing.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FeaturePropertyBag.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/PivotTable.php create mode 100644 vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/RichDataDrawing.php delete mode 100644 vendor/psr/http-client/CHANGELOG.md delete mode 100644 vendor/psr/http-client/LICENSE delete mode 100644 vendor/psr/http-client/README.md delete mode 100644 vendor/psr/http-client/composer.json delete mode 100644 vendor/psr/http-client/src/ClientExceptionInterface.php delete mode 100644 vendor/psr/http-client/src/ClientInterface.php delete mode 100644 vendor/psr/http-client/src/NetworkExceptionInterface.php delete mode 100644 vendor/psr/http-client/src/RequestExceptionInterface.php delete mode 100644 vendor/psr/http-factory/LICENSE delete mode 100644 vendor/psr/http-factory/README.md delete mode 100644 vendor/psr/http-factory/composer.json delete mode 100644 vendor/psr/http-factory/src/RequestFactoryInterface.php delete mode 100644 vendor/psr/http-factory/src/ResponseFactoryInterface.php delete mode 100644 vendor/psr/http-factory/src/ServerRequestFactoryInterface.php delete mode 100644 vendor/psr/http-factory/src/StreamFactoryInterface.php delete mode 100644 vendor/psr/http-factory/src/UploadedFileFactoryInterface.php delete mode 100644 vendor/psr/http-factory/src/UriFactoryInterface.php delete mode 100644 vendor/psr/http-message/CHANGELOG.md delete mode 100644 vendor/psr/http-message/LICENSE delete mode 100644 vendor/psr/http-message/README.md delete mode 100644 vendor/psr/http-message/composer.json delete mode 100644 vendor/psr/http-message/docs/PSR7-Interfaces.md delete mode 100644 vendor/psr/http-message/docs/PSR7-Usage.md delete mode 100644 vendor/psr/http-message/src/MessageInterface.php delete mode 100644 vendor/psr/http-message/src/RequestInterface.php delete mode 100644 vendor/psr/http-message/src/ResponseInterface.php delete mode 100644 vendor/psr/http-message/src/ServerRequestInterface.php delete mode 100644 vendor/psr/http-message/src/StreamInterface.php delete mode 100644 vendor/psr/http-message/src/UploadedFileInterface.php delete mode 100644 vendor/psr/http-message/src/UriInterface.php create mode 100644 web.config create mode 100644 xml/settings.example.xml create mode 100644 xsl/resetPassword.xsl diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..89c1925 --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +/xml/settings.local.xml +/.env +/.tools/ +/tests/.local/ diff --git a/.htaccess b/.htaccess new file mode 100644 index 0000000..9b0c667 --- /dev/null +++ b/.htaccess @@ -0,0 +1,10 @@ +Options -Indexes + + RewriteEngine On + RewriteRule ^(?:classes|vendor|xml|tables|views|migrations|bin|tests|nbproject|reports)(?:/|$) - [F,L,NC] + RewriteRule (^|/)\. - [F,L] + + + Require all denied + +DirectoryIndex main.php diff --git a/AUTHENTICATION.md b/AUTHENTICATION.md new file mode 100644 index 0000000..9ae6a9d --- /dev/null +++ b/AUTHENTICATION.md @@ -0,0 +1,155 @@ +# Authentication changes and deployment + +Login, signup, password changes, and password reset now use `classes/AuthService.php`. +The existing PHP/XSL presentation structure is retained. Signup creates an active +`User` account; it does not grant administrator access or club membership. + +## Before using this version against your database + +1. Back up the database and use a maintenance window for the schema update. +2. Use 64-bit PHP 8.3 or newer. Run `composer install` and + `composer check-platform-reqs` with the same PHP configuration used by the web server. +3. Configure database and SMTP settings as described below. +4. From the project directory, run `php bin/migrate-auth.php` once. It is safe to + rerun. It adds the session version, password reset storage, rate limit storage, + and refreshes `view_users`. It preserves accounts and password hashes. MySQL + schema changes may briefly lock tables; this is not a web-request migration. +5. Serve the site over HTTPS and verify the private-directory blocking rules. +6. Test delivery of a reset email to an account you control. + +The migration expects the schema in `tables/users.php`, including +`user_password_hash`, `user_status`, and `user_last_login`. It refuses to continue +if those fields are missing. Do not rebuild the database to fix an older schema; +map and migrate its existing account fields while preserving the data. + +On October 18, 2026, the migration was applied to the configured development +database, `192.168.1.190/huntingclubroster`, after a complete backup was restored +and checked locally. Existing account data and original table row counts were +preserved. Signup, login, logout and password reset were verified using the local +application against that database; the SMTP provider accepted a real test reset +email over TLS. The temporary account and restored database copy were removed. +The public hostname did not resolve from the verification machine, so these +results do not confirm deployment or operation of the public site. See +[database verification](DATABASE-VERIFICATION.md) for the backup and results. + +## Settings + +Your existing settings were preserved in ignored `xml/settings.local.xml`. +The tracked `xml/settings.xml` now contains no credentials. Settings are loaded in +this order: `HCR_SETTINGS_FILE`, local settings if present, then tracked defaults. +Environment variables override individual fields: + +| Variable | XML field | +| --- | --- | +| `HCR_ENVIRONMENT` | `Environment` | +| `HCR_DB_HOST` | `DatabaseServer` | +| `HCR_DB_PORT` | `DatabasePort` (default 3306) | +| `HCR_DB_NAME` | `DatabaseName` | +| `HCR_DB_USER` | `DatabaseUser` | +| `HCR_DB_PASSWORD` | `DatabasePassword` | +| `HCR_SMTP_HOST` | `SmtpServer` | +| `HCR_SMTP_PORT` | `SmtpPort` | +| `HCR_SMTP_USER` | `SmtpUserName` | +| `HCR_SMTP_PASSWORD` | `SmtpPassword` | +| `HCR_SMTP_FROM` | `SmtpFromEmail` (defaults to SMTP username) | +| `HCR_APPLICATION_URL` | `ApplicationUrl` | + +The configured application URL is **https://app.myhuntingclubroster.com**. It must +be the trusted HTTPS application directory, without `/main.php`, a query string, +or a fragment. Request `Host` headers are never used to construct email links. +SMTP uses implicit TLS on port 465 and required STARTTLS on other ports, +including 587. Certificate verification remains enabled. The sender must be +authorized by your SMTP provider. For production, use an external settings file +outside the document root or environment variables and set `Environment` to +`Production`. Production requests require the web server to set `HTTPS=on`. +If TLS terminates at a reverse proxy, configure that mapping in the web server; +the application does not trust arbitrary forwarded headers. + +Moving credentials out of tracked files does not remove them from Git history. +Rotate the previously committed database/SMTP credentials, webhook, and any +account still using the former hardcoded initial administrator password. + +## Behavior and protections + +- Login accepts the email shown by the form, with username compatibility. + SQL uses bound parameters. Passwords use PHP's password hashing API. +- Signup validates names, email, username, duplicate accounts, password length, + and confirmation on the server. Privilege fields submitted by the browser are + ignored. New passwords must be 12–72 bytes to avoid bcrypt truncation. +- Password reset emails contain 256-bit random links. Only token hashes are + stored; links expire after 30 minutes. Completing a reset consumes every + outstanding token for that account inside a transaction. Requesting a reset + does not change the password. Failed email delivery removes the unsent token. +- Reset requests return the same public message for unknown, inactive, + throttled, and undeliverable accounts. Delivery errors go to the server log. +- Password changes require the current password and matching confirmation. + Successful changes and resets revoke all old sessions. Administrator role and + status changes also revoke sessions. Account status and privileges are checked + against the database on each authenticated request. +- Sessions rotate on login/logout, expire after 30 idle minutes, and use + HttpOnly/SameSite cookies plus Secure cookies on HTTPS. +- CSRF checks cover public authentication forms and authenticated POST actions. + Existing application forms and same-origin AJAX requests receive the token. +- Database-backed limits apply across new cookies: 10 login attempts per account + and 50 per IP in 15 minutes; 3 reset emails per account and 10 per IP per hour; + 5 signup attempts per IP per hour. Reset submissions and password changes are + limited separately. +- Routing uses an explicit allowlist. Administrative screens, account editing, + resets for other users, and destructive maintenance actions require an + administrator role on the server. General helper methods are not endpoints. + System administrator names in the old settings file no longer override the + role stored in the database. +- Errors are logged without recursively opening a database connection or + emailing from the error handler. Visitors receive a generic failure message. + +Run `php bin/cleanup-auth.php` daily using your normal server scheduler to remove +expired reset tokens and old rate-limit rows. No scheduler was installed here. +Exclude query strings containing reset tokens from retained access/analytics +logs where your hosting supports it. + +## Hosting and setup scripts + +Apache `.htaccess` and IIS `web.config` block private application directories. +Apache needs the rewrite module and permission to apply these rules. On Nginx, +configure equivalent denies for `xml`, `classes`, `vendor`, `tables`, `views`, +`bin`, `migrations`, `tests`, `reports`, `nbproject`, and hidden files. Verify that +`/xml/settings.local.xml` and setup scripts cannot be downloaded or executed. +The production web server configuration has not been exercised by local tests. + +Setup scripts also refuse HTTP execution independently. The system rebuild is +disabled unless `HCR_ALLOW_REBUILD=yes`; it requires a unique +`HCR_INITIAL_ADMIN_PASSWORD` of 12–72 bytes before doing any rebuilding. It is +destructive and is not the authentication migration. + +For local development only: `php -S 127.0.0.1:8080 bin/dev-router.php`. This router +serves the entry point and static assets while denying private files. Set the +environment to `Development` for plain HTTP development. Never use PHP's built-in +server for production. + +## Dependencies and verification + +Composer requirements now explicitly declare the PHP version and authentication +extensions. PhpSpreadsheet was upgraded from 5.2.0 to 5.10.0 after the dependency +audit found advisories. Compatible ZipStream and Composer PCRE dependencies were +updated, and the lock file and installed autoloader were synchronized. Existing +auxiliary TCPDF library files were preserved. Composer reported no known +dependency security advisories after the update. + +`tests/auth_integration.php` creates and drops a randomly named database on the +server explicitly supplied by `HCR_TEST_DSN`, `HCR_TEST_USER`, and +`HCR_TEST_PASSWORD`. It never reads the application's database credentials. +Run `php tests/auth_integration.php` (or `composer test:auth`). The test server +account needs permission to create and drop databases. Use an isolated server. + +`tests/auth_http.py` runs the real forms against a temporary PHP development +server and disposable database. Supply the same test variables plus +`HCR_DB_HOST`, `HCR_DB_PORT`, `HCR_DB_USER`, and `HCR_DB_PASSWORD` for that isolated +server. `PHP_TEST_COMMAND` can hold a JSON argument list for a PHP executable and +extension options; it defaults to `["php"]`. Run `python tests/auth_http.py`. +Email delivery is disabled in HTTP tests; the service tests capture reset emails +and simulate transport failure. Tests cover schema migration, signup/login, +validation, SQL injection input, CSRF, authorization, token expiry/reuse, +throttling, session revocation, logout, and rendering. + +A wider review of unrelated roster/reporting logic and deployment penetration +testing are outside this authentication change. diff --git a/DATABASE-VERIFICATION.md b/DATABASE-VERIFICATION.md new file mode 100644 index 0000000..dc7f169 --- /dev/null +++ b/DATABASE-VERIFICATION.md @@ -0,0 +1,68 @@ +# Database backup, migration and verification + +Completed October 18, 2026 against the configured **development** database: +`192.168.1.190/huntingclubroster` (MariaDB 10.5.29). + +## Backup + +The complete SQL backup includes tables, views, triggers, routines and events: + +`.tools/backups/20261018-190815/huntingclubroster.sql` + +It is 34,198 bytes. SHA-256: + +`0cf92f6c5e05433a50bf7f8a8288e39e68893ad82dff679f2d57ea1317d358d9` + +The backup completion marker and checksum were verified. It was restored into +an isolated local MariaDB server, and row counts matched for all 10 original +tables. The authentication migration passed against the restored copy before +being applied to the configured database. The restore copy was then removed +and the temporary database server shut down. The original backup is retained. + +The server does not support TLS. The user explicitly approved an unencrypted +database connection for the backup, migration and verification. SMTP transport +remained encrypted with certificate verification enabled. + +## Migration + +The migration added `users.user_auth_version`, `password_resets`, and +`auth_attempts`, ensured transactional user storage, and refreshed `view_users`. +Existing user data (including the password hash) and all original table row +counts were confirmed unchanged immediately after migration. + +## Verification against the migrated database + +Eleven checks passed using temporary account data: + +1. Signup page loaded. +2. Signup created a regular user account. +3. An incorrect password was rejected. +4. The new account could log in. +5. Logout succeeded. +6. The configured SMTP provider accepted the authorized reset email over TLS. +7. The generated reset token opened the password form through the local server. +8. Resetting the temporary account password succeeded. +9. Reusing the consumed token failed. +10. The previous password failed after reset. +11. The new password could log in. + +The reset request was generated by the shared authentication service and sent +through the real configured SMTP transport. Signup, login, logout, reset-token +validation, and password replacement used the actual HTTP handlers. No existing +account was used for a password reset. The temporary account, its reset tokens, +and test rate-limit records were removed; the existing account snapshot matched +after cleanup. A skipped auto-increment value from the temporary account is +expected and was not reset. + +Private machine-readable manifests and results are stored beside the backup. +The backup folder and temporary verification helpers are ignored by Git. + +## Public-site limitation + +`https://app.myhuntingclubroster.com` did not resolve from this machine during +verification. The checks used the updated local application connected to the +configured development database. They do not prove that the public server has +the new code or that its database is this same database. DNS, deployment and +public HTTPS operation still need verification before declaring the live site +ready. SMTP acceptance was verified; receipt in the recipient's inbox was not +independently confirmed. diff --git a/README.md b/README.md index e69de29..fa3e5cf 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,5 @@ +# Hunting Club Roster + +See [authentication setup and changes](AUTHENTICATION.md) before deploying the +updated login, signup, and password reset flows. The authentication migration +must be applied to the existing database before using this version. diff --git a/bin/cleanup-auth.php b/bin/cleanup-auth.php new file mode 100644 index 0000000..77585c0 --- /dev/null +++ b/bin/cleanup-auth.php @@ -0,0 +1,7 @@ +connect(); +$db->exec('DELETE FROM password_resets WHERE expires_at < UTC_TIMESTAMP()'); +$db->exec('DELETE FROM auth_attempts WHERE window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 DAY)'); +echo "Expired authentication records removed.\n"; diff --git a/bin/dev-router.php b/bin/dev-router.php new file mode 100644 index 0000000..17b0813 --- /dev/null +++ b/bin/dev-router.php @@ -0,0 +1,15 @@ +connect(); +$columns = $db->query('SHOW COLUMNS FROM users')->fetchAll(PDO::FETCH_COLUMN); +foreach (['user_password_hash', 'user_first_name', 'user_last_name', 'user_status', 'user_last_login'] as $column) { + if (!in_array($column, $columns, true)) + throw new RuntimeException("Missing users.{$column}. Align the database with tables/users.php before migrating; existing account data must be preserved."); +} +$sql = file_get_contents(__DIR__ . '/../migrations/001_auth.sql'); +if (in_array('user_auth_version', $columns, true)) { + $sql = str_replace('ALTER TABLE users ADD COLUMN user_auth_version INT UNSIGNED NOT NULL DEFAULT 0;', '', $sql); +} +foreach (explode(';', $sql) as $statement) { + if (trim($statement)) + $db->exec($statement); +} +// Refresh the view so users.* includes the new session version column. +require __DIR__ . '/../views/view_users.php'; +$db->exec(str_replace('create view', 'CREATE OR REPLACE VIEW', $SQL)); +echo "Authentication schema is ready.\n"; diff --git a/classes/AuthService.php b/classes/AuthService.php new file mode 100644 index 0000000..32e0057 --- /dev/null +++ b/classes/AuthService.php @@ -0,0 +1,216 @@ +connect()->prepare('SELECT * FROM view_users WHERE user_email = :email OR user_name = :name LIMIT 1'); + $stmt->execute([':email' => trim($identifier), ':name' => trim($identifier)]); + return $stmt->fetch() ?: null; + } + + public static function passwordError(string $password, string $confirmation): string { + if ($password !== $confirmation) + return 'Passwords must match.'; + // PASSWORD_DEFAULT is bcrypt on supported PHP versions; avoid silent truncation. + if (strlen($password) < 12 || strlen($password) > 72 || str_contains($password, "\0")) { + return 'Use a password between 12 and 72 bytes long.'; + } + return ''; + } + + /** Shared database counters survive session/cookie changes. Requires the auth migration. */ + public function allowAttempt(string $scope, string $identifier, int $limit, int $seconds): bool { + $db = $this->connect(); + $key = hash('sha256', $scope . ':' . strtolower(trim($identifier))); + $stmt = $db->prepare('INSERT INTO auth_attempts (attempt_key, attempts, window_started) VALUES (:key, 1, UTC_TIMESTAMP()) + ON DUPLICATE KEY UPDATE attempts = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :seconds SECOND), 1, attempts + 1), + window_started = IF(window_started < DATE_SUB(UTC_TIMESTAMP(), INTERVAL :window SECOND), UTC_TIMESTAMP(), window_started)'); + $stmt->execute([':key' => $key, ':seconds' => $seconds, ':window' => $seconds]); + $stmt = $db->prepare('SELECT attempts FROM auth_attempts WHERE attempt_key = :key'); + $stmt->execute([':key' => $key]); + return (int) $stmt->fetchColumn() <= $limit; + } + + public function login(string $identifier, string $password): bool { + $ipAllowed = $this->allowAttempt('login-ip', $_SERVER['REMOTE_ADDR'] ?? '', 50, 900); + $accountAllowed = $this->allowAttempt('login-account', $identifier, 10, 900); + if (!$ipAllowed || !$accountAllowed) + return false; + $user = $this->findUser($identifier); + // A real hash avoids an immediate return for unknown accounts. + $hash = $user['user_password_hash'] ?? '$2y$12$92IXUNpkjO0rOQ5byMi.Ye4oKoEa3Ro9llC/.og/at2uheWG/igi.'; + $verified = password_verify($password, $hash); + if (!$user || !$verified || $user['user_status'] !== 'active') + return false; + if (password_needs_rehash($hash, PASSWORD_DEFAULT)) { + $stmt = $this->connect()->prepare('UPDATE users SET user_password_hash = :hash WHERE user_serial = :id AND user_password_hash = :old_hash'); + $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $user['user_serial'], ':old_hash' => $hash]); + } + session_regenerate_id(true); + $_SESSION[self::USER_AUTHENTICATED] = true; + $_SESSION[self::USER_SERIAL] = (int) $user['user_serial']; + $_SESSION[self::USER_NAME] = $user['user_name']; + $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; + $_SESSION[self::USER_EMAIL] = $user['user_email']; + $_SESSION[self::USER_ROLE] = $user['user_role']; + $_SESSION['auth_version'] = (int) $user['user_auth_version']; + $_SESSION['last_activity'] = time(); + $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); + $stmt = $this->connect()->prepare('UPDATE users SET user_last_login = :now WHERE user_serial = :id'); + $stmt->execute([':now' => $this->getTimestamp(), ':id' => $user['user_serial']]); + return true; + } + + public function currentUser(): ?array { + if (empty($_SESSION[self::USER_AUTHENTICATED])) + return null; + if (time() - ($_SESSION['last_activity'] ?? 0) > self::IDLE_TIMEOUT) + return null; + $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_serial = :id'); + $stmt->execute([':id' => $_SESSION[self::USER_SERIAL] ?? 0]); + $user = $stmt->fetch(); + if (!$user || $user['user_status'] !== 'active' || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1)) + return null; + $_SESSION['last_activity'] = time(); + $_SESSION[self::USER_ROLE] = $user['user_role']; + $_SESSION[self::USER_NAME] = $user['user_name']; + $_SESSION[self::USER_FULL_NAME] = $user['user_full_name']; + $_SESSION[self::USER_EMAIL] = $user['user_email']; + return $user; + } + + public function register(array $input): void { + $first = trim($input['user_first_name'] ?? ''); + $last = trim($input['user_last_name'] ?? ''); + $email = trim($input['user_email'] ?? ''); + $name = trim($input['user_name'] ?? ''); + if (!$first || !$last || strlen($first) > 128 || strlen($last) > 128) + throw new InvalidArgumentException('Enter your first and last name (up to 128 bytes each).'); + if (!filter_var($email, FILTER_VALIDATE_EMAIL) || strlen($email) > 100) + throw new InvalidArgumentException('Enter a valid email address (up to 100 bytes).'); + if (!preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $name)) + throw new InvalidArgumentException('Use 3–64 letters, numbers, dots, underscores or hyphens for your username.'); + $error = self::passwordError($input['user_password'] ?? '', $input['user_password_confirm'] ?? ''); + if ($error) + throw new InvalidArgumentException($error); + $db = $this->connect(); + // Cross-field uniqueness keeps email-or-username login unambiguous. + $stmt = $db->prepare('SELECT user_serial FROM users WHERE user_name IN (:name, :email) OR user_email IN (:email2, :name2) LIMIT 1'); + $stmt->execute([':name' => $name, ':email' => $email, ':email2' => $email, ':name2' => $name]); + if ($stmt->fetch()) + throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); + $stmt = $db->prepare("INSERT INTO users (user_name, user_first_name, user_last_name, user_email, user_password_hash, user_role, user_status, user_creator) VALUES (:name, :first, :last, :email, :hash, 'User', 'active', 'Self registration')"); + try { + $stmt->execute([':name' => $name, ':first' => $first, ':last' => $last, ':email' => $email, ':hash' => password_hash($input['user_password'], PASSWORD_DEFAULT)]); + } catch (PDOException $e) { + if ($e->getCode() === '23000') + throw new InvalidArgumentException('Unable to create an account with these details. Try signing in or resetting your password.'); + throw $e; + } + } + + /** Never modify the password when somebody merely requests an email. */ + public function requestReset(string $identifier): void { + $ipAllowed = $this->allowAttempt('reset-ip', $_SERVER['REMOTE_ADDR'] ?? '', 10, 3600); + $accountAllowed = $this->allowAttempt('reset-account', $identifier, 3, 3600); + if (!$ipAllowed || !$accountAllowed) + return; + $user = $this->findUser($identifier); + if (!$user || $user['user_status'] !== 'active') + return; + $baseUrl = rtrim((string) $this->getSettings()->ApplicationUrl, '/'); + if (!filter_var($baseUrl, FILTER_VALIDATE_URL) || parse_url($baseUrl, PHP_URL_SCHEME) !== 'https' + || parse_url($baseUrl, PHP_URL_QUERY) !== null || parse_url($baseUrl, PHP_URL_FRAGMENT) !== null + || parse_url($baseUrl, PHP_URL_USER) !== null || parse_url($baseUrl, PHP_URL_PASS) !== null) { + throw new RuntimeException('Configure an HTTPS ApplicationUrl before sending reset emails.'); + } + $token = bin2hex(random_bytes(32)); + $db = $this->connect(); + $stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (:hash, :id, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))'); + $hash = hash('sha256', $token); + $stmt->execute([':hash' => $hash, ':id' => $user['user_serial']]); + $url = $baseUrl . '/main.php?Xaction=resetpassword&token=' . rawurlencode($token); + if (!$this->deliverResetEmail($user, $url)) { + $stmt = $db->prepare('DELETE FROM password_resets WHERE token_hash = :hash'); + $stmt->execute([':hash' => $hash]); + throw new RuntimeException('Password reset email delivery failed.'); + } + } + + protected function deliverResetEmail(array $user, string $url): bool { + return (new Email())->sendPasswordReset($user, $url); + } + + public function resetPassword(string $token, string $password, string $confirmation): bool { + $error = self::passwordError($password, $confirmation); + if ($error) + throw new InvalidArgumentException($error); + if (!preg_match('/\A[a-f0-9]{64}\z/', $token)) + return false; + $db = $this->connect(); + $db->beginTransaction(); + try { + // Serialize resets and password changes on the user row. + $stmt = $db->prepare('SELECT user_serial FROM password_resets WHERE token_hash = :hash'); + $stmt->execute([':hash' => hash('sha256', $token)]); + $id = $stmt->fetchColumn(); + if (!$id) { + $db->rollBack(); + return false; + } + $stmt = $db->prepare("SELECT user_serial FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); + $stmt->execute([':id' => $id]); + if (!$stmt->fetch()) { + $db->rollBack(); + return false; + } + $stmt = $db->prepare('SELECT token_hash FROM password_resets WHERE token_hash = :hash AND expires_at > UTC_TIMESTAMP() FOR UPDATE'); + $stmt->execute([':hash' => hash('sha256', $token)]); + if (!$stmt->fetch()) { + $db->rollBack(); + return false; + } + $this->storePassword($db, (int) $id, $password); + $db->commit(); + return true; + } catch (Throwable $e) { + if ($db->inTransaction()) + $db->rollBack(); + throw $e; + } + } + + public function changePassword(int $id, string $current, string $password, string $confirmation): bool { + $error = self::passwordError($password, $confirmation); + if ($error) + throw new InvalidArgumentException($error); + $db = $this->connect(); + $db->beginTransaction(); + try { + $stmt = $db->prepare("SELECT user_password_hash, user_auth_version FROM users WHERE user_serial = :id AND user_status = 'active' FOR UPDATE"); + $stmt->execute([':id' => $id]); + $user = $stmt->fetch(); + if (!$user || (int) $user['user_auth_version'] !== ($_SESSION['auth_version'] ?? -1) || !password_verify($current, $user['user_password_hash'])) { + $db->rollBack(); + return false; + } + $this->storePassword($db, $id, $password); + $db->commit(); + return true; + } catch (Throwable $e) { + if ($db->inTransaction()) + $db->rollBack(); + throw $e; + } + } + + private function storePassword(PDO $db, int $id, string $password): void { + $stmt = $db->prepare('UPDATE users SET user_password_hash = :hash, user_change_password = 0, user_auth_version = user_auth_version + 1 WHERE user_serial = :id'); + $stmt->execute([':hash' => password_hash($password, PASSWORD_DEFAULT), ':id' => $id]); + $stmt = $db->prepare('DELETE FROM password_resets WHERE user_serial = :id'); + $stmt->execute([':id' => $id]); + } +} diff --git a/classes/Authentication.php b/classes/Authentication.php index f94e988..9e7097d 100644 --- a/classes/Authentication.php +++ b/classes/Authentication.php @@ -1,394 +1,49 @@ settings = new SimpleXMLElement((file_exists("xml/settings.xml") ? file_get_contents("xml/settings.xml") : "")); - - if ($this->settings->count() === 0) { - - error_log("GHR Authentication Class : Unable to initialize settings"); - exit(); - } - - // Open Database Connections - $this->ghrconnection = $this->connect(); - } - - // ====================================== - // Return a GHR MySQL Database Connection - // ====================================== - - public function connect() { - - if ($this->settings->Environment == 'Production') { - $host = 'localhost'; - } else { - $host = '192.168.1.190'; - $host = '100.67.215.67'; - } - - try { - - $database = (string) ($this->settings->DatabaseName ?? ""); - $user = (string) ($this->settings->DatabaseUser ?? ""); - $password = (string) ($this->settings->DatabasePassword ?? ""); - - $connection = new PDO("mysql:host={$host};dbname={$database};charset=utf8", $user, $password); - - $connection->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION); - } catch (PDOException | Exception $e) { - - $this->debug($e->getMessage()); - exit(); - } - - return $connection; - } - - // ======================== - // Generate Random Password - // ======================== - - private function generateRandomPassword($length = 12) { - - $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_+='; - $password = ''; - - for ($i = 0; $i < $length; $i++) { - $index = rand(0, strlen($characters) - 1); - $password .= $characters[$index]; - } - - return $password; - } - - // =============== - // Debug an Object - // =============== - - private function debug($object = "") { - - echo "
";
-        print_r($object);
-        exit();
-    }
-
-    // ==============
-    // Reset Password
-    // ==============
+/** Reset page controller; account storage and email are shared with the other flows. */
+class Authentication extends Base {
 
     public function resetPassword() {
-
-        $username = filter_input(INPUT_POST, "user_name") ?: "";
-
-        if (empty($username)) {
-            return false;
-        }
-
-        $exists = $this->userExists($username);
-
-        if (!$exists) {
-            return false;
-        }
-
-        $user = $this->getUser($username);
-
-        $password = $this->resetUsersPassword($user->record->user_serial);
-
-        $this->emailUserPasswordResetLink($user, $password);
-        $this->journalEntry("{$username} reset their password.");
-
-        return true;
-    }
-
-    // ===========
-    // User Exists
-    // ===========
-
-    private function userExists($username) {
-
-        $SQL = 'select user_name from view_users where user_name = :username';
-
-        $statement = $this->ghrconnection->prepare($SQL);
-        $statement->bindValue(':username', $username, PDO::PARAM_STR);
-        $statement->execute();
-
-        $user = $statement->fetch(PDO::FETCH_ASSOC);
-
-        return ($user !== false);
-    }
-
-    // =================
-    // Get a User Object
-    // =================
-
-    private function getUser($username) {
-
-        $SQL = 'select user_serial, user_name, user_email from view_users where user_name = :username';
-
-        $statement = $this->ghrconnection->prepare($SQL);
-        $statement->bindValue(':username', $username, PDO::PARAM_STR);
-        $statement->execute();
-
-        $user = $statement->fetch(PDO::FETCH_ASSOC);
-
-        $xml = new SimpleXMLElement('');
-        $record = $xml->addChild('record');
-
-        $this->arrayToXml($user, $record);
-
-        return $xml;
-    }
-
-    private function arrayToXml(array $data, SimpleXMLElement $xml): void {
-        foreach ($data as $key => $value) {
-            $key = is_numeric($key) ? 'item' : $key;
-
-            if (is_array($value)) {
-                $child = $xml->addChild($key);
-                $this->arrayToXml($value, $child);
-            } else {
-                $xml->addChild($key, htmlspecialchars((string) $value));
-            }
-        }
-    }
-
-    // ========================
-    // Reset the Users Password
-    // ========================
-
-    private function resetUsersPassword($user_serial) {
-
-        $user_random_password = $this->generateRandomPassword();
-        $user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
-
-        $SQL = "update users 
-                   set user_password        = :user_password,
-                       user_change_password = :user_change_password
-                 where user_serial          = :user_serial";
-
-        $statement = $this->ghrconnection->prepare($SQL);
-
-        $statement->bindValue(":user_serial", $user_serial);
-        $statement->bindValue(":user_password", $user_temp_password);
-        $statement->bindValue(":user_change_password", 1);
-
-        $statement->execute();
-
-        return $user_random_password;
-    }
-
-    // =================================
-    // Email a users Password Reset Link
-    // =================================
-
-    public function emailUserPasswordResetLink($user, $password) {
-
-        if (!$user instanceof SimpleXMLElement) {
-            return;
-        }
-
-        // Prepare the email.
-
-        $XSLParms['USER-TEMP-PASSWORD'] = $password;
-
-        $to = $user->record->user_email;
-        $subject = "DEC International DMS  - Password Reset Request";
-        $message = $this->applyXSL($user, $this->getXSL("emailPasswordResetLink"), $XSLParms);
-
-        // Send the Email
-        $this->sendEmail($to, $subject, $message);
-    }
-
-    // =======================================
-    // Send an email with optional attachments
-    // =======================================
-
-    public function sendEmail($to = "", $subject = "", $message = "", $attachments = "", $attachment_names = "unknown") {
-
-        if (empty($to) or empty($subject) or empty($message)) {
-
-            return false;
-        }
-
-        $SmtpHost = $this->settings->SmtpServer;
-        $SmtpUsername = $this->settings->SmtpUserName;
-        $SmtpPassword = $this->settings->SmtpPassword;
-        $SmtpPort = $this->settings->SmtpPort;
-
-        $email = new PHPMailer(true);
-
-        // Only for development
-
-        $message = "" . $message . "";
-
-        try {
-
-            //Server settings
-            $email->isSMTP();
-            $email->Host = $SmtpHost;
-            $email->SMTPAuth = true;
-            $email->Username = $SmtpUsername; // Your Gmail address
-            $email->Password = $SmtpPassword;    // App password from Google
-//            $email->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // or 'tls'
-            $email->Port = $SmtpPort;
-
-            $email->isHTML(true);
-            $email->SetFrom("info@dec-international.com", "DEC-International");
-
-            if (is_array($to)) {
-                foreach ($to as $address) {
-                    $address = trim(str_replace(" ", "", $address));
-                    if (empty($address)) {
-                        continue;
+        $token = $_POST['token'] ?? $_GET['token'] ?? '';
+        $params = ['TOKEN' => $token, 'MESSAGE' => '', 'ERROR' => '', 'COMPLETE' => 'FALSE'];
+        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+            $this->requireCsrf();
+            if (($_POST['step'] ?? '') === 'reset') {
+                try {
+                    $auth = new AuthService();
+                    if (!$auth->allowAttempt('reset-submit-ip', $_SERVER['REMOTE_ADDR'] ?? '', 20, 900)) {
+                        throw new InvalidArgumentException('Too many attempts. Please try again later.');
                     }
-                    $email->addAddress($address);
-                }
-            } else {
-                $email->addAddress($to);
-            }
-
-            $email->Subject = $subject;
-            $email->Body = $message;
-
-            if (is_array($attachments)) {
-                foreach ($attachments as $index => $item) {
-                    if (!empty($item)) {
-                        $email->addStringAttachment($item, ($attachment_names[$index] ?? "unknown"));
+                    if (!$auth->resetPassword($token, $_POST['user_password'] ?? '', $_POST['user_password_confirm'] ?? '')) {
+                        throw new InvalidArgumentException('This reset link is invalid or expired. Request a new link.');
                     }
+                    $this->logoutSession();
+                    $_SESSION['password_reset_success'] = true;
+                    header('Location: main.php?Xaction=resetpassword', true, 303);
+                    exit;
+                } catch (InvalidArgumentException $e) {
+                    $params['ERROR'] = $e->getMessage();
                 }
             } else {
-                if (!empty($attachments)) {
-                    $email->addStringAttachment($attachments, $attachment_names);
+                try {
+                    (new AuthService())->requestReset(trim($_POST['user_email'] ?? ''));
+                } catch (Throwable $e) {
+                    error_log('Password reset request failed: ' . $e->getMessage());
                 }
+                $_SESSION['password_reset_requested'] = true;
+                header('Location: main.php?Xaction=resetpassword', true, 303);
+                exit;
             }
-
-            $success = $email->Send();
-        } catch (phpMailerException $e) {
-
-            $this->journalEntry($e->errorMessage());
-            $success = false;
-        } catch (Exception $e) {
-
-            $this->journalEntry($e->getMessage());
-            $success = false;
         }
-
-        return $success;
-    }
-
-    // ======================
-    // Create a Journal Entry
-    // ======================
-
-    public function journalEntry($journal_entry = "") {
-
-        if (empty($journal_entry)) {
-            return;
+        if (!empty($_SESSION['password_reset_success'])) {
+            $params['MESSAGE'] = 'Your password has been changed. Please sign in with your new password.';
+            $params['COMPLETE'] = 'TRUE';
+            unset($_SESSION['password_reset_success']);
+        } elseif (!empty($_SESSION['password_reset_requested'])) {
+            $params['MESSAGE'] = 'If an active account matches, you will receive a password reset link. The link expires in 30 minutes.';
+            unset($_SESSION['password_reset_requested']);
         }
-
-        $SQL = "insert into journal
-      
-                      ( journal_origin,
-                        journal_ip,
-                        journal_entry )
-
-              VALUES ( :journal_origin,
-                       :journal_ip,
-                       :journal_entry )";
-
-        $statement = $this->ghrconnection->prepare($SQL);
-
-        $statement->bindValue(":journal_origin", "Authentication Class");
-        $statement->bindValue(":journal_ip", $_SERVER["REMOTE_ADDR"] ?? "");
-        $statement->bindValue(":journal_entry", $journal_entry);
-
-        $statement->execute();
-    }
-
-    // ==========================================
-    // Apply an XSL Stylesheet to an XML Document
-    // ==========================================
-
-    public function applyXSL($XMLString = "", $XSLString = "", $XSLParms = "") {
-
-        if ($XMLString instanceof SimpleXMLElement) {
-            $XMLString = $XMLString->asXML();
-        }
-
-        if (empty($XSLString)) {
-            trigger_error("No XSL Stylesheet provided.");
-        }
-
-        if (!is_array($XSLParms)) {
-            $XSLParms = array();
-        }
-
-        $XSLParms["RANDOM_NUMBER"] = rand();
-        $XSLParms["TODAY_YYYYMMDD"] = date("Y-m-d");
-        $XSLParms["TODAY_MMDDYYYY"] = date("m/d/Y");
-        $XSLParms["TODAY_HHMM"] = date("Hi");
-        $XSLParms["COPYRIGHT"] = date("Y");
-
-        $XML = new DOMDocument();
-        $XSL = new DOMDocument();
-        $XSLT = new XSLTProcessor();
-
-        if ($XMLString) {
-            $XML->loadXML($XMLString);
-        }
-
-        if ($XSLString) {
-            $XSL->loadXML($XSLString);
-        }
-
-        $XSLT->importStylesheet($XSL);
-
-        if ($XSLParms) {
-            $XSLT->setParameter("", $XSLParms);
-        }
-
-        $document = $XSLT->transformToXml($XML);
-
-        return $document;
-    }
-
-    // ========================
-    // Return an XSL Stylesheet
-    // ========================
-
-    public function getXSL($document = "") {
-
-        if (trim($document) == "") {
-            return "";
-        }
-
-        $document = "xsl/" . trim($document) . ".xsl";
-        $XSL = (file_exists($document) ? file_get_contents($document) : "");
-
-        return $XSL;
+        echo $this->applyXSL('', $this->getXSL('resetPassword'), $params);
+        exit;
     }
 }
diff --git a/classes/Autoloader.php b/classes/Autoloader.php
index de353d0..99e2adf 100644
--- a/classes/Autoloader.php
+++ b/classes/Autoloader.php
@@ -13,35 +13,37 @@ class Base_Autoloader {
     if (function_exists("__autoload")) {
       spl_autoload_register("__autoload");
     }
-    
+
     return spl_autoload_register(array("Base_Autoloader", "Load"), true, true);
-    
+
   }
 
   public static function Load($pClassName) {
-    
+
     if (class_exists($pClassName, false)) {
       return false;
     }
 
+    if (!preg_match('/\A[A-Za-z_][A-Za-z0-9_]*\z/', $pClassName)) return false;
+
     // Try for a "classes" Class first, then a "reports" Class.
-    
-    $pClassFilePath = "classes/{$pClassName}.php";
+
+    $pClassFilePath = __DIR__ . "/{$pClassName}.php";
 
     if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
-      
-      $pClassFilePath = "reports/{$pClassName}.php";
-      
+
+      $pClassFilePath = __DIR__ . "/../reports/{$pClassName}.php";
+
       if ((file_exists($pClassFilePath) === false) || (is_readable($pClassFilePath) === false)) {
         return false;
       }
-      
+
     }
 
     require($pClassFilePath);
-    
+
   }
 
 }
 
-?>
\ No newline at end of file
+?>
diff --git a/classes/Base.php b/classes/Base.php
index bf1f89f..3f68928 100644
--- a/classes/Base.php
+++ b/classes/Base.php
@@ -4,8 +4,8 @@
 // Base Class
 // ==========
 
-require_once "vendor/autoload.php";
-require_once "classes/Autoloader.php";
+require_once __DIR__ . "/../vendor/autoload.php";
+require_once __DIR__ . "/Autoloader.php";
 
 class Base {
 
@@ -13,31 +13,30 @@ class Base {
     // Constants
     // =========
 
-    const APPLICATION_NAME     = "Hunting Club Roster";
+    const APPLICATION_NAME = "Hunting Club Roster";
     const APPLICATION_MNEMONIC = "HUNTINGCLUBROSTER";
-    const VERSION              = "1.00";
-    const TIMEZONE             = "America/New_York";
-    
+    const VERSION = "1.00";
+    const TIMEZONE = "America/New_York";
     // =================
     // Session Variables
     // =================
 
-    const ENVIRONMENT          = "HUNTINGCLUBROSTER_ENVIRONMENT";
-    const ACTIVE_SESSION       = "HUNTINGCLUBROSTER_ACTIVE_SESSION";
-    const USER_SERIAL          = "HUNTINGCLUBROSTER_USER_SERIAL";
-    const USER_NAME            = "HUNTINGCLUBROSTER_USER_NAME";
-    const USER_FULL_NAME       = "HUNTINGCLUBROSTER_USER_FULL_NAME";
-    const USER_EMAIL           = "HUNTINGCLUBROSTER_USER_EMAIL";
-    const USER_ROLE            = "HUNTINGCLUBROSTER_USER_ROLE";
-    const USER_AUTHENTICATED   = "HUNTINGCLUBROSTER_USER_AUTHENTICATED";
-    const REPORTS_GET          = "HUNTINGCLUBROSTER_REPORTS_GET";
-    const REPORTS_POST         = "HUNTINGCLUBROSTER_REPORTS_POST";
-    const THEME                = "HUNTINGCLUBROSTER_THEME";
-    const AJAX                 = "HUNTINGCLUBROSTER_AJAX";
-    const TODAY                = "HUNTINGCLUBROSTER_TODAY";
-    const COPYRIGHT            = "HUNTINGCLUBROSTER_COPYRIGHT";
-    const PAGE                 = "HUNTINGCLUBROSTER_PAGE";
-    const PAGINATION_PAGE      = "HUNTINGCLUBROSTER_PAGINATION_PAGE";
+    const ENVIRONMENT = "HUNTINGCLUBROSTER_ENVIRONMENT";
+    const ACTIVE_SESSION = "HUNTINGCLUBROSTER_ACTIVE_SESSION";
+    const USER_SERIAL = "HUNTINGCLUBROSTER_USER_SERIAL";
+    const USER_NAME = "HUNTINGCLUBROSTER_USER_NAME";
+    const USER_FULL_NAME = "HUNTINGCLUBROSTER_USER_FULL_NAME";
+    const USER_EMAIL = "HUNTINGCLUBROSTER_USER_EMAIL";
+    const USER_ROLE = "HUNTINGCLUBROSTER_USER_ROLE";
+    const USER_AUTHENTICATED = "HUNTINGCLUBROSTER_USER_AUTHENTICATED";
+    const REPORTS_GET = "HUNTINGCLUBROSTER_REPORTS_GET";
+    const REPORTS_POST = "HUNTINGCLUBROSTER_REPORTS_POST";
+    const THEME = "HUNTINGCLUBROSTER_THEME";
+    const AJAX = "HUNTINGCLUBROSTER_AJAX";
+    const TODAY = "HUNTINGCLUBROSTER_TODAY";
+    const COPYRIGHT = "HUNTINGCLUBROSTER_COPYRIGHT";
+    const PAGE = "HUNTINGCLUBROSTER_PAGE";
+    const PAGINATION_PAGE = "HUNTINGCLUBROSTER_PAGINATION_PAGE";
 
     // =================
     // Class Constructor
@@ -56,7 +55,7 @@ class Base {
 
     public function appendSXE(SimpleXMLElement $from, SimpleXMLElement $to) {
 
-        $toDOM   = dom_import_simplexml($to);
+        $toDOM = dom_import_simplexml($to);
         $fromDOM = dom_import_simplexml($from);
 
         $toDOM->appendChild($toDOM->ownerDocument->importNode($fromDOM, true));
@@ -80,30 +79,31 @@ class Base {
             $XSLParms = array();
         }
 
-        $XSLParms["APPLICATION_NAME"]     = self::APPLICATION_NAME;
+        $XSLParms["CSRF_TOKEN"] = $this->csrfToken();
+        $XSLParms["APPLICATION_NAME"] = self::APPLICATION_NAME;
         $XSLParms["APPLICATION_MNEMONIC"] = self::APPLICATION_MNEMONIC;
-        $XSLParms["VERSION"]              = self::VERSION;
-        $XSLParms["THEME"]                = $_SESSION[self::THEME]            ?? self::THEME;
-        $XSLParms["ENVIRONMENT"]          = $_SESSION[self::ENVIRONMENT]      ?? "Production";
-        $XSLParms["USER_NAME"]            = $_SESSION[self::USER_NAME]        ?? "";
-        $XSLParms["USER_FULL_NAME"]       = $_SESSION[self::USER_FULL_NAME]   ?? "";
-        $XSLParms["USER_ROLE"]            = $_SESSION[self::USER_ROLE]        ?? "Guest";
-        $XSLParms["RANDOM_NUMBER"]        = rand();
-        $XSLParms["TODAY_YYYYMMDD"]       = date("Y-m-d");
-        $XSLParms["TODAY_MMDDYYYY"]       = date("m/d/Y");
-        $XSLParms["TODAY_HHMM"]           = date("Hi");
-        $XSLParms["COPYRIGHT"]            = date("Y");
+        $XSLParms["VERSION"] = self::VERSION;
+        $XSLParms["THEME"] = $_SESSION[self::THEME] ?? self::THEME;
+        $XSLParms["ENVIRONMENT"] = $_SESSION[self::ENVIRONMENT] ?? "Production";
+        $XSLParms["USER_NAME"] = $XSLParms["USER_NAME"] ?? $_SESSION[self::USER_NAME] ?? "";
+        $XSLParms["USER_FULL_NAME"] = $_SESSION[self::USER_FULL_NAME] ?? "";
+        $XSLParms["USER_ROLE"] = $_SESSION[self::USER_ROLE] ?? "Guest";
+        $XSLParms["RANDOM_NUMBER"] = rand();
+        $XSLParms["TODAY_YYYYMMDD"] = date("Y-m-d");
+        $XSLParms["TODAY_MMDDYYYY"] = date("m/d/Y");
+        $XSLParms["TODAY_HHMM"] = date("Hi");
+        $XSLParms["COPYRIGHT"] = date("Y");
 
-        $XML  = new DOMDocument();
-        $XSL  = new DOMDocument();
+        $XML = new DOMDocument();
+        $XSL = new DOMDocument();
         $XSLT = new XSLTProcessor();
 
         if ($XMLString) {
-            $XML->loadXML($XMLString);
+            $XML->loadXML($XMLString, LIBXML_NONET);
         }
 
         if ($XSLString) {
-            $XSL->loadXML($XSLString);
+            $XSL->loadXML($XSLString, LIBXML_NONET);
         }
 
         $XSLT->importStylesheet($XSL);
@@ -112,6 +112,8 @@ class Base {
             $XSLT->setParameter("", $XSLParms);
         }
 
+        if (!$XML->documentElement)
+            $XML->loadXML('');
         $document = $XSLT->transformToXml($XML);
 
         return $document;
@@ -122,27 +124,26 @@ class Base {
     // ============================
 
     public function connect(): PDO {
-        
+
         $settings = $this->getSettings();
-        
-        $host     = ((string) $settings->Environment === 'Production') ? 'localhost' : '192.168.1.190';
-        $database =  (string) $settings->DatabaseName;
-        $username =  (string) $settings->DatabaseUser;
-        $password =  (string) $settings->DatabasePassword;
+
+        $host = (string) $settings->DatabaseServer;
+        $database = (string) $settings->DatabaseName;
+        $username = (string) $settings->DatabaseUser;
+        $password = (string) $settings->DatabasePassword;
 
         try {
 
-            $dsn = sprintf('mysql:host=%s;dbname=%s;charset=utf8mb4', $host, $database);
+            $dsn = sprintf('mysql:host=%s;port=%d;dbname=%s;charset=utf8mb4', $host, (int) ($settings->DatabasePort ?? 3306), $database);
 
-            return new PDO($dsn, $username, $password, 
-                      [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, 
-                       PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC, 
-                       PDO::ATTR_EMULATE_PREPARES => false, 
-                       PDO::ATTR_PERSISTENT => false,]);
-            
+            return new PDO($dsn, $username, $password,
+                    [PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
+                PDO::ATTR_DEFAULT_FETCH_MODE => PDO::FETCH_ASSOC,
+                PDO::ATTR_EMULATE_PREPARES => false,
+                PDO::ATTR_PERSISTENT => false,]);
         } catch (PDOException $e) {
 
-            $this->logError("Database Connection Failed: " . $e->getMessage());
+            error_log("Database Connection Failed: " . $e->getMessage());
 
             throw new Exception("Unable to connect to the database.", 0, $e);
         }
@@ -182,32 +183,17 @@ class Base {
     // Custom error handler to display informative error page
     // ======================================================
 
-    public function displayError($error_number = "", $error_message = "", $error_file = "", $error_line = "") {
-
-        if (error_reporting() == 0) {
-            return;
+    public function displayError($error_number = 0, $error_message = '', $error_file = '', $error_line = 0) {
+        if (!(error_reporting() & $error_number)) {
+            return false;
         }
+        // Never log application failures through the failing database or SMTP service.
+        error_log("PHP {$error_number}: {$error_message} in {$error_file}:{$error_line}");
 
-        $error_file    = explode("/", $error_file);
-        $error_file    = end($error_file);
-        $error_message = str_replace("'", '"', $error_message);
-
-        (new Journal())->journalEntry("{$error_file} | {$error_line} | {$error_message}");
-
-        if (($_SESSION[self::ENVIRONMENT] ?? "Production") == "Production") {
-            (new Email())->sendErrorEmail($error_message, $error_file, $error_line);
+        if (in_array($error_number, [E_DEPRECATED, E_USER_DEPRECATED], true)) {
+            return true;
         }
-
-        $XSLParms["FILE_NAME"]     = $error_file;
-        $XSLParms["LINE_NUMBER"]   = $error_line;
-        $XSLParms["ERROR_MESSAGE"] = $error_message;
-        $XSLParms["ERROR_NUMBER"]  = $error_number;
-
-        $content = $this->applyXSL("", $this->getXSL("error"), $XSLParms);
-
-        $this->displayContent($content);
-
-        exit();  // Exit so that only one error is presented.
+        throw new ErrorException($error_message, 0, $error_number, $error_file, $error_line);
     }
 
     // =====================
@@ -241,71 +227,139 @@ class Base {
 
     public function do() {
 
-        $_SESSION[self::TODAY] = date("Y-m-d");
-
-        // Get the requested Action
-
-        $action  = filter_input(INPUT_POST, "action") ?: "";
-        $Xaction = filter_input(INPUT_GET, "Xaction") ?: "";
-
-        $action = (empty($Xaction) ? $action : $Xaction);
-
-        if ($Xaction == 'resetpassword') {
-            $this->resetpassword();
-        }
-
-        // Login
-
-        if (($_SESSION[self::USER_AUTHENTICATED] ?? false) === false) {
-
-            (new SignIn())->login();
-        }
-
-        // Determine if user needs a password change
-        if ($this->userChangePassword() === true) {
-            (new Users())->changePassword();
-            exit();
-        }
-
-        // Determine if this is an AJAX request.
-
-        $_SESSION[self::AJAX] = (isset($_SERVER["HTTP_X_REQUESTED_WITH"]) and strtolower($_SERVER["HTTP_X_REQUESTED_WITH"]) === "xmlhttprequest");
-
-        // Get the requested Action
-
-        $action  = filter_input(INPUT_POST, "action") ?: "";
-        $Xaction = filter_input(INPUT_GET, "Xaction") ?: "";
-
-        $action = (empty($Xaction) ? $action : $Xaction);
-
-        // Sign Out if requested.
-
-        if (strtolower(trim($action)) === "signout") {
+        if (empty($_SESSION[self::ACTIVE_SESSION])) {
             $this->initializeSession();
         }
 
-        // Process the requested Action.
+        $_SESSION[self::AJAX] = strtolower($_SERVER['HTTP_X_REQUESTED_WITH'] ?? '') === 'xmlhttprequest';
 
-        list($class, $method) = array_pad(explode('.', $action), 2, "");
+        $_SESSION[self::TODAY] = date('Y-m-d');
 
-        if (empty($method)) {
+        $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';
 
-            $method = $class;
-
-            if (method_exists(__CLASS__, $method)) {
-                $this->$method();
-                return;
-            }
-        } else {
-
-            if (method_exists($class, $method)) {
-                (new $class())->$method();
-                return;
-            }
+        if (!in_array($method, ['GET', 'POST'], true)) {
+            http_response_code(405);
+            exit('Method not allowed.');
         }
 
-        // Default to the Home Page.
-        $this->displayHome();
+        // Reject arrays and oversized values before passing input to string operations.
+        foreach (array_merge($_GET, $_POST) as $value) {
+            if (!is_string($value) || strlen($value) > 8192) {
+                http_response_code(400);
+                exit('Invalid request.');
+            }
+        }
+        $action = strtolower(trim($_POST['action'] ?? $_GET['Xaction'] ?? $_GET['action'] ?? ''));
+        if ($action === 'signout') {
+            if ($method !== 'POST') {
+                http_response_code(405);
+                exit('Use POST to sign out.');
+            }
+            $this->requireCsrf();
+            $ajax = $_SESSION[self::AJAX];
+            $this->logoutSession();
+            if ($ajax) {
+                header('Content-Type: application/json');
+                echo json_encode(true);
+                exit;
+            }
+            header('Location: main.php', true, 303);
+            exit;
+        }
+        if (in_array($action, ['resetpassword', 'authentication.resetpassword'], true)) {
+            (new Authentication())->resetPassword();
+        }
+        if (in_array($action, ['signup', 'signup.createaccount'], true)) {
+            (new SignUp())->createAccount();
+        }
+        if (in_array($action, ['signin', 'signin.login', 'users.login'], true)) {
+            (new SignIn())->login();
+        }
+        
+        $user = (new AuthService())->currentUser();
+        
+        if (!$user) {
+            $ajax = $_SESSION[self::AJAX];
+            if (!empty($_SESSION[self::USER_AUTHENTICATED])) {
+                $this->logoutSession();
+            }
+            if ($ajax) {
+                http_response_code(401);
+                exit('Please sign in again.');
+            }
+            (new SignIn())->login();
+        }
+        if ($method === 'POST') {
+            $this->requireCsrf();
+        }
+        if ((bool) $user['user_change_password']) {
+            (new Users())->changePassword();
+            return;
+        }
+        // Only named, reviewed entry points are routable. Helpers are never endpoints.
+        $routes = [
+            '' => [Base::class, 'displayHome', false],
+            './' => [Base::class, 'displayHome', false],
+            'displayhome' => [Base::class, 'displayHome', false],
+            'toggletheme' => [Base::class, 'toggleTheme', false],
+            'getzipcode' => [Base::class, 'getZipcode', false],
+            'validatedate' => [Base::class, 'validateDate', false],
+            'getage' => [Base::class, 'getAge', false],
+            'users.changepassword' => [Users::class, 'changePassword', false],
+            'print' => [Base::class, 'print', true],
+            'print_all_permitsbybuilder.print' => ['Print_All_PermitsByBuilder', 'print', true],
+            'export_allpermitsbybuildercsv.export' => ['Export_AllPermitsByBuilderCSV', 'export', true],
+            'export_allpermitsbybuilderexcel.export' => ['Export_AllPermitsByBuilderExcel', 'export', true],
+        ];
+        $adminRoutes = [
+            'Users' => ['setupUsers', 'addUser', 'editUser', 'deleteUser', 'resetPassword', 'userExists', 'getUser_AJAX', 'updateActiveStatus', 'userActive'],
+            'Alerts' => ['setupAlerts', 'addAlert', 'editAlert', 'deleteAlert'],
+            'Dropdowns' => ['setupDropdowns', 'setupDropdowntypes', 'addDropdown', 'editDropdown', 'deleteDropdown', 'addDropdowntype', 'editDropdowntype', 'deleteDropdowntype', 'dropdownExists', 'dropdowntypeExists', 'dropdowntypetitleExists', 'dropdownActive', 'dropdowntypeActive', 'updateDropdownActive', 'updateDropdownDefault'],
+            'Popovers' => ['setupPopovers', 'addPopover', 'editPopover', 'deletePopover', 'popoverExists'],
+            'Reports' => ['setupReports', 'addReport', 'editReport', 'deleteReport', 'selectReport', 'reportExists'],
+            'Statuses' => ['setupStatuses', 'addStatus', 'editStatus', 'deleteStatus', 'statusExists', 'statusActive'],
+            'Journal' => ['viewJournal', 'clearJournal'],
+            'Notes' => ['addNote', 'editNote', 'deleteNote', 'addNoteQuick'],
+        ];
+        foreach ($adminRoutes as $class => $methods) {
+            foreach ($methods as $entry)
+                $routes[strtolower($class . '.' . $entry)] = [$class, $entry, true];
+        }
+        if (!isset($routes[$action])) {
+            http_response_code(404);
+            exit('Page not found.');
+        }
+        [$class, $entry, $adminOnly] = $routes[$action];
+        if ($adminOnly && !in_array($user['user_role'], ['Administrator', 'System Administrator'], true)) {
+            http_response_code(403);
+            exit('You do not have permission to perform this action.');
+        }
+        if ($method !== 'POST' && !in_array($action, ['', './', 'displayhome', 'users.changepassword'], true)) {
+            http_response_code(405);
+            exit('Use POST for this action.');
+        }
+        (new $class())->$entry();
+    }
+
+    public function csrfToken(): string {
+        if (empty($_SESSION['csrf_token']))
+            $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
+        return $_SESSION['csrf_token'];
+    }
+
+    public function requireCsrf(): void {
+        $token = $_POST['csrf_token'] ?? $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';
+        if (!is_string($token) || !hash_equals($this->csrfToken(), $token)) {
+            http_response_code(403);
+            exit('Your form expired. Reload the page and try again.');
+        }
+    }
+
+    public function logoutSession(): void {
+        $_SESSION = [];
+        session_regenerate_id(true);
+        $this->initializeSession();
+        $this->csrfToken();
     }
 
     // ==========================
@@ -314,12 +368,12 @@ class Base {
 
     protected function encrypt($string = "", $key = "") {
 
-        $key       = (empty($key)) ? __CLASS__ : $key;
+        $key = (empty($key)) ? __CLASS__ : $key;
 
-        $ivlen     = openssl_cipher_iv_length($cipher = "AES-128-CBC");
-        $iv        = openssl_random_pseudo_bytes($ivlen);
-        $raw       = openssl_encrypt($string, $cipher, $key, $options = OPENSSL_RAW_DATA, $iv);
-        $hmac      = hash_hmac("sha256", $raw, $key, $as_binary = true);
+        $ivlen = openssl_cipher_iv_length($cipher = "AES-128-CBC");
+        $iv = openssl_random_pseudo_bytes($ivlen);
+        $raw = openssl_encrypt($string, $cipher, $key, $options = OPENSSL_RAW_DATA, $iv);
+        $hmac = hash_hmac("sha256", $raw, $key, $as_binary = true);
         $encrypted = base64_encode($iv . $hmac . $raw);
 
         return $encrypted;
@@ -366,7 +420,15 @@ class Base {
 
     public function getSettings() {
 
-        $settings = new SimpleXMLElement($this->getXML("settings"));
+        $path = getenv('HCR_SETTINGS_FILE') ?: (is_file(__DIR__ . '/../xml/settings.local.xml') ? __DIR__ . '/../xml/settings.local.xml' : __DIR__ . '/../xml/settings.xml');
+        $settings = simplexml_load_file($path, SimpleXMLElement::class, LIBXML_NONET);
+        if (!$settings)
+            throw new RuntimeException('Unable to load application settings.');
+        foreach (['Environment' => 'HCR_ENVIRONMENT', 'DatabaseServer' => 'HCR_DB_HOST', 'DatabasePort' => 'HCR_DB_PORT', 'DatabaseName' => 'HCR_DB_NAME', 'DatabaseUser' => 'HCR_DB_USER', 'DatabasePassword' => 'HCR_DB_PASSWORD', 'SmtpServer' => 'HCR_SMTP_HOST', 'SmtpUserName' => 'HCR_SMTP_USER', 'SmtpPassword' => 'HCR_SMTP_PASSWORD', 'SmtpPort' => 'HCR_SMTP_PORT', 'SmtpFromEmail' => 'HCR_SMTP_FROM', 'ApplicationUrl' => 'HCR_APPLICATION_URL'] as $name => $variable) {
+            $value = getenv($variable);
+            if ($value !== false)
+                $settings->$name = $value;
+        }
 
         return $settings;
     }
@@ -378,11 +440,11 @@ class Base {
     public function getAge($birthdate = "", $date = "") {
 
         $birthdate = filter_input(INPUT_POST, "birthdate", FILTER_UNSAFE_RAW) ?: $birthdate;
-        $date      = filter_input(INPUT_POST, "date", FILTER_UNSAFE_RAW) ?: $date;
+        $date = filter_input(INPUT_POST, "date", FILTER_UNSAFE_RAW) ?: $date;
 
-        $age       = 0;
+        $age = 0;
 
-        $date      = (empty($date))      ? new Datetime() : new DateTime($date);
+        $date = (empty($date)) ? new Datetime() : new DateTime($date);
         $birthdate = (empty($birthdate)) ? new Datetime() : new DateTime($birthdate);
 
         $age = $birthdate->diff($date)->format("%y");
@@ -529,8 +591,8 @@ class Base {
             return "";
         }
 
-        $document = "xml/" . trim($document) . ".xml";
-        $XML      = (file_exists($document) ? file_get_contents($document) : "");
+        $document = __DIR__ . "/../xml/" . trim($document) . ".xml";
+        $XML = (file_exists($document) ? file_get_contents($document) : "");
 
         return $XML;
     }
@@ -545,8 +607,8 @@ class Base {
             return "";
         }
 
-        $document = "xsl/" . trim($document) . ".xsl";
-        $XSL      = (file_exists($document) ? file_get_contents($document) : "");
+        $document = __DIR__ . "/../xsl/" . trim($document) . ".xsl";
+        $XSL = (file_exists($document) ? file_get_contents($document) : "");
 
         return $XSL;
     }
@@ -557,13 +619,13 @@ class Base {
 
     public function getZipcode($zip_code = 0) {
 
-        $zip_code      = filter_input(INPUT_POST, "zip_code", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-        $zip_code      = (int) substr((string) $zip_code, 0, 5);
+        $zip_code = filter_input(INPUT_POST, "zip_code", FILTER_SANITIZE_NUMBER_INT) ?: 0;
+        $zip_code = (int) substr((string) $zip_code, 0, 5);
 
         $zipCodeDetail = null;
 
-        $SQL = "select * 
-                  from zipcodes 
+        $SQL = "select *
+                  from zipcodes
                  where zipcode_code = {$zip_code}";
 
         $zipcodes = $this->getTable("zipcodes", $SQL);
@@ -571,9 +633,9 @@ class Base {
         if ($zipcodes->count() > 0) {
 
             $zipCodeDetail["ZipCode"] = $zip_code;
-            $zipCodeDetail["City"]    = (string) $zipcodes->record->zipcode_city;
-            $zipCodeDetail["State"]   = (string) $zipcodes->record->zipcode_state;
-            $zipCodeDetail["County"]  = (string) $zipcodes->record->zipcode_county;
+            $zipCodeDetail["City"] = (string) $zipcodes->record->zipcode_city;
+            $zipCodeDetail["State"] = (string) $zipcodes->record->zipcode_state;
+            $zipCodeDetail["County"] = (string) $zipcodes->record->zipcode_county;
             $zipCodeDetail["Country"] = (string) $zipcodes->record->zipcode_country;
         }
 
@@ -592,18 +654,18 @@ class Base {
 
         $settings = $this->getSettings();
 
-        $_SESSION[self::ACTIVE_SESSION]     = true;
-        $_SESSION[self::THEME]              = "light";
-        $_SESSION[self::ENVIRONMENT]        = (string) ($settings->Environment ?? "Production");
+        $_SESSION[self::ACTIVE_SESSION] = true;
+        $_SESSION[self::THEME] = "light";
+        $_SESSION[self::ENVIRONMENT] = (string) ($settings->Environment ?? "Production");
         $_SESSION[self::USER_AUTHENTICATED] = false;
-        $_SESSION[self::USER_SERIAL]        = 0;
-        $_SESSION[self::USER_NAME]          = "";
-        $_SESSION[self::USER_FULL_NAME]     = "";
-        $_SESSION[self::USER_EMAIL]         = "";
-        $_SESSION[self::USER_ROLE]          = "Guest";
-        $_SESSION[self::REPORTS_GET]        = "";
-        $_SESSION[self::REPORTS_POST]       = "";
-        $_SESSION[self::AJAX]               = false;
+        $_SESSION[self::USER_SERIAL] = 0;
+        $_SESSION[self::USER_NAME] = "";
+        $_SESSION[self::USER_FULL_NAME] = "";
+        $_SESSION[self::USER_EMAIL] = "";
+        $_SESSION[self::USER_ROLE] = "Guest";
+        $_SESSION[self::REPORTS_GET] = "";
+        $_SESSION[self::REPORTS_POST] = "";
+        $_SESSION[self::AJAX] = false;
     }
 
     // ============
@@ -647,7 +709,7 @@ class Base {
         $thatDOM = new DOMDocument();
         $thatDOM->loadXML($thatXML);
 
-        $xpath   = new DOMXpath($thisDOM);
+        $xpath = new DOMXpath($thisDOM);
         $xpathQuery = $xpath->query("/*");
 
         for ($i = 0; $i < $xpathQuery->length; $i++) {
@@ -665,11 +727,15 @@ class Base {
 
     public function print() {
 
-        $_SESSION[self::REPORTS_GET]  = $_GET;
+        $_SESSION[self::REPORTS_GET] = $_GET;
         $_SESSION[self::REPORTS_POST] = $_POST;
 
         $report = filter_input(INPUT_POST, "report") ?: "";
 
+        if ($report !== 'Print_All_PermitsByBuilder') {
+            http_response_code(404);
+            exit('Report not found.');
+        }
         (new $report())->print();
     }
 
@@ -697,10 +763,10 @@ class Base {
 
     public function validateDate($date = "", $format = "Y-m-d") {
 
-        $date     = filter_input(INPUT_POST, "date") ?: $date;
+        $date = filter_input(INPUT_POST, "date") ?: $date;
 
         $datetime = DateTime::createFromFormat($format, $date);
-        $valid    = $datetime && $datetime->format($format) === $date;
+        $valid = $datetime && $datetime->format($format) === $date;
 
         if ($_SESSION[self::AJAX]) {
             echo json_encode($valid);
@@ -716,10 +782,10 @@ class Base {
     public function generateRandomPassword($length = 12) {
 
         $characters = '0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!@#$%^&*()-_+=';
-        $password   = '';
+        $password = '';
 
         for ($i = 0; $i < $length; $i++) {
-            $index = rand(0, strlen($characters) - 1);
+            $index = random_int(0, strlen($characters) - 1);
             $password .= $characters[$index];
         }
 
@@ -759,7 +825,7 @@ class Base {
 
     public function testing() {
 
-        $to      = "james.richie@onesourceits.com";
+        $to = "james.richie@onesourceits.com";
         $subject = "Test Subject";
         $message = "Test Message";
 
@@ -773,19 +839,4 @@ class Base {
     // =====================
     // Forgot/Reset Password
     // =====================
-
-    public function resetPassword() {
-
-        require_once 'classes/Authentication.php';
-
-        $XSLParms = [
-            "BTN_SUBMIT"                => "main.php?action=Users.signIn&step=authenticate",
-            "RESET_PASSWORD_SUCCESSFUL" => (new Authentication())->resetPassword() ? "TRUE" : "FALSE"
-        ];
-
-        echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-        exit;
-    }
 }
-
-?>
diff --git a/classes/Email.php b/classes/Email.php
index 90bbcf7..11c653a 100644
--- a/classes/Email.php
+++ b/classes/Email.php
@@ -9,6 +9,13 @@ use PHPMailer\PHPMailer\Exception;
 
 class Email extends Base {
 
+    public function sendPasswordReset(array $user, string $url): bool {
+        $xml = new SimpleXMLElement('');
+        $xml->record->user_name = $user['user_first_name'] ?: $user['user_name'];
+        $body = $this->applyXSL($xml, $this->getXSL('emailPasswordResetLink'), ['RESET_URL' => $url]);
+        return $this->sendEmail((string) $user['user_email'], self::APPLICATION_NAME . ' - Password reset', $body);
+    }
+
     // Variables.
 
     private $current_user_name = "";
@@ -32,45 +39,14 @@ class Email extends Base {
     // Email a new users credentials
     // =============================
 
-    public function emailNewUserCredentials($user, $password) {
-
-        if (!$user instanceof SimpleXMLElement) {
-            return;
-        }
-
-        // Prepare the email
-
-        $to = (string) $user->record->user_email;
-
-        $XSLParms['USER-TEMP-PASSWORD'] = $password;
-
-        $subject = "New User Credentials";
-        $message = $this->applyXSL($user, $this->getXSL("emailNewUserCredentials"), $XSLParms);
-
-        // Send the Email
-        $this->sendEmail($to, $subject, $message);
+    public function emailNewUserCredentials($user, $password = null) {
+        $this->emailUserPasswordResetLink($user);
     }
 
-    // =================================
-    // Email a users Password Reset Link
-    // =================================
-
-    public function emailUserPasswordResetLink($user, $password) {
-
-        if (!$user instanceof SimpleXMLElement) {
-            return;
+    public function emailUserPasswordResetLink($user, $password = null) {
+        if ($user instanceof SimpleXMLElement) {
+            (new AuthService())->requestReset((string) $user->record->user_email);
         }
-
-        // Prepare the email.
-
-        $XSLParms['USER-TEMP-PASSWORD'] = $password;
-
-        $to = (string) $user->record->user_email;
-        $subject = "Password Reset Request";
-        $message = $this->applyXSL($user, $this->getXSL("emailPasswordResetLink"), $XSLParms);
-
-        // Send the Email
-        $this->sendEmail($to, $subject, $message);
     }
 
     // =======================================
@@ -101,15 +77,17 @@ class Email extends Base {
 
             //Server settings
             $email->isSMTP();
-            $email->Host = $SmtpHost;
+            $email->Host = (string) $SmtpHost;
             $email->SMTPAuth = true;
-            $email->Username = $SmtpUsername; // Your Gmail address
-            $email->Password = $SmtpPassword;    // App password from Google
-//            $email->SMTPSecure = PHPMailer::ENCRYPTION_STARTTLS; // or 'tls'
-            $email->Port = $SmtpPort;
+            $email->Username = (string) $SmtpUsername;
+            $email->Password = (string) $SmtpPassword;
+            $email->Port = (int) $SmtpPort;
+            $email->SMTPSecure = $email->Port === 465 ? PHPMailer::ENCRYPTION_SMTPS : PHPMailer::ENCRYPTION_STARTTLS;
+            $email->Timeout = 15;
+            $email->CharSet = 'UTF-8';
 
             $email->isHTML(true);
-            $email->SetFrom("info@onesourceits.com", "OneSource IT Solutions LLC");
+            $email->setFrom((string) ($settings->SmtpFromEmail ?? $settings->SmtpUserName), self::APPLICATION_NAME);
 
             if (is_array($to)) {
                 foreach ($to as $address) {
@@ -125,6 +103,7 @@ class Email extends Base {
 
             $email->Subject = $subject;
             $email->Body = $message;
+            $email->AltBody = html_entity_decode(strip_tags(preg_replace('/]*>(.*?)<\/a>/is', '$2 ($1)', $message)), ENT_QUOTES | ENT_HTML5, 'UTF-8');
 
             if (is_array($attachments)) {
                 foreach ($attachments as $index => $item) {
@@ -139,13 +118,9 @@ class Email extends Base {
             }
 
             $success = $email->Send();
-        } catch (phpMailerException $e) {
+        } catch (\Throwable $e) {
 
-            (new Journal())->journalEntry($e->errorMessage());
-            $success = false;
-        } catch (Exception $e) {
-
-            (new Journal())->journalEntry($e->getMessage());
+            error_log('Email delivery failed: ' . $e->getMessage());
             $success = false;
         }
 
diff --git a/classes/SignIn.php b/classes/SignIn.php
index 4021933..45f655d 100644
--- a/classes/SignIn.php
+++ b/classes/SignIn.php
@@ -1,118 +1,16 @@
 current_user_name       = $_SESSION[self::USER_NAME]       ?? "";
-        $this->current_user_full_name  = $_SESSION[self::USER_FULL_NAME]  ?? "";
-        $this->current_user_email      = $_SESSION[self::USER_EMAIL]      ?? "";
-    }
-
-    // ========================
-    // Login to the application
-    // ========================
-
     public function login() {
-
-        $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
-        switch ($step) {
-
-            case "prompt":
-
-                $XSLParms["BTN_SUBMIT"]     = "main.php?action=SignIn.login&step=authenticate";
-                $XSLParms["INVALID_SIGNIN"] = "FALSE";
-
-                echo $this->applyXSL("", $this->getXSL("login"), $XSLParms);
-                exit();
-
-                break;
-
-            case "authenticate":
-
-                $user_name     = trim(filter_input(INPUT_POST, "user_name") ?: "");
-                $user_password = filter_input(INPUT_POST, "user_password")  ?: "";
-
-                $XSLParms["BTN_SUBMIT"]     = "main.php?action=SignIn.login&step=authenticate";
-                $XSLParms["INVALID_SIGNIN"] = "TRUE";
-
-                $user = (new Users())->getUserByUserName($user_name);
-
-                if ($user->count() == 0) {
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
-                }
-
-                if ((string) $user->record->user_status !== 'active') {
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
-                }
-
-                $stored_hashed_password = $user->record->user_password_hash;
-
-                if (!password_verify($user_password, $stored_hashed_password)) {
-
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
-                }
-
-                session_regenerate_id(true);
-
-                $_SESSION[self::USER_AUTHENTICATED] = true;
-                $_SESSION[self::USER_SERIAL]        = (int)    $user->record->user_serial;
-                $_SESSION[self::USER_NAME]          = (string) $user->record->user_name;
-                $_SESSION[self::USER_FULL_NAME]     = (string) $user->record->user_full_name;
-                $_SESSION[self::USER_EMAIL]         = (string) $user->record->user_email;
-                $_SESSION[self::USER_ROLE]          = (string) $user->record->user_role;
-
-                $this->current_user_serial          = $_SESSION[self::USER_SERIAL] ?? 0;
-                $this->current_user_name            = $_SESSION[self::USER_NAME]   ?? "";
-                $this->current_user_email           = $_SESSION[self::USER_EMAIL]  ?? "";
-
-                $system_admins = $this->getSettings()->xpath("//SystemAdministrator");
-
-                foreach ($system_admins as $admin) {
-                    if ((string) $admin === $user_name) {
-                        $_SESSION[self::USER_ROLE] = "System Administrator";
-                        break;
-                    }
-                }
-
-                (new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} logged in ({$_SESSION[self::USER_ROLE]}).");
-
-                (new Users())->updateLogin();
-
-                header("Location: ./");
-                exit();
-
-                break;
-
-            default:
-                // User NOT Validated.
-                header("Location: ./");
-                exit();
+        $params = ['BTN_SUBMIT' => 'main.php', 'INVALID_SIGNIN' => 'FALSE'];
+        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+            $this->requireCsrf();
+            if ((new AuthService())->login(trim($_POST['user_email'] ?? $_POST['user_name'] ?? ''), $_POST['user_password'] ?? '')) {
+                header('Location: main.php', true, 303);
+                exit;
+            }
+            $params['INVALID_SIGNIN'] = 'TRUE';
         }
+        echo $this->applyXSL('', $this->getXSL('login'), $params);
+        exit;
     }
 }
-
-?>
diff --git a/classes/SignUp.php b/classes/SignUp.php
index 8e59dfd..15368bd 100644
--- a/classes/SignUp.php
+++ b/classes/SignUp.php
@@ -1,103 +1,32 @@
 applyXSL("", $this->getXSL("createAccount"), $XSLParms);
-                exit();
-
-                break;
-
-            case "create":
-
-                $user_name = trim(filter_input(INPUT_POST, "user_name") ?: "");
-                $user_password = filter_input(INPUT_POST, "user_password") ?: "";
-
-                $XSLParms["BTN_SUBMIT"] = "main.php?action=SignIn.login&step=authenticate";
-                $XSLParms["INVALID_SIGNIN"] = "TRUE";
-
-                $user = (new Users())->getUserByUserName($user_name);
-
-                if ($user->count() == 0) {
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
+        $params = ['BTN_SUBMIT' => 'main.php', 'REGISTER_ACTION' => 'SignUp.createAccount'];
+        if (($_SERVER['REQUEST_METHOD'] ?? 'GET') === 'POST') {
+            $this->requireCsrf();
+            try {
+                $auth = new AuthService();
+                if (!$auth->allowAttempt('signup-ip', $_SERVER['REMOTE_ADDR'] ?? '', 5, 3600)) {
+                    throw new InvalidArgumentException('Too many account requests. Please try again later.');
                 }
-
-                if ((string) $user->record->user_status !== 'active') {
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
+                $auth->register($_POST);
+                $_SESSION['signup_success'] = true;
+                header('Location: main.php?Xaction=signup', true, 303);
+                exit;
+            } catch (InvalidArgumentException $e) {
+                $params['SIGNUP_ERROR'] = $e->getMessage();
+                foreach (['user_first_name', 'user_last_name', 'user_email', 'user_name'] as $field) {
+                    $params[strtoupper($field)] = $_POST[$field] ?? '';
                 }
-
-                $stored_hashed_password = $user->record->user_password_hash;
-
-                if (!password_verify($user_password, $stored_hashed_password)) {
-
-                    echo $this->applyXSL("", $this->getXSL("signIn"), $XSLParms);
-                    die();
-                }
-
-                session_regenerate_id(true);
-
-                $_SESSION[self::USER_AUTHENTICATED] = true;
-                $_SESSION[self::USER_SERIAL] = (int) $user->record->user_serial;
-                $_SESSION[self::USER_NAME] = (string) $user->record->user_name;
-                $_SESSION[self::USER_FULL_NAME] = (string) $user->record->user_full_name;
-                $_SESSION[self::USER_EMAIL] = (string) $user->record->user_email;
-                $_SESSION[self::USER_ROLE] = (string) $user->record->user_role;
-
-                $this->current_user_serial = $_SESSION[self::USER_SERIAL] ?? 0;
-                $this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
-                $this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
-
-                $system_admins = $this->getSettings()->xpath("//SystemAdministrator");
-
-                foreach ($system_admins as $admin) {
-                    if ((string) $admin === $user_name) {
-                        $_SESSION[self::USER_ROLE] = "System Administrator";
-                        break;
-                    }
-                }
-
-                (new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} logged in ({$_SESSION[self::USER_ROLE]}).");
-
-                (new Users())->updateLogin();
-
-                header("Location: ./");
-                exit();
-
-                break;
-
-            default:
-                // User NOT Validated.
-                header("Location: ./");
-                exit();
+            }
         }
+        if (!empty($_SESSION['signup_success'])) {
+            $params['SIGNUP_SUCCESSFUL'] = 'TRUE';
+            unset($_SESSION['signup_success']);
+        }
+        echo $this->applyXSL('', $this->getXSL('createAccount'), $params);
+        exit;
     }
 }
-
-?>
diff --git a/classes/Users.php b/classes/Users.php
index 6525521..d107d95 100644
--- a/classes/Users.php
+++ b/classes/Users.php
@@ -25,6 +25,7 @@ class Users extends Base {
 
         date_default_timezone_set(self::TIMEZONE);
 
+        $this->current_user_serial = (int) ($_SESSION[self::USER_SERIAL] ?? 0);
         $this->current_user_name = $_SESSION[self::USER_NAME] ?? "";
         $this->current_user_email = $_SESSION[self::USER_EMAIL] ?? "";
         $this->pagination_page = $_SESSION[self::PAGINATION_PAGE] ?? 1;
@@ -100,9 +101,13 @@ class Users extends Base {
             case "add":
 
                 $user_name = filter_input(INPUT_POST, "user_name") ?: "";
-                $user_client_name = filter_input(INPUT_POST, "user_client_name") ?: "";
+                $user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
                 $user_email = filter_input(INPUT_POST, "user_email") ?: "";
                 $user_role = filter_input(INPUT_POST, "user_role") ?: "";
+                if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
+                    http_response_code(422); exit('Enter a valid username, email address and role.');
+                }
+
 
                 $user_name = strtoupper(preg_replace("/\s+/", " ", $user_name));
 
@@ -110,31 +115,31 @@ class Users extends Base {
                 $user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
 
                 $user_change_password = (int) true;
-                $user_active = (int) true;
+                $user_active = 'active';
 
                 // Add the User
 
                 $connection = $this->connect();
 
                 $SQL = "insert into users
-          
+
                          (  user_name,
                             user_email,
-                            user_client_name,
-                            user_password,
+                            user_first_name,
+                            user_password_hash,
                             user_role,
                             user_change_password,
-                            user_active,
+                            user_status,
                             user_creator,
                             user_created )
 
                   values ( :user_name,
                            :user_email,
-                           :user_client_name,
-                           :user_password,
+                           :user_first_name,
+                           :user_password_hash,
                            :user_role,
                            :user_change_password,
-                           :user_active,
+                           :user_status,
                            :user_creator,
                            :user_created )";
 
@@ -142,11 +147,11 @@ class Users extends Base {
 
                 $statement->bindValue(":user_name", $user_name);
                 $statement->bindValue(":user_email", $user_email);
-                $statement->bindValue(":user_client_name", $user_client_name);
-                $statement->bindValue(":user_password", $user_temp_password);
+                $statement->bindValue(":user_first_name", $user_first_name);
+                $statement->bindValue(":user_password_hash", $user_temp_password);
                 $statement->bindValue(":user_role", $user_role);
                 $statement->bindValue(":user_change_password", $user_change_password, PDO::PARAM_BOOL);
-                $statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
+                $statement->bindValue(":user_status", $user_active);
                 $statement->bindValue(":user_creator", $this->current_user_name);
                 $statement->bindValue(":user_created", $this->getTimestamp());
 
@@ -158,7 +163,7 @@ class Users extends Base {
 
                 // Send User Credentials
 
-                (new Email())->emailNewUserCredentials($user, $user_random_password);
+                (new AuthService())->requestReset($user_email);
 
                 break;
 
@@ -226,9 +231,13 @@ class Users extends Base {
                 $user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
                 $user_name = filter_input(INPUT_POST, "user_name") ?: "";
                 $user_email = filter_input(INPUT_POST, "user_email") ?: "";
-                $user_client_name = filter_input(INPUT_POST, "user_client_name") ?: "";
+                $user_first_name = filter_input(INPUT_POST, "user_first_name") ?: "";
                 $user_role = filter_input(INPUT_POST, "user_role") ?: "";
-                $user_active = filter_input(INPUT_POST, "user_active", FILTER_VALIDATE_BOOLEAN) ?: false;
+                if (!in_array($user_role, ['User', 'Guest', 'Administrator'], true) || !filter_var($user_email, FILTER_VALIDATE_EMAIL) || strlen($user_email) > 100 || !preg_match('/\A[A-Za-z0-9_.-]{3,64}\z/', $user_name)) {
+                    http_response_code(422); exit('Enter a valid username, email address and role.');
+                }
+
+                $user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
 
                 $users = $this->getUser($user_serial);
 
@@ -238,12 +247,13 @@ class Users extends Base {
 
                 // Update the User
 
-                $SQL = "update users 
+                $SQL = "update users
                            set user_name            = :user_name,
                                user_email           = :user_email,
-                               user_client_name     = :user_client_name,
+                               user_first_name     = :user_first_name,
                                user_role            = :user_role,
-                               user_active          = :user_active,
+                               user_auth_version    = user_auth_version + 1,
+                               user_status          = :user_status,
                                user_changer         = :user_changer,
                                user_changed         = :user_changed
                          where user_serial          = :user_serial";
@@ -253,9 +263,9 @@ class Users extends Base {
                 $statement->bindValue(":user_serial", $user_serial);
                 $statement->bindValue(":user_name", $user_name);
                 $statement->bindValue(":user_email", $user_email);
-                $statement->bindValue(":user_client_name", $user_client_name);
+                $statement->bindValue(":user_first_name", $user_first_name);
                 $statement->bindValue(":user_role", $user_role);
-                $statement->bindValue(":user_active", $user_active, PDO::PARAM_BOOL);
+                $statement->bindValue(":user_status", $user_active);
                 $statement->bindValue(":user_changer", $this->current_user_name);
                 $statement->bindValue(":user_changed", $this->getTimestamp());
 
@@ -274,6 +284,7 @@ class Users extends Base {
     // ====================
 
     public function getUser($user_serial = 0) {
+        $user_serial = (int) $user_serial;
 
         $SQL = "select view_users.*
                   from view_users
@@ -287,12 +298,9 @@ class Users extends Base {
     // ====================
 
     public function getUserByUserName($user_name) {
-
-        $SQL = "select view_users.*
-                  from view_users
-                 where user_name = '{$user_name}'";
-
-        return $this->getTable("user", $SQL);
+        $stmt = $this->connect()->prepare('SELECT * FROM view_users WHERE user_name = :name');
+        $stmt->execute([':name' => $user_name]);
+        return $this->getTableXML('users', $stmt);
     }
 
     // =====================
@@ -337,14 +345,14 @@ class Users extends Base {
         $SQL = "select count(*) as count
                   from view_users
                  where user_name        regexp :user_name
-                    or user_client_name regexp :user_client_name
+                    or user_first_name regexp :user_first_name
                     or user_email       regexp :user_email
                     or user_role        regexp :user_role";
 
         $statement = $connection->prepare($SQL);
 
         $statement->bindValue(":user_name", $find_words);
-        $statement->bindValue(":user_client_name", $find_words);
+        $statement->bindValue(":user_first_name", $find_words);
         $statement->bindValue(":user_email", $find_words);
         $statement->bindValue(":user_role", $find_words);
 
@@ -379,7 +387,7 @@ class Users extends Base {
         $SQL = "select view_users.*
               from view_users
              where user_name        regexp :user_name
-                or user_client_name regexp :user_client_name
+                or user_first_name regexp :user_first_name
                 or user_email       regexp :user_email
                 or user_role        regexp :user_role
              limit {$this->pagination_size}
@@ -388,7 +396,7 @@ class Users extends Base {
         $statement = $connection->prepare($SQL);
 
         $statement->bindValue(":user_name", $find_words);
-        $statement->bindValue(":user_client_name", $find_words);
+        $statement->bindValue(":user_first_name", $find_words);
         $statement->bindValue(":user_email", $find_words);
         $statement->bindValue(":user_role", $find_words);
 
@@ -485,111 +493,33 @@ class Users extends Base {
     // ===============
 
     public function changePassword() {
-
-        $step = filter_input(INPUT_POST, "step") ?: "prompt";
-
-        switch ($step) {
-
-            case "prompt":
-
-                $popovers = (new Popovers())->getPopovers();
-                $XML = $this->mergeXML($popovers, "");
-
-                $content = $this->applyXSL($XML, $this->getXSL("changePassword"));
-                $this->displayContent($content);
-
-                break;
-
-            case "change":
-
-                $user_serial = $_SESSION[self::USER_SERIAL];
-                $user_new_password = filter_input(INPUT_POST, "user_new_password") ?: "";
-                $user_verify_password = filter_input(INPUT_POST, "user_verify_password") ?: "";
-
-                if (!$user_new_password == $user_verify_password) {
-                    $this->initializeSession();
+        $params = ['PASSWORD_ERROR' => ''];
+        if (($_POST['step'] ?? '') === 'change') {
+            $this->requireCsrf();
+            try {
+                $auth = new AuthService();
+                if (!$auth->allowAttempt('change-password', (string) $_SESSION[self::USER_SERIAL], 10, 900)) {
+                    throw new InvalidArgumentException('Too many attempts. Please try again later.');
                 }
-
-                $user_new_hashed_password = password_hash($user_new_password, PASSWORD_DEFAULT);
-
-                // Update User Password
-
-                $connection = $this->connect();
-
-                $SQL = "update users 
-                           set user_password        = :user_password,
-                               user_change_password = :user_change_password,
-                               user_changer         = :user_changer,
-                               user_changed         = :user_changed
-                         where user_serial          = :user_serial";
-
-                $statement = $connection->prepare($SQL);
-
-                $statement->bindValue(":user_serial", $user_serial);
-                $statement->bindValue(":user_password", $user_new_hashed_password);
-                $statement->bindValue(":user_change_password", 0);
-                $statement->bindValue(":user_changer", $this->current_user_name);
-                $statement->bindValue(":user_changed", $this->getTimestamp());
-
-                if (!$statement->execute()) {
-                    $this->debug($statement->errorInfo()[2]);
+                if (!$auth->changePassword((int) $_SESSION[self::USER_SERIAL], $_POST['user_current_password'] ?? '', $_POST['user_new_password'] ?? '', $_POST['user_verify_password'] ?? '')) {
+                    throw new InvalidArgumentException('Your current password is incorrect or your session has expired.');
                 }
-
-                (new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} changed their password.");
-
-                break;
-
-            default:
-
-                $this->displayHome();
+                $this->logoutSession();
+                header('Location: main.php', true, 303);
+                exit;
+            } catch (InvalidArgumentException $e) { $params['PASSWORD_ERROR'] = $e->getMessage(); }
         }
+        $this->displayContent($this->applyXSL('', $this->getXSL('changePassword'), $params));
     }
 
-    // ==============
-    // Reset Password
-    // ==============
-
     public function resetPassword() {
-
-        $reset = false;
-
-        $user_serial = filter_input(INPUT_POST, "user_serial") ?: "";
-
-        $user = $this->getUser($user_serial);
-
-        $user_name = $user->record->user_name;
-
-        $user_random_password = $this->generateRandomPassword();
-        $user_temp_password = password_hash($user_random_password, PASSWORD_DEFAULT);
-
-        // Update User Password
-
-        $connection = $this->connect();
-
-        $SQL = "update users 
-                         set user_password        = :user_password,
-                             user_change_password = :user_change_password
-                       where user_serial          = :user_serial";
-
-        $statement = $connection->prepare($SQL);
-
-        $statement->bindValue(":user_serial", $user_serial);
-        $statement->bindValue(":user_password", $user_temp_password);
-        $statement->bindValue(":user_change_password", 1);
-
-        if (!$statement->execute()) {
-            $this->debug($statement->errorInfo()[2]);
-        } else {
-            $reset = true;
-        }
-
-        // Send User Credentials
-
-        (new Email())->emailUserPasswordResetLink($user, $user_random_password);
-
-        (new Journal())->journalEntry("{$_SESSION[self::USER_NAME]} reset {$user_name}'s password.");
-
-        echo json_encode($reset);
+        $this->requireCsrf();
+        $stmt = $this->connect()->prepare('SELECT user_email FROM users WHERE user_serial = :id');
+        $stmt->execute([':id' => (int) ($_POST['user_serial'] ?? 0)]);
+        $email = $stmt->fetchColumn();
+        if ($email) (new AuthService())->requestReset($email);
+        header('Content-Type: application/json');
+        echo json_encode(true);
     }
 
     // ===============================================
@@ -599,7 +529,7 @@ class Users extends Base {
     public function updateLogin() {
 
         $SQL = "update users
-                   set user_login  = :user_login
+                   set user_last_login = :user_login
                  where user_serial = :user_serial";
 
         $statement = $this->connect()->prepare($SQL);
@@ -617,10 +547,11 @@ class Users extends Base {
     public function updateActiveStatus() {
 
         $user_serial = filter_input(INPUT_POST, "user_serial", FILTER_SANITIZE_NUMBER_INT) ?: 0;
-        $user_active = filter_input(INPUT_POST, "user_active", FILTER_SANITIZE_NUMBER_INT) ?: 0;
+        $user_active = filter_input(INPUT_POST, 'user_active', FILTER_VALIDATE_BOOLEAN) ? 'active' : 'inactive';
 
         $SQL = "update users
-                   set user_active       = :user_active,
+                   set user_status       = :user_status,
+                       user_auth_version = user_auth_version + 1,
                        user_changed      = :user_changed,
                        user_changer      = :user_changer
                  where user_serial       = :user_serial";
@@ -628,7 +559,7 @@ class Users extends Base {
         $statement = $this->connect()->prepare($SQL);
 
         $statement->bindValue(":user_serial", $user_serial);
-        $statement->bindValue(":user_active", $user_active);
+        $statement->bindValue(":user_status", $user_active);
         $statement->bindValue(":user_changed", $this->getTimestamp());
         $statement->bindValue(":user_changer", $this->current_user_name);
 
diff --git a/composer.json b/composer.json
index 70ebc1a..80f3586 100644
--- a/composer.json
+++ b/composer.json
@@ -1,7 +1,21 @@
 {
     "require": {
+        "php": "^8.3",
+        "php-64bit": "^8.3",
+        "ext-dom": "*",
+        "ext-filter": "*",
+        "ext-mbstring": "*",
+        "ext-openssl": "*",
+        "ext-pdo": "*",
+        "ext-pdo_mysql": "*",
+        "ext-session": "*",
+        "ext-simplexml": "*",
+        "ext-xsl": "*",
         "tecnickcom/tcpdf": "^6.10",
         "phpmailer/phpmailer": "^7.0",
         "phpoffice/phpspreadsheet": "^5.2"
+    },
+    "scripts": {
+        "test:auth": "php tests/auth_integration.php"
     }
 }
diff --git a/composer.lock b/composer.lock
index 4ccbf57..5d53152 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,32 +4,33 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "6621356a0da1644f04f6f40bd0e0b948",
+    "content-hash": "0389de306dca31ea5873c1d9ed6a4c5d",
     "packages": [
         {
             "name": "composer/pcre",
-            "version": "3.3.2",
+            "version": "3.4.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/pcre.git",
-                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
-                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.4 || ^8.0"
             },
             "conflict": {
-                "phpstan/phpstan": "<1.11.10"
+                "phpstan/phpstan": "<2.2.2"
             },
             "require-dev": {
-                "phpstan/phpstan": "^1.12 || ^2",
-                "phpstan/phpstan-strict-rules": "^1 || ^2",
-                "phpunit/phpunit": "^8 || ^9"
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^9"
             },
             "type": "library",
             "extra": {
@@ -67,7 +68,7 @@
             ],
             "support": {
                 "issues": "https://github.com/composer/pcre/issues",
-                "source": "https://github.com/composer/pcre/tree/3.3.2"
+                "source": "https://github.com/composer/pcre/tree/3.4.0"
             },
             "funding": [
                 {
@@ -77,26 +78,22 @@
                 {
                     "url": "https://github.com/composer",
                     "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
-                    "type": "tidelift"
                 }
             ],
-            "time": "2024-11-12T16:29:46+00:00"
+            "time": "2026-06-07T11:47:49+00:00"
         },
         {
             "name": "maennchen/zipstream-php",
-            "version": "3.2.0",
+            "version": "3.2.2",
             "source": {
                 "type": "git",
                 "url": "https://github.com/maennchen/ZipStream-PHP.git",
-                "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
-                "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
+                "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
                 "shasum": ""
             },
             "require": {
@@ -107,7 +104,7 @@
             "require-dev": {
                 "brianium/paratest": "^7.7",
                 "ext-zip": "*",
-                "friendsofphp/php-cs-fixer": "^3.16",
+                "friendsofphp/php-cs-fixer": "^3.86",
                 "guzzlehttp/guzzle": "^7.5",
                 "mikey179/vfsstream": "^1.6",
                 "php-coveralls/php-coveralls": "^2.5",
@@ -153,7 +150,7 @@
             ],
             "support": {
                 "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
-                "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
+                "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
             },
             "funding": [
                 {
@@ -161,7 +158,7 @@
                     "type": "github"
                 }
             ],
-            "time": "2025-07-17T11:15:13+00:00"
+            "time": "2026-04-11T18:38:28+00:00"
         },
         {
             "name": "markbaker/complex",
@@ -354,16 +351,16 @@
         },
         {
             "name": "phpoffice/phpspreadsheet",
-            "version": "5.2.0",
+            "version": "5.10.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
-                "reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb"
+                "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
-                "reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
+                "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
+                "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
                 "shasum": ""
             },
             "require": {
@@ -371,6 +368,7 @@
                 "ext-ctype": "*",
                 "ext-dom": "*",
                 "ext-fileinfo": "*",
+                "ext-filter": "*",
                 "ext-gd": "*",
                 "ext-iconv": "*",
                 "ext-libxml": "*",
@@ -384,14 +382,14 @@
                 "maennchen/zipstream-php": "^2.1 || ^3.0",
                 "markbaker/complex": "^3.0",
                 "markbaker/matrix": "^3.0",
-                "php": "^8.1",
-                "psr/http-client": "^1.0",
-                "psr/http-factory": "^1.0",
+                "php": "^8.2",
                 "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
             },
             "require-dev": {
                 "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
                 "dompdf/dompdf": "^2.0 || ^3.0",
+                "ext-intl": "*",
+                "ext-openssl": "*",
                 "friendsofphp/php-cs-fixer": "^3.2",
                 "mitoteam/jpgraph": "^10.5",
                 "mpdf/mpdf": "^8.1.1",
@@ -399,13 +397,14 @@
                 "phpstan/phpstan": "^1.1 || ^2.0",
                 "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
                 "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
-                "phpunit/phpunit": "^10.5",
+                "phpunit/phpunit": "^10.5 || ^11.0",
                 "squizlabs/php_codesniffer": "^3.7",
-                "tecnickcom/tcpdf": "^6.5"
+                "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
             },
             "suggest": {
                 "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
-                "ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
+                "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
+                "ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
                 "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
                 "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
                 "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
@@ -438,6 +437,9 @@
                 },
                 {
                     "name": "Adrien Crivelli"
+                },
+                {
+                    "name": "Owen Leibman"
                 }
             ],
             "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -454,169 +456,9 @@
             ],
             "support": {
                 "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
-                "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.2.0"
+                "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
             },
-            "time": "2025-10-26T15:54:22+00:00"
-        },
-        {
-            "name": "psr/http-client",
-            "version": "1.0.3",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-client.git",
-                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
-                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0 || ^8.0",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Client\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP clients",
-            "homepage": "https://github.com/php-fig/http-client",
-            "keywords": [
-                "http",
-                "http-client",
-                "psr",
-                "psr-18"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-client"
-            },
-            "time": "2023-09-23T14:17:50+00:00"
-        },
-        {
-            "name": "psr/http-factory",
-            "version": "1.1.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-factory.git",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
-            "keywords": [
-                "factory",
-                "http",
-                "message",
-                "psr",
-                "psr-17",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-factory"
-            },
-            "time": "2024-04-15T12:06:14+00:00"
-        },
-        {
-            "name": "psr/http-message",
-            "version": "2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-message.git",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0"
-            },
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP messages",
-            "homepage": "https://github.com/php-fig/http-message",
-            "keywords": [
-                "http",
-                "http-message",
-                "psr",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-message/tree/2.0"
-            },
-            "time": "2023-04-04T09:54:51+00:00"
+            "time": "2026-09-17T03:54:50+00:00"
         },
         {
             "name": "psr/simple-cache",
@@ -747,7 +589,19 @@
     "stability-flags": {},
     "prefer-stable": false,
     "prefer-lowest": false,
-    "platform": {},
+    "platform": {
+        "php": "^8.3",
+        "php-64bit": "^8.3",
+        "ext-dom": "*",
+        "ext-filter": "*",
+        "ext-mbstring": "*",
+        "ext-openssl": "*",
+        "ext-pdo": "*",
+        "ext-pdo_mysql": "*",
+        "ext-session": "*",
+        "ext-simplexml": "*",
+        "ext-xsl": "*"
+    },
     "platform-dev": {},
-    "plugin-api-version": "2.6.0"
+    "plugin-api-version": "2.9.0"
 }
diff --git a/css/signin.css b/css/login.css
similarity index 100%
rename from css/signin.css
rename to css/login.css
diff --git a/js/addUser.js b/js/addUser.js
index a8b6dc4..6b96d16 100644
--- a/js/addUser.js
+++ b/js/addUser.js
@@ -83,7 +83,7 @@ $(document).ready(function () {
     $("#addUser").submit(function (e) {
 
         var user_name = $("#user_name"),
-                user_client_name = $("#user_client_name"),
+                user_first_name = $("#user_first_name"),
                 user_email = $("#user_email"),
                 user_role = $("#user_role");
 
@@ -108,8 +108,8 @@ $(document).ready(function () {
             return false;
         }
 
-        if (isBlank(user_client_name)) {
-            createInputError(user_client_name, "Client Name is required");
+        if (isBlank(user_first_name)) {
+            createInputError(user_first_name, "Client Name is required");
             return false;
         }
 
diff --git a/js/base.js b/js/base.js
index 5410f3f..1285659 100644
--- a/js/base.js
+++ b/js/base.js
@@ -1,3 +1,17 @@
+// Attach the session CSRF token to existing forms and same-origin AJAX requests.
+$.ajaxPrefilter(function (options, original, xhr) {
+    if (!options.crossDomain && !/^(GET|HEAD|OPTIONS)$/i.test(options.type)) {
+        xhr.setRequestHeader('X-CSRF-Token', $('meta[name="csrf-token"]').attr('content') || '');
+    }
+});
+$(function () {
+    $('form[method="post" i]').each(function () {
+        if (!$(this).find('[name="csrf_token"]').length) {
+            $('', {type: 'hidden', name: 'csrf_token', value: $('meta[name="csrf-token"]').attr('content') || ''}).appendTo(this);
+        }
+    });
+});
+
 
 // ==============
 // Base Functions
diff --git a/js/editUser.js b/js/editUser.js
index 0b1b021..8151731 100644
--- a/js/editUser.js
+++ b/js/editUser.js
@@ -97,7 +97,7 @@ $(document).ready(function () {
         var user_name = $("#user_name"),
                 user_email = $("#user_email"),
                 user_role = $("#user_role"),
-                user_client_name = $("#user_client_name");
+                user_first_name = $("#user_first_name");
 
         clearInputErrors();
 
@@ -114,8 +114,8 @@ $(document).ready(function () {
             return false;
         }
 
-        if (isBlank(user_client_name)) {
-            createInputError(user_client_name, "Client Name is required");
+        if (isBlank(user_first_name)) {
+            createInputError(user_first_name, "Client Name is required");
             return false;
         }
 
diff --git a/js/login.js b/js/login.js
index e009847..8f9b3fb 100644
--- a/js/login.js
+++ b/js/login.js
@@ -11,7 +11,7 @@ $(document).ready(function () {
     // Use our Bootstrap validation messages.
     $login.add($signUp).add($resetPassword).attr("novalidate", "novalidate");
 
-    $(".btnSignUp").on("click", function (e) {
+    $("#login .btnSignUp").on("click", function (e) {
         e.preventDefault();
         window.location.href = "./?Xaction=signup";
     });
diff --git a/main.php b/main.php
index cd08087..c86bffc 100644
--- a/main.php
+++ b/main.php
@@ -1,8 +1,29 @@
 getSettings()->Environment === 'Production' && !$https) {
+    http_response_code(400);
+    exit('HTTPS is required. Open the secure application URL.');
+}
+ini_set('session.use_strict_mode', '1');
+ini_set('session.use_only_cookies', '1');
+session_name('HUNTINGCLUBROSTER');
+session_set_cookie_params([
+    'lifetime' => 0, 'path' => '/',
+    'secure' => $https,
+    'httponly' => true, 'samesite' => 'Lax',
+]);
 session_start();
-
-(new Base())->do();
-?>
\ No newline at end of file
+$application->do();
diff --git a/migrations/001_auth.sql b/migrations/001_auth.sql
new file mode 100644
index 0000000..5311f29
--- /dev/null
+++ b/migrations/001_auth.sql
@@ -0,0 +1,18 @@
+-- Run once on the supplied users schema. Take a database backup first.
+ALTER TABLE users ADD COLUMN user_auth_version INT UNSIGNED NOT NULL DEFAULT 0;
+ALTER TABLE users ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS password_resets (
+    token_hash CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
+    user_serial BIGINT UNSIGNED NOT NULL,
+    expires_at DATETIME NOT NULL,
+    INDEX (user_serial),
+    INDEX (expires_at)
+) ENGINE=InnoDB;
+
+CREATE TABLE IF NOT EXISTS auth_attempts (
+    attempt_key CHAR(64) CHARACTER SET ascii COLLATE ascii_bin NOT NULL PRIMARY KEY,
+    attempts INT UNSIGNED NOT NULL DEFAULT 0,
+    window_started DATETIME NOT NULL,
+    INDEX (window_started)
+) ENGINE=InnoDB;
diff --git a/reports/Print_All_PermitsByBuilder.php b/reports/Print_All_PermitsByBuilder.php
index a24e3de..111bddd 100644
--- a/reports/Print_All_PermitsByBuilder.php
+++ b/reports/Print_All_PermitsByBuilder.php
@@ -1,6 +1,6 @@
 
-
-    
-        
-        Create System Tables
-
-        
-    
-    
-
-         72) throw new RuntimeException('Set HCR_INITIAL_ADMIN_PASSWORD before rebuilding.');
+
         // --------------------------
         // Load Application Settings
         // --------------------------
@@ -247,7 +217,9 @@
 
         function seedInitialAdminUser(PDO $connection) {
 
-            $password = password_hash("349Jamot@", PASSWORD_DEFAULT);
+            $initialPassword = getenv('HCR_INITIAL_ADMIN_PASSWORD');
+            if (!$initialPassword || strlen($initialPassword) < 12 || strlen($initialPassword) > 72) throw new RuntimeException('Set HCR_INITIAL_ADMIN_PASSWORD to a unique 12-72 byte password.');
+            $password = password_hash($initialPassword, PASSWORD_DEFAULT);
 
             $SQL = "INSERT INTO users (
                         user_name,
diff --git a/tables/_createTable.php b/tables/_createTable.php
index b904a49..86b7242 100644
--- a/tables/_createTable.php
+++ b/tables/_createTable.php
@@ -1,14 +1,6 @@
-
-
 
diff --git a/tests/auth_http.py b/tests/auth_http.py
new file mode 100644
index 0000000..3b75943
--- /dev/null
+++ b/tests/auth_http.py
@@ -0,0 +1,127 @@
+"""End-to-end tests against a disposable local database and PHP development server.
+
+Set HCR_TEST_DSN, HCR_TEST_USER, HCR_TEST_PASSWORD, HCR_DB_HOST,
+HCR_DB_PORT, HCR_DB_USER, HCR_DB_PASSWORD and PHP_TEST_COMMAND (JSON argv).
+"""
+import http.cookiejar
+import json
+import os
+from pathlib import Path
+import re
+import socket
+import subprocess
+import tempfile
+import time
+import urllib.error
+import urllib.parse
+import urllib.request
+
+ROOT = Path(__file__).resolve().parents[1]
+PHP = json.loads(os.environ.get('PHP_TEST_COMMAND', '["php"]'))
+env = os.environ.copy()
+env['HCR_APPLICATION_URL'] = ''  # Never send real email from the test suite.
+env['HCR_ENVIRONMENT'] = 'Development'
+checks = []
+
+def fixture(action, database=None):
+    args = PHP + ['tests/http_fixture.php', action] + ([database] if database else [])
+    result = subprocess.run(args, cwd=ROOT, env=env, check=True, text=True, capture_output=True)
+    return json.loads(result.stdout.splitlines()[-1]) if result.stdout.strip() else {}
+
+def check(condition, message):
+    if not condition:
+        raise AssertionError(message)
+    checks.append(message)
+
+jar = http.cookiejar.CookieJar()
+client = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(jar))
+
+def request(path='/', data=None, headers=None):
+    encoded = urllib.parse.urlencode(data).encode() if data is not None else None
+    req = urllib.request.Request(base + path, data=encoded, headers=headers or {})
+    try:
+        with client.open(req, timeout=20) as response:
+            return response.status, response.read().decode(), response.headers
+    except urllib.error.HTTPError as error:
+        return error.code, error.read().decode(), error.headers
+
+def csrf(html):
+    match = re.search(r'name="csrf_token"[^>]*value="([a-f0-9]+)"', html)
+    if not match:
+        match = re.search(r'name="csrf-token"[^>]*content="([a-f0-9]+)"', html)
+    if not match:
+        raise AssertionError('Missing CSRF token')
+    return match.group(1)
+
+database = fixture('create')['database']
+env['HCR_DB_NAME'] = database
+with socket.socket() as available:
+    available.bind(('127.0.0.1', 0))
+    port = available.getsockname()[1]
+base = f'http://127.0.0.1:{port}'
+server = None
+try:
+    result = subprocess.run(PHP + ['bin/migrate-auth.php'], cwd=ROOT, env=env, check=True, capture_output=True, text=True)
+    check('schema is ready' in result.stdout, 'Database migration can be rerun safely')
+    with tempfile.TemporaryFile(mode='w+b') as log:
+        server = subprocess.Popen(PHP + ['-S', f'127.0.0.1:{port}', 'bin/dev-router.php'], cwd=ROOT, env=env, stdout=log, stderr=log)
+        for _ in range(50):
+            try:
+                status, html, headers = request()
+                break
+            except urllib.error.URLError:
+                time.sleep(0.1)
+        else:
+            raise AssertionError('PHP development server did not start')
+        check(status == 200 and 'name="user_email"' in html, 'Login page renders over HTTP')
+        check('no-store' in headers.get('Cache-Control', ''), 'Authentication pages are not cached')
+        check('HttpOnly' in headers.get('Set-Cookie', '') and 'SameSite=Lax' in headers.get('Set-Cookie', ''), 'Session cookies have HttpOnly and SameSite protections')
+        check(request(data={'action': 'SignIn.login', 'user_email': 'http@example.test', 'user_password': 'wrong'})[0] == 403, 'Login rejects missing CSRF token')
+        check(request(data={'action[]': 'bad'})[0] == 400, 'Array-shaped request fields are rejected')
+        status, html, _ = request('/?Xaction=signup')
+        check(status == 200 and 'name="user_first_name"' in html, 'Signup is reachable before login')
+        fields = {'action': 'SignUp.createAccount', 'csrf_token': csrf(html), 'user_first_name': 'HTTP', 'user_last_name': 'Tester', 'user_email': 'http@example.test', 'user_name': 'httpuser', 'user_password': 'HTTP long password!', 'user_password_confirm': 'HTTP long password!', 'user_role': 'Administrator'}
+        status, html, _ = request(data=fields)
+        check(status == 200 and 'Your account has been created' in html, 'Signup form creates an account and redirects')
+        status, html, _ = request()
+        status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'wrong'})
+        check(status == 200 and 'Invalid email address or password' in html, 'Invalid login renders the existing login template')
+        status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP long password!'})
+        check(status == 200 and 'name="csrf-token"' in html, 'Email/password form signs in and reaches the authenticated shell')
+        token = csrf(html)
+        check(request(data={'action': 'Users.editUser', 'step': 'update', 'csrf_token': token, 'user_serial': '1', 'user_role': 'Administrator'})[0] == 403, 'Regular users cannot edit account privileges')
+        check(request(data={'action': 'print', 'report': 'Email', 'csrf_token': token})[0] == 403, 'Regular users cannot invoke administrative report actions')
+        check(request(data={'action': 'Base.executeSQL', 'csrf_token': token})[0] == 404, 'Arbitrary helper methods cannot be dispatched')
+        check(request(data={'action': 'signout'})[0] == 403, 'Logout rejects missing CSRF token')
+        status, html, _ = request(data={'action': 'signout', 'csrf_token': token})
+        check(status == 200 and 'id="login"' in html, 'Logout ends the authenticated session')
+        status, html, _ = request('/?Xaction=resetpassword')
+        check('Send reset link' in html, 'Password reset request page is reachable before login')
+        status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(html), 'user_email': 'unknown@example.test'})
+        check(status == 200 and 'If an active account matches' in html, 'Unknown account reset returns a neutral response')
+        status, form, _ = request('/?Xaction=resetpassword')
+        status, html, _ = request(data={'action': 'resetpassword', 'csrf_token': csrf(form), 'user_email': 'http@example.test'})
+        check('If an active account matches' in html, 'Reset delivery/configuration failure returns the same neutral response')
+        reset_token = fixture('token', database)['token']
+        status, html, headers = request('/?Xaction=resetpassword&token=' + reset_token)
+        check(status == 200 and 'Set new password' in html and headers.get('Referrer-Policy') == 'no-referrer', 'Reset link renders a protected new-password form')
+        status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
+        check(status == 200 and 'Your password has been changed' in html, 'Reset form changes the password and redirects')
+        status, html, _ = request('/?Xaction=resetpassword&token=' + reset_token)
+        status, html, _ = request(data={'action': 'resetpassword', 'step': 'reset', 'token': reset_token, 'csrf_token': csrf(html), 'user_password': 'HTTP changed password!', 'user_password_confirm': 'HTTP changed password!'})
+        check('invalid or expired' in html, 'Used reset link fails through the real HTTP handler')
+        status, html, _ = request()
+        status, html, _ = request(data={'action': 'SignIn.login', 'csrf_token': csrf(html), 'user_email': 'http@example.test', 'user_password': 'HTTP changed password!'})
+        check(status == 200 and 'name="csrf-token"' in html, 'Reset password works through the real login form')
+        fixture('revoke', database)
+        check(request(data={'action': 'toggleTheme', 'csrf_token': csrf(html)}, headers={'X-Requested-With': 'XMLHttpRequest'})[0] == 401, 'Revoked AJAX sessions return 401 instead of a login document')
+        for path in ['/xml/settings.local.xml', '/tables/_createSystemTable.php', '/bin/migrate-auth.php', '/.git/config']:
+            check(request(path)[0] == 404, 'Development router blocks ' + path)
+        for message in checks:
+            print('PASS:', message)
+        print(len(checks), 'HTTP checks passed.')
+finally:
+    if server:
+        server.terminate()
+        server.wait(timeout=10)
+    fixture('drop', database)
diff --git a/tests/auth_integration.php b/tests/auth_integration.php
new file mode 100644
index 0000000..ec904e6
--- /dev/null
+++ b/tests/auth_integration.php
@@ -0,0 +1,134 @@
+ PDO::ERRMODE_EXCEPTION]);
+$database = 'hcr_auth_test_' . bin2hex(random_bytes(6));
+$server->exec("CREATE DATABASE `{$database}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+$server->exec("USE `{$database}`");
+$server->setAttribute(PDO::ATTR_DEFAULT_FETCH_MODE, PDO::FETCH_ASSOC);
+$server->setAttribute(PDO::ATTR_EMULATE_PREPARES, false);
+session_start();
+
+class TestAuth extends AuthService {
+    public array $mailbox = [];
+    public bool $mailWorks = true;
+    public function __construct(private PDO $db) { parent::__construct(); }
+    public function connect(): PDO { return $this->db; }
+    public function getSettings() { return new SimpleXMLElement('https://roster.example.test/club'); }
+    protected function deliverResetEmail(array $user, string $url): bool {
+        $this->mailbox[] = ['email' => $user['user_email'], 'url' => $url];
+        return $this->mailWorks;
+    }
+}
+class CaptureMailer extends Email {
+    public array $message = [];
+    public function sendEmail($to = '', $subject = '', $message = '', $attachments = '', $attachment_names = 'unknown') {
+        $this->message = ['to' => $to, 'subject' => $subject, 'body' => $message];
+        return true;
+    }
+}
+$checks = [];
+function check(bool $condition, string $message): void {
+    global $checks;
+    if (!$condition) throw new RuntimeException('FAILED: ' . $message);
+    $checks[] = $message;
+}
+function rejects(callable $operation, string $message): void {
+    try { $operation(); } catch (InvalidArgumentException $e) { check(true, $message); return; }
+    check(false, $message);
+}
+function tokenFrom(array $email): string {
+    parse_str(parse_url($email['url'], PHP_URL_QUERY), $query);
+    return $query['token'];
+}
+try {
+    require __DIR__ . '/../tables/users.php';
+    $server->exec($SQL);
+    // Exercise the upgrade path from the supplied pre-change schema too.
+    $server->exec('ALTER TABLE users DROP COLUMN user_auth_version');
+    foreach (explode(';', file_get_contents(__DIR__ . '/../migrations/001_auth.sql')) as $sql) {
+        if (trim($sql)) $server->exec($sql);
+    }
+    require __DIR__ . '/../views/view_users.php';
+    $server->exec($SQL);
+    $auth = new TestAuth($server);
+    $auth->initializeSession();
+    $input = ['user_first_name' => 'Jane', 'user_last_name' => 'Hunter', 'user_email' => 'jane@example.test', 'user_name' => 'jane', 'user_password' => 'A long password 123!', 'user_password_confirm' => 'A long password 123!', 'user_role' => 'Administrator'];
+    $auth->register($input);
+    $user = $auth->findUser('jane@example.test');
+    $id = (int) $user['user_serial'];
+    check($user['user_role'] === 'User', 'Signup cannot assign administrator privileges');
+    check(password_verify($input['user_password'], $user['user_password_hash']), 'Signup stores a verifiable password hash');
+    check($user['user_password_hash'] !== $input['user_password'], 'Signup does not store plaintext passwords');
+    rejects(fn() => $auth->register($input), 'Duplicate account details are rejected');
+    rejects(fn() => $auth->register(array_replace($input, ['user_email' => 'invalid'])), 'Invalid email is rejected on the server');
+    rejects(fn() => $auth->register(array_replace($input, ['user_password_confirm' => 'different'])), 'Mismatched passwords are rejected on the server');
+    rejects(fn() => $auth->register(array_replace($input, ['user_password' => 'short', 'user_password_confirm' => 'short'])), 'Short passwords are rejected');
+    rejects(fn() => $auth->register(array_replace($input, ['user_password' => str_repeat('x', 73), 'user_password_confirm' => str_repeat('x', 73)])), 'Passwords above bcrypt byte limit are rejected');
+    check(!$auth->login("' OR 1=1 -- ", 'wrong'), 'SQL injection input cannot log in');
+    check(!$auth->login('jane@example.test', 'wrong'), 'Wrong password cannot log in');
+    $oldSession = session_id();
+    check($auth->login('jane@example.test', $input['user_password']), 'Email login succeeds');
+    check(session_id() !== $oldSession, 'Login rotates session ID');
+    check($auth->currentUser() !== null, 'Authenticated session is valid');
+    check($server->query('SELECT user_last_login FROM users')->fetchColumn() !== null, 'Login timestamp uses the correct column');
+    check($auth->login('jane', $input['user_password']), 'Username login remains supported');
+    $before = $auth->findUser('jane')['user_password_hash'];
+    $auth->requestReset('jane@example.test');
+    check(count($auth->mailbox) === 1, 'Reset request generates an email');
+    check($auth->findUser('jane')['user_password_hash'] === $before, 'Reset request leaves password unchanged');
+    $token = tokenFrom($auth->mailbox[0]);
+    check(strlen($token) === 64, 'Reset token contains 256 bits of randomness');
+    check($server->query('SELECT token_hash FROM password_resets')->fetchColumn() === hash('sha256', $token), 'Database stores only a hash of the reset token');
+    $auth->requestReset('missing@example.test');
+    check(count($auth->mailbox) === 1, 'Unknown accounts do not produce reset email');
+    check(!$auth->resetPassword(str_repeat('0', 64), 'Another long password!', 'Another long password!'), 'Unknown reset token is rejected');
+    $server->exec('UPDATE password_resets SET expires_at = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 1 SECOND)');
+    check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Expired reset token is rejected');
+    $auth->requestReset('jane@example.test');
+    $token = tokenFrom($auth->mailbox[1]);
+    check($auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Valid reset token changes password');
+    check(!$auth->resetPassword($token, 'Another long password!', 'Another long password!'), 'Reset token cannot be reused');
+    check($auth->currentUser() === null, 'Password reset revokes an existing session');
+    check(!$auth->login('jane', $input['user_password']), 'Old password fails after reset');
+    check($auth->login('jane', 'Another long password!'), 'New password works after reset');
+    rejects(fn() => $auth->changePassword($id, 'Another long password!', 'New password 123!', 'mismatch'), 'Password change rejects mismatched confirmation');
+    check(!$auth->changePassword($id, 'wrong', 'New password 123!', 'New password 123!'), 'Password change requires the current password');
+    check($auth->changePassword($id, 'Another long password!', 'New password 123!', 'New password 123!'), 'Authenticated password change succeeds');
+    check($auth->currentUser() === null, 'Password change revokes previous sessions');
+    check($auth->login('jane', 'New password 123!'), 'Changed password works');
+    $_SESSION['last_activity'] = time() - 1801;
+    check($auth->currentUser() === null, 'Idle sessions expire on the server');
+    $server->exec("UPDATE users SET user_status = 'inactive'");
+    check(!$auth->login('jane', 'New password 123!'), 'Inactive users cannot log in');
+    $server->exec("UPDATE users SET user_status = 'active'");
+    $auth->mailWorks = false;
+    $beforeFailedEmail = $auth->findUser('jane')['user_password_hash'];
+    try { $auth->requestReset('jane@example.test'); check(false, 'Mail failure must be detected'); }
+    catch (RuntimeException $e) { check($e->getMessage() === 'Password reset email delivery failed.', 'Email failure is detected'); }
+    check((int) $server->query('SELECT COUNT(*) FROM password_resets')->fetchColumn() === 0, 'Failed email delivery removes its token');
+    check($auth->findUser('jane')['user_password_hash'] === $beforeFailedEmail, 'Email failure preserves the existing password');
+    $mailer = new CaptureMailer();
+    $mailer->sendPasswordReset(['user_name' => 'jane', 'user_first_name' => 'Jane & club', 'user_email' => 'jane@example.test'], 'https://app.myhuntingclubroster.com/main.php?Xaction=resetpassword&token=example');
+    check($mailer->message['to'] === 'jane@example.test', 'Reset email uses the stored account email');
+    check(str_contains($mailer->message['body'], 'https://app.myhuntingclubroster.com/main.php'), 'Reset email contains the configured application link');
+    check(str_contains($mailer->message['body'], '<b>Jane & club</b>'), 'Reset email escapes account names');
+    check(!str_contains($mailer->message['body'], 'New password 123!'), 'Reset email never includes an account password');
+    for ($i = 0; $i < 3; $i++) $allowed = $auth->allowAttempt('test', 'address', 2, 60);
+    check(!$allowed, 'Rate limits reject excessive attempts');
+    $server->exec('UPDATE auth_attempts SET window_started = DATE_SUB(UTC_TIMESTAMP(), INTERVAL 2 HOUR)');
+    check($auth->allowAttempt('test', 'address', 2, 60), 'Rate limits recover after their window');
+    $freshAuth = new TestAuth($server);
+    for ($i = 0; $i < 11; $i++) $allowed = $freshAuth->login('jane', 'wrong');
+    check(!$freshAuth->login('jane', 'New password 123!'), 'Login rate limits survive new service instances');
+    foreach (['login', 'createAccount', 'resetPassword', 'changePassword', 'content'] as $template) {
+        $html = $auth->applyXSL('', $auth->getXSL($template));
+        check(is_string($html) && strlen($html) > 100, $template . ' stylesheet renders');
+    }
+    foreach ($checks as $message) echo "PASS: {$message}\n";
+    echo count($checks) . " checks passed.\n";
+} finally {
+    $server->exec("DROP DATABASE `{$database}`");
+}
diff --git a/tests/http_fixture.php b/tests/http_fixture.php
new file mode 100644
index 0000000..8095cb9
--- /dev/null
+++ b/tests/http_fixture.php
@@ -0,0 +1,33 @@
+ PDO::ERRMODE_EXCEPTION]);
+$action = $argv[1] ?? '';
+$name = $argv[2] ?? ('hcr_http_test_' . bin2hex(random_bytes(6)));
+if (!preg_match('/\Ahcr_http_test_[a-f0-9]{12}\z/', $name)) throw new RuntimeException('Only generated test databases may be used.');
+if ($action === 'drop') { $db->exec("DROP DATABASE `{$name}`"); exit; }
+if ($action === 'create') {
+    $db->exec("CREATE DATABASE `{$name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+    $db->exec("USE `{$name}`");
+    require __DIR__ . '/../tables/users.php'; $db->exec($SQL);
+    // The CLI migration must also support being run against a fresh users table.
+    putenv('HCR_DB_NAME=' . $name);
+    require __DIR__ . '/../bin/migrate-auth.php';
+    // Idempotent migration is verified separately by the HTTP test runner.
+    echo json_encode(['database' => $name]);
+    exit;
+}
+$db->exec("USE `{$name}`");
+if ($action === 'revoke') {
+    $db->exec("UPDATE users SET user_auth_version = user_auth_version + 1 WHERE user_name = 'httpuser'");
+    exit;
+}
+if ($action === 'token') {
+    $id = $db->query("SELECT user_serial FROM users WHERE user_name = 'httpuser'")->fetchColumn();
+    $token = bin2hex(random_bytes(32));
+    $stmt = $db->prepare('INSERT INTO password_resets (token_hash, user_serial, expires_at) VALUES (?, ?, DATE_ADD(UTC_TIMESTAMP(), INTERVAL 30 MINUTE))');
+    $stmt->execute([hash('sha256', $token), $id]);
+    echo json_encode(['token' => $token]);
+}
diff --git a/vendor/composer/InstalledVersions.php b/vendor/composer/InstalledVersions.php
index 8d3f8ac..2052022 100644
--- a/vendor/composer/InstalledVersions.php
+++ b/vendor/composer/InstalledVersions.php
@@ -277,7 +277,7 @@ class InstalledVersions
         if (null === self::$installed) {
             // only require the installed.php file if this file is loaded from its dumped location,
             // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
-            if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
+            if (substr(__DIR__, -8, 1) !== 'C') {
                 self::$installed = include __DIR__ . '/installed.php';
             } else {
                 self::$installed = array();
@@ -378,7 +378,7 @@ class InstalledVersions
         if (null === self::$installed) {
             // only require the installed.php file if this file is loaded from its dumped location,
             // and not from its source location in the composer/composer package, see https://github.com/composer/composer/issues/9937
-            if (substr(__DIR__, -8, 1) !== 'C' && is_file(__DIR__ . '/installed.php')) {
+            if (substr(__DIR__, -8, 1) !== 'C') {
                 /** @var array{root: array{name: string, pretty_version: string, version: string, reference: string|null, type: string, install_path: string, aliases: string[], dev: bool}, versions: array} $required */
                 $required = require __DIR__ . '/installed.php';
                 self::$installed = $required;
diff --git a/vendor/composer/LICENSE b/vendor/composer/LICENSE
index 62ecfd8..f27399a 100644
--- a/vendor/composer/LICENSE
+++ b/vendor/composer/LICENSE
@@ -1,3 +1,4 @@
+
 Copyright (c) Nils Adermann, Jordi Boggiano
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
@@ -17,3 +18,4 @@ 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/vendor/composer/autoload_psr4.php b/vendor/composer/autoload_psr4.php
index 28c70e9..7d6469c 100644
--- a/vendor/composer/autoload_psr4.php
+++ b/vendor/composer/autoload_psr4.php
@@ -8,25 +8,9 @@ $baseDir = dirname($vendorDir);
 return array(
     'ZipStream\\' => array($vendorDir . '/maennchen/zipstream-php/src'),
     'Psr\\SimpleCache\\' => array($vendorDir . '/psr/simple-cache/src'),
-    'Psr\\Http\\Message\\' => array($vendorDir . '/psr/http-factory/src', $vendorDir . '/psr/http-message/src'),
-    'Psr\\Http\\Client\\' => array($vendorDir . '/psr/http-client/src'),
     'PhpOffice\\PhpSpreadsheet\\' => array($vendorDir . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet'),
     'PHPMailer\\PHPMailer\\' => array($vendorDir . '/phpmailer/phpmailer/src'),
     'Matrix\\' => array($vendorDir . '/markbaker/matrix/classes/src'),
     'Composer\\Pcre\\' => array($vendorDir . '/composer/pcre/src'),
     'Complex\\' => array($vendorDir . '/markbaker/complex/classes/src'),
-    'Com\\Tecnick\\Unicode\\Data\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode-data/src'),
-    'Com\\Tecnick\\Unicode\\' => array($vendorDir . '/tecnickcom/tc-lib-unicode/src'),
-    'Com\\Tecnick\\Pdf\\Sign\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-sign/src'),
-    'Com\\Tecnick\\Pdf\\Parser\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-parser/src'),
-    'Com\\Tecnick\\Pdf\\Page\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-page/src'),
-    'Com\\Tecnick\\Pdf\\Image\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-image/src'),
-    'Com\\Tecnick\\Pdf\\Graph\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-graph/src'),
-    'Com\\Tecnick\\Pdf\\Font\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-font/src'),
-    'Com\\Tecnick\\Pdf\\Filter\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-filter/src'),
-    'Com\\Tecnick\\Pdf\\Encrypt\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf-encrypt/src'),
-    'Com\\Tecnick\\Pdf\\' => array($vendorDir . '/tecnickcom/tc-lib-pdf/src'),
-    'Com\\Tecnick\\File\\' => array($vendorDir . '/tecnickcom/tc-lib-file/src'),
-    'Com\\Tecnick\\Color\\' => array($vendorDir . '/tecnickcom/tc-lib-color/src'),
-    'Com\\Tecnick\\Barcode\\' => array($vendorDir . '/tecnickcom/tc-lib-barcode/src'),
 );
diff --git a/vendor/composer/autoload_static.php b/vendor/composer/autoload_static.php
index 6078577..1f49c16 100644
--- a/vendor/composer/autoload_static.php
+++ b/vendor/composer/autoload_static.php
@@ -14,8 +14,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
         'P' =>
         array (
             'Psr\\SimpleCache\\' => 16,
-            'Psr\\Http\\Message\\' => 17,
-            'Psr\\Http\\Client\\' => 16,
             'PhpOffice\\PhpSpreadsheet\\' => 25,
             'PHPMailer\\PHPMailer\\' => 20,
         ),
@@ -27,20 +25,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
         array (
             'Composer\\Pcre\\' => 14,
             'Complex\\' => 8,
-            'Com\\Tecnick\\Unicode\\Data\\' => 25,
-            'Com\\Tecnick\\Unicode\\' => 20,
-            'Com\\Tecnick\\Pdf\\Sign\\' => 21,
-            'Com\\Tecnick\\Pdf\\Parser\\' => 23,
-            'Com\\Tecnick\\Pdf\\Page\\' => 21,
-            'Com\\Tecnick\\Pdf\\Image\\' => 22,
-            'Com\\Tecnick\\Pdf\\Graph\\' => 22,
-            'Com\\Tecnick\\Pdf\\Font\\' => 21,
-            'Com\\Tecnick\\Pdf\\Filter\\' => 23,
-            'Com\\Tecnick\\Pdf\\Encrypt\\' => 24,
-            'Com\\Tecnick\\Pdf\\' => 16,
-            'Com\\Tecnick\\File\\' => 17,
-            'Com\\Tecnick\\Color\\' => 18,
-            'Com\\Tecnick\\Barcode\\' => 20,
         ),
     );
 
@@ -53,15 +37,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
         array (
             0 => __DIR__ . '/..' . '/psr/simple-cache/src',
         ),
-        'Psr\\Http\\Message\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/psr/http-factory/src',
-            1 => __DIR__ . '/..' . '/psr/http-message/src',
-        ),
-        'Psr\\Http\\Client\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/psr/http-client/src',
-        ),
         'PhpOffice\\PhpSpreadsheet\\' =>
         array (
             0 => __DIR__ . '/..' . '/phpoffice/phpspreadsheet/src/PhpSpreadsheet',
@@ -82,62 +57,6 @@ class ComposerStaticInit6621356a0da1644f04f6f40bd0e0b948
         array (
             0 => __DIR__ . '/..' . '/markbaker/complex/classes/src',
         ),
-        'Com\\Tecnick\\Unicode\\Data\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode-data/src',
-        ),
-        'Com\\Tecnick\\Unicode\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-unicode/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Sign\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-sign/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Parser\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-parser/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Page\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-page/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Image\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-image/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Graph\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-graph/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Font\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-font/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Filter\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-filter/src',
-        ),
-        'Com\\Tecnick\\Pdf\\Encrypt\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf-encrypt/src',
-        ),
-        'Com\\Tecnick\\Pdf\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-pdf/src',
-        ),
-        'Com\\Tecnick\\File\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-file/src',
-        ),
-        'Com\\Tecnick\\Color\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-color/src',
-        ),
-        'Com\\Tecnick\\Barcode\\' =>
-        array (
-            0 => __DIR__ . '/..' . '/tecnickcom/tc-lib-barcode/src',
-        ),
     );
 
     public static $classMap = array (
diff --git a/vendor/composer/installed.json b/vendor/composer/installed.json
index 6f09eef..69458e6 100644
--- a/vendor/composer/installed.json
+++ b/vendor/composer/installed.json
@@ -2,31 +2,32 @@
     "packages": [
         {
             "name": "composer/pcre",
-            "version": "3.3.2",
-            "version_normalized": "3.3.2.0",
+            "version": "3.4.0",
+            "version_normalized": "3.4.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/composer/pcre.git",
-                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e"
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/composer/pcre/zipball/b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
-                "reference": "b2bed4734f0cc156ee1fe9c0da2550420d99a21e",
+                "url": "https://api.github.com/repos/composer/pcre/zipball/d5a341b3fb61f3001970940afb1d332968a183ed",
+                "reference": "d5a341b3fb61f3001970940afb1d332968a183ed",
                 "shasum": ""
             },
             "require": {
                 "php": "^7.4 || ^8.0"
             },
             "conflict": {
-                "phpstan/phpstan": "<1.11.10"
+                "phpstan/phpstan": "<2.2.2"
             },
             "require-dev": {
-                "phpstan/phpstan": "^1.12 || ^2",
-                "phpstan/phpstan-strict-rules": "^1 || ^2",
-                "phpunit/phpunit": "^8 || ^9"
+                "phpstan/phpstan": "^2",
+                "phpstan/phpstan-deprecation-rules": "^2",
+                "phpstan/phpstan-strict-rules": "^2",
+                "phpunit/phpunit": "^9"
             },
-            "time": "2024-11-12T16:29:46+00:00",
+            "time": "2026-06-07T11:47:49+00:00",
             "type": "library",
             "extra": {
                 "phpstan": {
@@ -64,7 +65,7 @@
             ],
             "support": {
                 "issues": "https://github.com/composer/pcre/issues",
-                "source": "https://github.com/composer/pcre/tree/3.3.2"
+                "source": "https://github.com/composer/pcre/tree/3.4.0"
             },
             "funding": [
                 {
@@ -74,27 +75,23 @@
                 {
                     "url": "https://github.com/composer",
                     "type": "github"
-                },
-                {
-                    "url": "https://tidelift.com/funding/github/packagist/composer/composer",
-                    "type": "tidelift"
                 }
             ],
             "install-path": "./pcre"
         },
         {
             "name": "maennchen/zipstream-php",
-            "version": "3.2.0",
-            "version_normalized": "3.2.0.0",
+            "version": "3.2.2",
+            "version_normalized": "3.2.2.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/maennchen/ZipStream-PHP.git",
-                "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416"
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
-                "reference": "9712d8fa4cdf9240380b01eb4be55ad8dcf71416",
+                "url": "https://api.github.com/repos/maennchen/ZipStream-PHP/zipball/77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
+                "reference": "77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e",
                 "shasum": ""
             },
             "require": {
@@ -105,7 +102,7 @@
             "require-dev": {
                 "brianium/paratest": "^7.7",
                 "ext-zip": "*",
-                "friendsofphp/php-cs-fixer": "^3.16",
+                "friendsofphp/php-cs-fixer": "^3.86",
                 "guzzlehttp/guzzle": "^7.5",
                 "mikey179/vfsstream": "^1.6",
                 "php-coveralls/php-coveralls": "^2.5",
@@ -116,7 +113,7 @@
                 "guzzlehttp/psr7": "^2.4",
                 "psr/http-message": "^2.0"
             },
-            "time": "2025-07-17T11:15:13+00:00",
+            "time": "2026-04-11T18:38:28+00:00",
             "type": "library",
             "installation-source": "dist",
             "autoload": {
@@ -153,7 +150,7 @@
             ],
             "support": {
                 "issues": "https://github.com/maennchen/ZipStream-PHP/issues",
-                "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.0"
+                "source": "https://github.com/maennchen/ZipStream-PHP/tree/3.2.2"
             },
             "funding": [
                 {
@@ -363,17 +360,17 @@
         },
         {
             "name": "phpoffice/phpspreadsheet",
-            "version": "5.2.0",
-            "version_normalized": "5.2.0.0",
+            "version": "5.10.0",
+            "version_normalized": "5.10.0.0",
             "source": {
                 "type": "git",
                 "url": "https://github.com/PHPOffice/PhpSpreadsheet.git",
-                "reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb"
+                "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53"
             },
             "dist": {
                 "type": "zip",
-                "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
-                "reference": "3b8994b3aac4b61018bc04fc8c441f4fd68c18eb",
+                "url": "https://api.github.com/repos/PHPOffice/PhpSpreadsheet/zipball/eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
+                "reference": "eb18727acf6b1f4cc67145a52ab04f9fd4c28d53",
                 "shasum": ""
             },
             "require": {
@@ -381,6 +378,7 @@
                 "ext-ctype": "*",
                 "ext-dom": "*",
                 "ext-fileinfo": "*",
+                "ext-filter": "*",
                 "ext-gd": "*",
                 "ext-iconv": "*",
                 "ext-libxml": "*",
@@ -394,14 +392,14 @@
                 "maennchen/zipstream-php": "^2.1 || ^3.0",
                 "markbaker/complex": "^3.0",
                 "markbaker/matrix": "^3.0",
-                "php": "^8.1",
-                "psr/http-client": "^1.0",
-                "psr/http-factory": "^1.0",
+                "php": "^8.2",
                 "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
             },
             "require-dev": {
                 "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
                 "dompdf/dompdf": "^2.0 || ^3.0",
+                "ext-intl": "*",
+                "ext-openssl": "*",
                 "friendsofphp/php-cs-fixer": "^3.2",
                 "mitoteam/jpgraph": "^10.5",
                 "mpdf/mpdf": "^8.1.1",
@@ -409,18 +407,19 @@
                 "phpstan/phpstan": "^1.1 || ^2.0",
                 "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
                 "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
-                "phpunit/phpunit": "^10.5",
+                "phpunit/phpunit": "^10.5 || ^11.0",
                 "squizlabs/php_codesniffer": "^3.7",
-                "tecnickcom/tcpdf": "^6.5"
+                "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
             },
             "suggest": {
                 "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
-                "ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
+                "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
+                "ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
                 "mitoteam/jpgraph": "Option for rendering charts, or including charts with PDF or HTML Writers",
                 "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
                 "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer"
             },
-            "time": "2025-10-26T15:54:22+00:00",
+            "time": "2026-09-17T03:54:50+00:00",
             "type": "library",
             "installation-source": "dist",
             "autoload": {
@@ -450,6 +449,9 @@
                 },
                 {
                     "name": "Adrien Crivelli"
+                },
+                {
+                    "name": "Owen Leibman"
                 }
             ],
             "description": "PHPSpreadsheet - Read, Create and Write Spreadsheet documents in PHP - Spreadsheet engine",
@@ -466,179 +468,10 @@
             ],
             "support": {
                 "issues": "https://github.com/PHPOffice/PhpSpreadsheet/issues",
-                "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.2.0"
+                "source": "https://github.com/PHPOffice/PhpSpreadsheet/tree/5.10.0"
             },
             "install-path": "../phpoffice/phpspreadsheet"
         },
-        {
-            "name": "psr/http-client",
-            "version": "1.0.3",
-            "version_normalized": "1.0.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-client.git",
-                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-client/zipball/bb5906edc1c324c9a05aa0873d40117941e5fa90",
-                "reference": "bb5906edc1c324c9a05aa0873d40117941e5fa90",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.0 || ^8.0",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "time": "2023-09-23T14:17:50+00:00",
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Client\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP clients",
-            "homepage": "https://github.com/php-fig/http-client",
-            "keywords": [
-                "http",
-                "http-client",
-                "psr",
-                "psr-18"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-client"
-            },
-            "install-path": "../psr/http-client"
-        },
-        {
-            "name": "psr/http-factory",
-            "version": "1.1.0",
-            "version_normalized": "1.1.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-factory.git",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-factory/zipball/2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "reference": "2b4765fddfe3b508ac62f829e852b1501d3f6e8a",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=7.1",
-                "psr/http-message": "^1.0 || ^2.0"
-            },
-            "time": "2024-04-15T12:06:14+00:00",
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "1.0.x-dev"
-                }
-            },
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "PSR-17: Common interfaces for PSR-7 HTTP message factories",
-            "keywords": [
-                "factory",
-                "http",
-                "message",
-                "psr",
-                "psr-17",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-factory"
-            },
-            "install-path": "../psr/http-factory"
-        },
-        {
-            "name": "psr/http-message",
-            "version": "2.0",
-            "version_normalized": "2.0.0.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/php-fig/http-message.git",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/php-fig/http-message/zipball/402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "reference": "402d35bcb92c70c026d1a6a9883f06b2ead23d71",
-                "shasum": ""
-            },
-            "require": {
-                "php": "^7.2 || ^8.0"
-            },
-            "time": "2023-04-04T09:54:51+00:00",
-            "type": "library",
-            "extra": {
-                "branch-alias": {
-                    "dev-master": "2.0.x-dev"
-                }
-            },
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Psr\\Http\\Message\\": "src/"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "MIT"
-            ],
-            "authors": [
-                {
-                    "name": "PHP-FIG",
-                    "homepage": "https://www.php-fig.org/"
-                }
-            ],
-            "description": "Common interface for HTTP messages",
-            "homepage": "https://github.com/php-fig/http-message",
-            "keywords": [
-                "http",
-                "http-message",
-                "psr",
-                "psr-7",
-                "request",
-                "response"
-            ],
-            "support": {
-                "source": "https://github.com/php-fig/http-message/tree/2.0"
-            },
-            "install-path": "../psr/http-message"
-        },
         {
             "name": "psr/simple-cache",
             "version": "3.0.0",
@@ -693,1005 +526,6 @@
             },
             "install-path": "../psr/simple-cache"
         },
-        {
-            "name": "tecnickcom/tc-lib-barcode",
-            "version": "2.13.2",
-            "version_normalized": "2.13.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-barcode.git",
-                "reference": "c7f3ddbc3df10259c59ddc3016ec421267e25e5d"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-barcode/zipball/c7f3ddbc3df10259c59ddc3016ec421267e25e5d",
-                "reference": "c7f3ddbc3df10259c59ddc3016ec421267e25e5d",
-                "shasum": ""
-            },
-            "require": {
-                "ext-ctype": "*",
-                "ext-gd": "*",
-                "ext-pcre": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-color": "^2.13"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "suggest": {
-                "ext-bcmath": "Speeds up the arbitrary precision arithmetic used by the IMB and PDF417 barcode types (a pure-PHP implementation is used when missing)",
-                "ext-imagick": "Alternative PNG renderer used by getPngData() when the extension is loaded"
-            },
-            "time": "2026-08-07T08:07:20+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Barcode\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to generate linear and bidimensional barcodes",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "3 of 9",
-                "ANSI MH10.8M-1983",
-                "CBC",
-                "CODABAR",
-                "CODE 11",
-                "CODE 128 A B C",
-                "CODE 39",
-                "CODE 93",
-                "EAN 13",
-                "EAN 8",
-                "ECC200",
-                "ISO IEC 15438 2006",
-                "ISO IEC 16022",
-                "ISO IEC 24778 2008",
-                "Intelligent Mail Barcode",
-                "Interleaved 2 of 5",
-                "KIX",
-                "Klant",
-                "MSI",
-                "Onecode",
-                "PHARMACODE",
-                "PHARMACODE TWO-TRACKS",
-                "POSTNET",
-                "RMS4CC",
-                "Standard 2 of 5",
-                "UPC-A",
-                "UPC-E",
-                "USD-3",
-                "USPS-B-3200",
-                "USS-93",
-                "aztec",
-                "barcode",
-                "datamatrix",
-                "pdf417",
-                "planet",
-                "qr-code",
-                "royal mail",
-                "tc-lib-barcode",
-                "upc"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-barcode/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-barcode"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-barcode"
-        },
-        {
-            "name": "tecnickcom/tc-lib-color",
-            "version": "2.13.3",
-            "version_normalized": "2.13.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-color.git",
-                "reference": "36b0cb2215c446fed65842151d494f2b3081533c"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-color/zipball/36b0cb2215c446fed65842151d494f2b3081533c",
-                "reference": "36b0cb2215c446fed65842151d494f2b3081533c",
-                "shasum": ""
-            },
-            "require": {
-                "ext-pcre": "*",
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:05:09+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Color\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to manipulate various color representations",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "cmyk",
-                "color",
-                "colors",
-                "colour",
-                "colours",
-                "hsl",
-                "hsla",
-                "javascript",
-                "rgb",
-                "rgba",
-                "tc-lib-color",
-                "web"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-color/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-color"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-color"
-        },
-        {
-            "name": "tecnickcom/tc-lib-file",
-            "version": "3.7.3",
-            "version_normalized": "3.7.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-file.git",
-                "reference": "c582b6d493c26703e95809f6d3e938c79f88885f"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-file/zipball/c582b6d493c26703e95809f6d3e938c79f88885f",
-                "reference": "c582b6d493c26703e95809f6d3e938c79f88885f",
-                "shasum": ""
-            },
-            "require": {
-                "ext-curl": "*",
-                "ext-pcre": "*",
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^13.2 || ^12.5 || ^11.5"
-            },
-            "suggest": {
-                "ext-intl": "Enables Unicode NFC normalization when matching paths against the allowlist"
-            },
-            "time": "2026-08-07T08:05:39+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\File\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to read byte-level data from files",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "Double",
-                "bit",
-                "byte",
-                "file",
-                "long",
-                "read",
-                "short",
-                "tc-lib-file"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-file/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-file"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-file"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf",
-            "version": "8.68.3",
-            "version_normalized": "8.68.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf.git",
-                "reference": "6b056239c0d9781319a4d009e8ca33d48e94e0ea"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf/zipball/6b056239c0d9781319a4d009e8ca33d48e94e0ea",
-                "reference": "6b056239c0d9781319a4d009e8ca33d48e94e0ea",
-                "shasum": ""
-            },
-            "require": {
-                "ext-ctype": "*",
-                "ext-filter": "*",
-                "ext-hash": "*",
-                "ext-json": "*",
-                "ext-mbstring": "*",
-                "ext-openssl": "*",
-                "ext-pcre": "*",
-                "ext-xml": "*",
-                "ext-zlib": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-barcode": "^2.13",
-                "tecnickcom/tc-lib-color": "^2.13",
-                "tecnickcom/tc-lib-file": "^3.7",
-                "tecnickcom/tc-lib-pdf-encrypt": "^2.9",
-                "tecnickcom/tc-lib-pdf-font": "^3.13",
-                "tecnickcom/tc-lib-pdf-graph": "^2.15",
-                "tecnickcom/tc-lib-pdf-image": "^3.12",
-                "tecnickcom/tc-lib-pdf-page": "^4.14",
-                "tecnickcom/tc-lib-pdf-parser": "^3.14",
-                "tecnickcom/tc-lib-pdf-sign": "^1.1",
-                "tecnickcom/tc-lib-unicode": "^3.0",
-                "tecnickcom/tc-lib-unicode-data": "^3.0"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "suggest": {
-                "ext-curl": "Required to contact a Timestamp Authority (RFC 3161) when timestamping signatures",
-                "ext-intl": "Enables Unicode NFC normalization of the PDF file name"
-            },
-            "time": "2026-08-07T08:11:00+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP PDF Library",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "PDFD32000-2008",
-                "TCPDF",
-                "document",
-                "pdf",
-                "tc-lib-pdf"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-encrypt",
-            "version": "2.9.3",
-            "version_normalized": "2.9.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-encrypt.git",
-                "reference": "9d188937a673af8adc6da1f104c44cf434f7b198"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-encrypt/zipball/9d188937a673af8adc6da1f104c44cf434f7b198",
-                "reference": "9d188937a673af8adc6da1f104c44cf434f7b198",
-                "shasum": ""
-            },
-            "require": {
-                "ext-hash": "*",
-                "ext-openssl": "*",
-                "ext-pcre": "*",
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "suggest": {
-                "ext-posix": "Adds the process id to the random seed material"
-            },
-            "time": "2026-08-07T08:05:51+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Encrypt\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to encrypt data for PDF",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "aes",
-                "encrypt",
-                "encryption",
-                "pdf",
-                "rc4",
-                "tc-lib-pdf-encrypt"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-encrypt/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-encrypt"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-encrypt"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-filter",
-            "version": "2.10.3",
-            "version_normalized": "2.10.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-filter.git",
-                "reference": "48270d50db69852ef58a22b5b034b8a08e5c8464"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-filter/zipball/48270d50db69852ef58a22b5b034b8a08e5c8464",
-                "reference": "48270d50db69852ef58a22b5b034b8a08e5c8464",
-                "shasum": ""
-            },
-            "require": {
-                "ext-pcre": "*",
-                "ext-zlib": "*",
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "suggest": {
-                "ext-imagick": "Required for the JPXDecode (JPEG 2000) and CCITTFaxDecode filters"
-            },
-            "time": "2026-08-07T08:06:03+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Filter\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to decode PDF compression and encryption filters",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "compression",
-                "encryption",
-                "filter",
-                "pdf",
-                "tc-lib-pdf-filter"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-filter/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-filter"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-filter"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-font",
-            "version": "3.13.2",
-            "version_normalized": "3.13.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-font.git",
-                "reference": "314dfbe240057e1119d4830f35827455f8a76eba"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-font/zipball/314dfbe240057e1119d4830f35827455f8a76eba",
-                "reference": "314dfbe240057e1119d4830f35827455f8a76eba",
-                "shasum": ""
-            },
-            "require": {
-                "ext-hash": "*",
-                "ext-json": "*",
-                "ext-pcre": "*",
-                "ext-zlib": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-file": "^3.7",
-                "tecnickcom/tc-lib-pdf-encrypt": "^2.9",
-                "tecnickcom/tc-lib-unicode-data": "^3.0"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "suggest": {
-                "ext-iconv": "Decodes legacy Macintosh (MacRoman) TrueType name strings",
-                "ext-mbstring": "Decodes UTF-16BE and Windows-1252 TrueType name strings"
-            },
-            "time": "2026-08-07T08:09:37+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Font\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing PDF page formats and definitions",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "PFB",
-                "afm",
-                "font",
-                "import",
-                "pdf",
-                "tc-lib-pdf-font",
-                "ttf"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-font/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-font"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-font"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-graph",
-            "version": "2.15.2",
-            "version_normalized": "2.15.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-graph.git",
-                "reference": "4a3408690c65e14b9c86abe97a12c3079eb8a0cd"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-graph/zipball/4a3408690c65e14b9c86abe97a12c3079eb8a0cd",
-                "reference": "4a3408690c65e14b9c86abe97a12c3079eb8a0cd",
-                "shasum": ""
-            },
-            "require": {
-                "ext-zlib": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-color": "^2.13",
-                "tecnickcom/tc-lib-pdf-encrypt": "^2.9"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:08:50+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Graph\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing PDF graphic and geometric methods",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "geometry",
-                "graphic",
-                "pdf",
-                "tc-lib-pdf-graph",
-                "transformation"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-graph/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-graph"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-graph"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-image",
-            "version": "3.12.2",
-            "version_normalized": "3.12.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-image.git",
-                "reference": "d7052a7e9216e842bd77395f1dfb443e9bfbcef8"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-image/zipball/d7052a7e9216e842bd77395f1dfb443e9bfbcef8",
-                "reference": "d7052a7e9216e842bd77395f1dfb443e9bfbcef8",
-                "shasum": ""
-            },
-            "require": {
-                "ext-gd": "*",
-                "ext-zlib": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-color": "^2.13",
-                "tecnickcom/tc-lib-file": "^3.7",
-                "tecnickcom/tc-lib-pdf-encrypt": "^2.9"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:09:04+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Image\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing PDF Image methods",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "image",
-                "jpeg",
-                "jpg",
-                "pdf",
-                "png",
-                "tc-lib-pdf-image"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-image/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-image"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-image"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-page",
-            "version": "4.14.2",
-            "version_normalized": "4.14.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-page.git",
-                "reference": "ac9adaddf8624b5008da335914a42a318cd7d310"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-page/zipball/ac9adaddf8624b5008da335914a42a318cd7d310",
-                "reference": "ac9adaddf8624b5008da335914a42a318cd7d310",
-                "shasum": ""
-            },
-            "require": {
-                "ext-zlib": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-color": "^2.13",
-                "tecnickcom/tc-lib-pdf-encrypt": "^2.9"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:08:37+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Page\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing PDF page formats and definitions",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "format",
-                "page",
-                "pdf",
-                "tc-lib-pdf-page"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-page/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-page"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-page"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-parser",
-            "version": "3.14.2",
-            "version_normalized": "3.14.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-parser.git",
-                "reference": "db9c32046a3e8d57c8626e8d3ffc94caa10a0675"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-parser/zipball/db9c32046a3e8d57c8626e8d3ffc94caa10a0675",
-                "reference": "db9c32046a3e8d57c8626e8d3ffc94caa10a0675",
-                "shasum": ""
-            },
-            "require": {
-                "ext-pcre": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-pdf-filter": "^2.10"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:08:23+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Parser\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to parse PDF documents",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "document",
-                "parser",
-                "pdf",
-                "tc-lib-pdf-parser"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-parser/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-parser"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-parser"
-        },
-        {
-            "name": "tecnickcom/tc-lib-pdf-sign",
-            "version": "1.1.3",
-            "version_normalized": "1.1.3.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-pdf-sign.git",
-                "reference": "4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-pdf-sign/zipball/4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7",
-                "reference": "4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7",
-                "shasum": ""
-            },
-            "require": {
-                "ext-hash": "*",
-                "ext-openssl": "*",
-                "ext-pcre": "*",
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:06:17+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Pdf\\Sign\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library to create and embed digital signatures (PKCS#7 / CAdES / PAdES) in PDF documents",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "cades",
-                "digital-signature",
-                "pades",
-                "pdf",
-                "pkcs7",
-                "sign",
-                "signature",
-                "tc-lib-pdf-sign",
-                "timestamp"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-pdf-sign/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-pdf-sign"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-pdf-sign"
-        },
-        {
-            "name": "tecnickcom/tc-lib-unicode",
-            "version": "3.0.2",
-            "version_normalized": "3.0.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-unicode.git",
-                "reference": "e0549f1e0bee657a7a5c361d823409655a4ea2c3"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-unicode/zipball/e0549f1e0bee657a7a5c361d823409655a4ea2c3",
-                "reference": "e0549f1e0bee657a7a5c361d823409655a4ea2c3",
-                "shasum": ""
-            },
-            "require": {
-                "ext-ctype": "*",
-                "ext-mbstring": "*",
-                "ext-pcre": "*",
-                "php": ">=8.2",
-                "tecnickcom/tc-lib-unicode-data": "^3.0"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:08:11+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Unicode\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing Unicode methods",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "font",
-                "pdf",
-                "tc-lib-unicode",
-                "unicode",
-                "utf-8"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-unicode/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-unicode"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-unicode"
-        },
-        {
-            "name": "tecnickcom/tc-lib-unicode-data",
-            "version": "3.0.2",
-            "version_normalized": "3.0.2.0",
-            "source": {
-                "type": "git",
-                "url": "https://github.com/tecnickcom/tc-lib-unicode-data.git",
-                "reference": "6b090f51613087c2ed6e576e9543c1feddf2ba51"
-            },
-            "dist": {
-                "type": "zip",
-                "url": "https://api.github.com/repos/tecnickcom/tc-lib-unicode-data/zipball/6b090f51613087c2ed6e576e9543c1feddf2ba51",
-                "reference": "6b090f51613087c2ed6e576e9543c1feddf2ba51",
-                "shasum": ""
-            },
-            "require": {
-                "php": ">=8.2"
-            },
-            "require-dev": {
-                "pdepend/pdepend": "^2.16",
-                "phpunit/phpunit": "^11.5 || ^12.5 || ^13.2"
-            },
-            "time": "2026-08-07T08:06:30+00:00",
-            "type": "library",
-            "installation-source": "dist",
-            "autoload": {
-                "psr-4": {
-                    "Com\\Tecnick\\Unicode\\Data\\": "src"
-                }
-            },
-            "notification-url": "https://packagist.org/downloads/",
-            "license": [
-                "LGPL-3.0-or-later"
-            ],
-            "authors": [
-                {
-                    "name": "Nicola Asuni",
-                    "email": "info@tecnick.com",
-                    "role": "lead"
-                }
-            ],
-            "description": "PHP library containing Unicode definitions",
-            "homepage": "https://tcpdf.org",
-            "keywords": [
-                "font",
-                "pdf",
-                "tc-lib-unicode-data",
-                "unicode",
-                "utf-8"
-            ],
-            "support": {
-                "issues": "https://github.com/tecnickcom/tc-lib-unicode-data/issues",
-                "source": "https://github.com/tecnickcom/tc-lib-unicode-data"
-            },
-            "funding": [
-                {
-                    "url": "https://github.com/sponsors/tecnickcom",
-                    "type": "github"
-                }
-            ],
-            "install-path": "../tecnickcom/tc-lib-unicode-data"
-        },
         {
             "name": "tecnickcom/tcpdf",
             "version": "6.10.0",
diff --git a/vendor/composer/installed.php b/vendor/composer/installed.php
index 7bb6e9a..d19329e 100644
--- a/vendor/composer/installed.php
+++ b/vendor/composer/installed.php
@@ -1,9 +1,9 @@
  array(
         'name' => '__root__',
-        'pretty_version' => '1.0.0+no-version-set',
-        'version' => '1.0.0.0',
-        'reference' => null,
+        'pretty_version' => 'dev-development',
+        'version' => 'dev-development',
+        'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
         'type' => 'library',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -11,27 +11,27 @@
     ),
     'versions' => array(
         '__root__' => array(
-            'pretty_version' => '1.0.0+no-version-set',
-            'version' => '1.0.0.0',
-            'reference' => null,
+            'pretty_version' => 'dev-development',
+            'version' => 'dev-development',
+            'reference' => 'a1643eb1b74d1dae0fda79a68b6f0672d4603a81',
             'type' => 'library',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'composer/pcre' => array(
-            'pretty_version' => '3.3.2',
-            'version' => '3.3.2.0',
-            'reference' => 'b2bed4734f0cc156ee1fe9c0da2550420d99a21e',
+            'pretty_version' => '3.4.0',
+            'version' => '3.4.0.0',
+            'reference' => 'd5a341b3fb61f3001970940afb1d332968a183ed',
             'type' => 'library',
             'install_path' => __DIR__ . '/./pcre',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
         'maennchen/zipstream-php' => array(
-            'pretty_version' => '3.2.0',
-            'version' => '3.2.0.0',
-            'reference' => '9712d8fa4cdf9240380b01eb4be55ad8dcf71416',
+            'pretty_version' => '3.2.2',
+            'version' => '3.2.2.0',
+            'reference' => '77bebeb4c6c340bb3c11c843b2cffd8bbfde4d5e',
             'type' => 'library',
             'install_path' => __DIR__ . '/../maennchen/zipstream-php',
             'aliases' => array(),
@@ -65,41 +65,14 @@
             'dev_requirement' => false,
         ),
         'phpoffice/phpspreadsheet' => array(
-            'pretty_version' => '5.2.0',
-            'version' => '5.2.0.0',
-            'reference' => '3b8994b3aac4b61018bc04fc8c441f4fd68c18eb',
+            'pretty_version' => '5.10.0',
+            'version' => '5.10.0.0',
+            'reference' => 'eb18727acf6b1f4cc67145a52ab04f9fd4c28d53',
             'type' => 'library',
             'install_path' => __DIR__ . '/../phpoffice/phpspreadsheet',
             'aliases' => array(),
             'dev_requirement' => false,
         ),
-        'psr/http-client' => array(
-            'pretty_version' => '1.0.3',
-            'version' => '1.0.3.0',
-            'reference' => 'bb5906edc1c324c9a05aa0873d40117941e5fa90',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../psr/http-client',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'psr/http-factory' => array(
-            'pretty_version' => '1.1.0',
-            'version' => '1.1.0.0',
-            'reference' => '2b4765fddfe3b508ac62f829e852b1501d3f6e8a',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../psr/http-factory',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'psr/http-message' => array(
-            'pretty_version' => '2.0',
-            'version' => '2.0.0.0',
-            'reference' => '402d35bcb92c70c026d1a6a9883f06b2ead23d71',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../psr/http-message',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
         'psr/simple-cache' => array(
             'pretty_version' => '3.0.0',
             'version' => '3.0.0.0',
@@ -109,132 +82,6 @@
             'aliases' => array(),
             'dev_requirement' => false,
         ),
-        'tecnickcom/tc-lib-barcode' => array(
-            'pretty_version' => '2.13.2',
-            'version' => '2.13.2.0',
-            'reference' => 'c7f3ddbc3df10259c59ddc3016ec421267e25e5d',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-barcode',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-color' => array(
-            'pretty_version' => '2.13.3',
-            'version' => '2.13.3.0',
-            'reference' => '36b0cb2215c446fed65842151d494f2b3081533c',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-color',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-file' => array(
-            'pretty_version' => '3.7.3',
-            'version' => '3.7.3.0',
-            'reference' => 'c582b6d493c26703e95809f6d3e938c79f88885f',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-file',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf' => array(
-            'pretty_version' => '8.68.3',
-            'version' => '8.68.3.0',
-            'reference' => '6b056239c0d9781319a4d009e8ca33d48e94e0ea',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-encrypt' => array(
-            'pretty_version' => '2.9.3',
-            'version' => '2.9.3.0',
-            'reference' => '9d188937a673af8adc6da1f104c44cf434f7b198',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-encrypt',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-filter' => array(
-            'pretty_version' => '2.10.3',
-            'version' => '2.10.3.0',
-            'reference' => '48270d50db69852ef58a22b5b034b8a08e5c8464',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-filter',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-font' => array(
-            'pretty_version' => '3.13.2',
-            'version' => '3.13.2.0',
-            'reference' => '314dfbe240057e1119d4830f35827455f8a76eba',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-font',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-graph' => array(
-            'pretty_version' => '2.15.2',
-            'version' => '2.15.2.0',
-            'reference' => '4a3408690c65e14b9c86abe97a12c3079eb8a0cd',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-graph',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-image' => array(
-            'pretty_version' => '3.12.2',
-            'version' => '3.12.2.0',
-            'reference' => 'd7052a7e9216e842bd77395f1dfb443e9bfbcef8',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-image',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-page' => array(
-            'pretty_version' => '4.14.2',
-            'version' => '4.14.2.0',
-            'reference' => 'ac9adaddf8624b5008da335914a42a318cd7d310',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-page',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-parser' => array(
-            'pretty_version' => '3.14.2',
-            'version' => '3.14.2.0',
-            'reference' => 'db9c32046a3e8d57c8626e8d3ffc94caa10a0675',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-parser',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-pdf-sign' => array(
-            'pretty_version' => '1.1.3',
-            'version' => '1.1.3.0',
-            'reference' => '4132f1fc4b9c103b1a9ec879e2d7b7f9598918f7',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-pdf-sign',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-unicode' => array(
-            'pretty_version' => '3.0.2',
-            'version' => '3.0.2.0',
-            'reference' => 'e0549f1e0bee657a7a5c361d823409655a4ea2c3',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
-        'tecnickcom/tc-lib-unicode-data' => array(
-            'pretty_version' => '3.0.2',
-            'version' => '3.0.2.0',
-            'reference' => '6b090f51613087c2ed6e576e9543c1feddf2ba51',
-            'type' => 'library',
-            'install_path' => __DIR__ . '/../tecnickcom/tc-lib-unicode-data',
-            'aliases' => array(),
-            'dev_requirement' => false,
-        ),
         'tecnickcom/tcpdf' => array(
             'pretty_version' => '6.10.0',
             'version' => '6.10.0.0',
diff --git a/vendor/composer/pcre/composer.json b/vendor/composer/pcre/composer.json
index d3a7e67..a6e72c3 100644
--- a/vendor/composer/pcre/composer.json
+++ b/vendor/composer/pcre/composer.json
@@ -20,12 +20,13 @@
         "php": "^7.4 || ^8.0"
     },
     "require-dev": {
-        "phpunit/phpunit": "^8 || ^9",
-        "phpstan/phpstan": "^1.12 || ^2",
-        "phpstan/phpstan-strict-rules": "^1 || ^2"
+        "phpunit/phpunit": "^9",
+        "phpstan/phpstan": "^2",
+        "phpstan/phpstan-strict-rules": "^2",
+        "phpstan/phpstan-deprecation-rules": "^2"
     },
     "conflict": {
-        "phpstan/phpstan": "<1.11.10"
+        "phpstan/phpstan": "<2.2.2"
     },
     "autoload": {
         "psr-4": {
@@ -48,7 +49,10 @@
         }
     },
     "scripts": {
-        "test": "@php vendor/bin/phpunit",
+        "test": [
+                "@php vendor/bin/phpunit",
+                "@php vendor/bin/phpunit --testsuite phpstan"
+        ],
         "phpstan": "@php phpstan analyse"
     }
 }
diff --git a/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php b/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php
index 3db0ce0..e492c79 100644
--- a/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php
+++ b/vendor/composer/pcre/src/PHPStan/PregMatchTypeSpecifyingExtension.php
@@ -57,18 +57,38 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
     {
         $args = $node->getArgs();
         $patternArg = $args[0] ?? null;
+        $subjectArg = $args[1] ?? null;
         $matchesArg = $args[2] ?? null;
         $flagsArg = $args[3] ?? null;
 
+        $subjectTypes = new SpecifiedTypes();
+        if ($patternArg === null) {
+            return $subjectTypes;
+        }
+
         if (
-            $patternArg === null || $matchesArg === null
+            $subjectArg !== null
+            && $context->true()
+            && $scope->getType($subjectArg->value)->isString()->yes()
         ) {
-            return new SpecifiedTypes();
+            $subjectType = $this->regexShapeMatcher->matchSubjectExpr($patternArg->value, $scope);
+            if ($subjectType !== null) {
+                $subjectTypes = $this->typeSpecifier->create(
+                    $subjectArg->value,
+                    $subjectType,
+                    $context,
+                    $scope,
+                )->setRootExpr($node);
+            }
+        }
+
+        if ($matchesArg === null) {
+            return $subjectTypes;
         }
 
         $flagsType = PregMatchFlags::getType($flagsArg, $scope);
         if ($flagsType === null) {
-            return new SpecifiedTypes();
+            return $subjectTypes;
         }
 
         if (stripos($methodReflection->getName(), 'matchAll') !== false) {
@@ -78,7 +98,7 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
         }
 
         if ($matchedType === null) {
-            return new SpecifiedTypes();
+            return $subjectTypes;
         }
 
         if (
@@ -93,27 +113,13 @@ final class PregMatchTypeSpecifyingExtension implements StaticMethodTypeSpecifyi
             $context = $context->negate();
         }
 
-        // @phpstan-ignore function.alreadyNarrowedType
-        if (method_exists('PHPStan\Analyser\SpecifiedTypes', 'setRootExpr')) {
-            $typeSpecifier = $this->typeSpecifier->create(
-                $matchesArg->value,
-                $matchedType,
-                $context,
-                $scope
-            )->setRootExpr($node);
-
-            return $overwrite ? $typeSpecifier->setAlwaysOverwriteTypes() : $typeSpecifier;
-        }
-
-        // @phpstan-ignore arguments.count
-        return $this->typeSpecifier->create(
+        $specifiedTypes = $this->typeSpecifier->create(
             $matchesArg->value,
             $matchedType,
             $context,
-            // @phpstan-ignore argument.type
-            $overwrite,
-            $scope,
-            $node
-        );
+            $scope
+        )->setRootExpr($node);
+
+        return $subjectTypes->unionWith($overwrite ? $specifiedTypes->setAlwaysOverwriteTypes() : $specifiedTypes);
     }
 }
diff --git a/vendor/composer/pcre/src/Preg.php b/vendor/composer/pcre/src/Preg.php
index 400abbf..98b4d29 100644
--- a/vendor/composer/pcre/src/Preg.php
+++ b/vendor/composer/pcre/src/Preg.php
@@ -395,7 +395,7 @@ class Preg
      * @return array
      * @throws UnexpectedNullMatchException
      */
-    private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod)
+    private static function enforceNonNullMatches(string $pattern, array $matches, string $variantMethod): array
     {
         foreach ($matches as $group => $match) {
             if (is_string($match) || (is_array($match) && is_string($match[0]))) {
@@ -414,7 +414,7 @@ class Preg
      * @return array>
      * @throws UnexpectedNullMatchException
      */
-    private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod)
+    private static function enforceNonNullMatchAll(string $pattern, array $matches, string $variantMethod): array
     {
         foreach ($matches as $group => $groupMatches) {
             foreach ($groupMatches as $match) {
diff --git a/vendor/maennchen/zipstream-php/.php-cs-fixer.dist.php b/vendor/maennchen/zipstream-php/.php-cs-fixer.dist.php
index 31c8f0c..af9423c 100644
--- a/vendor/maennchen/zipstream-php/.php-cs-fixer.dist.php
+++ b/vendor/maennchen/zipstream-php/.php-cs-fixer.dist.php
@@ -71,4 +71,5 @@ return $config->setRules([
     ])
     ->setFinder($finder)
     ->setRiskyAllowed(true)
+    ->setUnsupportedPhpVersionAllowed(true)
     ->setParallelConfig(Runner\Parallel\ParallelConfigFactory::detect());
diff --git a/vendor/maennchen/zipstream-php/.tool-versions b/vendor/maennchen/zipstream-php/.tool-versions
index abc068c..727f5c9 100644
--- a/vendor/maennchen/zipstream-php/.tool-versions
+++ b/vendor/maennchen/zipstream-php/.tool-versions
@@ -1 +1 @@
-php 8.4.10
+php 8.5.0
diff --git a/vendor/maennchen/zipstream-php/composer.json b/vendor/maennchen/zipstream-php/composer.json
index 529c244..f45acd4 100644
--- a/vendor/maennchen/zipstream-php/composer.json
+++ b/vendor/maennchen/zipstream-php/composer.json
@@ -32,7 +32,7 @@
     "ext-zip": "*",
     "mikey179/vfsstream": "^1.6",
     "php-coveralls/php-coveralls": "^2.5",
-    "friendsofphp/php-cs-fixer": "^3.16",
+    "friendsofphp/php-cs-fixer": "^3.86",
     "vimeo/psalm": "^6.0",
     "brianium/paratest": "^7.7"
   },
diff --git a/vendor/maennchen/zipstream-php/guides/LargeFilesToS3.rst b/vendor/maennchen/zipstream-php/guides/LargeFilesToS3.rst
new file mode 100644
index 0000000..2330b85
--- /dev/null
+++ b/vendor/maennchen/zipstream-php/guides/LargeFilesToS3.rst
@@ -0,0 +1,245 @@
+Compressing large files to S3 compatible storages
+=============
+
+S3 compatible storages usually have a limitation of 5 GiB when using single upload. When generating larger zips, the
+solution is to use a `multi-part upload`_.
+
+.. _multi-part upload: `
+
+We can implement a `PSR-7 stream `_ that buffers ZipStream's output and uploads
+to S3 in chunks.
+
+MultipartUploadBufferStream example
+---------------
+
+.. code-block:: php
+    client->createMultipartUpload(
+                [
+                    'Bucket'       => $bucket,
+                    'Key'          => $destinationFileName,
+                    'StorageClass' => 'REDUCED_REDUNDANCY',
+                ]
+            );
+
+            $this->uploadId = $result['UploadId'];
+
+            $this->parts['Parts'] = [];
+        }
+
+        public function write($string): int
+        {
+            $chunkSize           = strlen($string);
+
+            $this->buffer     .= $string;
+            $this->bufferSize += $chunkSize;
+
+            if ($this->bufferSize >= self::PART_SIZE) {
+                $this->uploadPart();
+            }
+
+            return $chunkSize;
+        }
+
+        public function close(): void
+        {
+            // Upload remaining closing bytes from zip
+            $this->uploadPart();
+
+            $this->client->completeMultipartUpload([
+                'Bucket'          => $this->bucket,
+                'Key'             => $this->destinationFileName,
+                'UploadId'        => $this->uploadId,
+                'MultipartUpload' => $this->parts,
+            ]);
+
+            $this->buffer = '';
+            $this->bufferSize = 0;
+        }
+
+        private function uploadPart(): void
+        {
+            $result = $this->client->uploadPart([
+                'Bucket'     => $this->bucket,
+                'Key'        => $this->destinationFileName,
+                'UploadId'   => $this->uploadId,
+                'PartNumber' => $this->partNumber,
+                'Body'       => $this->buffer,
+            ]);
+
+            $this->buffer = '';
+            $this->bufferSize = 0;
+
+            $this->parts['Parts'][$this->partNumber] = [
+                'PartNumber' => $this->partNumber,
+                'ETag' => $result['ETag'],
+            ];
+
+            $this->partNumber++;
+
+            $result = null;
+            gc_collect_cycles(); // To avoid memory leaks. @see github.com/aws/aws-sdk-php/issues/1273
+        }
+
+        public function __toString(): string
+        {
+            return $this->getContents();
+        }
+
+        public function getContents(): string
+        {
+            $buffer = $this->buffer;
+            $this->buffer = '';
+            $this->bufferSize = 0;
+
+            return $buffer;
+        }
+
+        public function detach(): null
+        {
+            $this->close();
+
+            return null;
+        }
+
+        public function getSize(): int
+        {
+            return $this->bufferSize;
+        }
+
+        public function isReadable(): bool
+        {
+            return true;
+        }
+
+        public function isWritable(): bool
+        {
+            return true;
+        }
+
+        public function isSeekable(): bool
+        {
+            return false;
+        }
+
+        public function rewind(): void
+        {
+            $this->seek(0);
+        }
+
+        public function seek($offset, $whence = SEEK_SET): void
+        {
+            throw new \RuntimeException('Cannot seek a BufferStream');
+        }
+
+        public function eof(): bool
+        {
+            return $this->bufferSize === 0;
+        }
+
+        public function tell(): int
+        {
+            throw new \RuntimeException('Cannot determine the position of a BufferStream');
+        }
+
+        public function read($length): string
+        {
+            $currentLength = $this->bufferSize;
+
+            if ($length >= $currentLength) {
+                // No need to slice the buffer because we don't have enough data.
+                $result = $this->buffer;
+                $this->buffer = '';
+                $this->bufferSize = 0;
+            } else {
+                // Slice up the result to provide a subset of the buffer.
+                $result = substr($this->buffer, 0, $length);
+                $this->buffer = substr($this->buffer, $length);
+                $this->bufferSize -= $length;
+            }
+
+            return $result;
+        }
+
+        public function getMetadata($key = null)
+        {
+            return $key ? null : [];
+        }
+    }
+
+Then, we can use it with ZipStream to compress the files and upload the parts to the storage.
+
+ZipStream usage
+---------------
+
+.. code-block:: php
+     'your region',
+        'version' => 'latest',
+        'bucketName' => $bucket,
+        'credentials' => CredentialProvider::defaultProvider(),
+    ]);
+
+    $bufferStream = new MultipartUploadBufferStream(
+        'destination-file.zip',
+        $bucket,
+        $client,
+    );
+
+    $zip = new ZipStream(
+        outputStream: $destination,
+        defaultCompressionMethod: CompressionMethod::STORE,
+        defaultEnableZeroHeader: true,
+        sendHttpHeaders: false,
+    );
+
+    $zip->addFile(
+        fileName: 'big-file-1.txt',
+        data: 'File1 data',
+    );
+    $zip->addFile(
+        fileName: 'big-file-2.txt',
+        data: 'File2 data',
+    );
+
+    $zip->finish();
+    $destination->close(); // Needed after $zip->finish() to upload the last remaining bytes to S3
+
+You can read more about the logic behind this implementation in the `discussion`_.
+
+.. _discussion: https://github.com/maennchen/ZipStream-PHP/discussions/402
diff --git a/vendor/maennchen/zipstream-php/guides/index.rst b/vendor/maennchen/zipstream-php/guides/index.rst
index 70211c0..8d429a4 100644
--- a/vendor/maennchen/zipstream-php/guides/index.rst
+++ b/vendor/maennchen/zipstream-php/guides/index.rst
@@ -16,6 +16,7 @@ user, which is much faster. It can work with S3 buckets or any PSR7 Stream.
    Nginx
    Varnish
    ContentLength
+   LargeFilesToS3
 
 Installation
 ---------------
diff --git a/vendor/maennchen/zipstream-php/src/File.php b/vendor/maennchen/zipstream-php/src/File.php
index 148fc55..d597e95 100644
--- a/vendor/maennchen/zipstream-php/src/File.php
+++ b/vendor/maennchen/zipstream-php/src/File.php
@@ -110,8 +110,8 @@ final class File
         $detectedSize = $forecastSize ?? ($this->compressedSize > 0 ? $this->compressedSize : null);
 
         if (
-            $this->isSimulation() &&
-            $detectedSize !== null
+            $this->isSimulation()
+            && $detectedSize !== null
         ) {
             $this->uncompressedSize = $detectedSize;
             $this->compressedSize = $detectedSize;
@@ -202,10 +202,10 @@ final class File
             compressionMethod: $this->compressionMethod,
             lastModificationDateTime: $this->lastModificationDateTime,
             crc32UncompressedData: $this->crc,
-            compressedSize: $zip64Enabled
+            compressedSize: ($forceEnableZip64 || $this->compressedSize > 0xFFFFFFFF)
                 ? 0xFFFFFFFF
                 : $this->compressedSize,
-            uncompressedSize: $zip64Enabled
+            uncompressedSize: ($forceEnableZip64 || $this->uncompressedSize > 0xFFFFFFFF)
                 ? 0xFFFFFFFF
                 : $this->uncompressedSize,
             fileName: $this->fileName,
@@ -238,8 +238,8 @@ final class File
         // Sets Bit 11: Language encoding flag (EFS).  If this bit is set,
         // the filename and comment fields for this file
         // MUST be encoded using UTF-8. (see APPENDIX D)
-        if (mb_check_encoding($this->fileName, 'UTF-8') &&
-                mb_check_encoding($this->comment, 'UTF-8')) {
+        if (mb_check_encoding($this->fileName, 'UTF-8')
+                && mb_check_encoding($this->comment, 'UTF-8')) {
             $this->generalPurposeBitFlag |= GeneralPurposeBitFlag::EFS;
         }
     }
@@ -321,9 +321,9 @@ final class File
         $deflate = $this->compressionInit();
 
         while (
-            !feof($this->unpackStream()) &&
-            ($this->maxSize === null || $this->uncompressedSize < $this->maxSize) &&
-            ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
+            !feof($this->unpackStream())
+            && ($this->maxSize === null || $this->uncompressedSize < $this->maxSize)
+            && ($this->exactSize === null || $this->uncompressedSize < $this->exactSize)
         ) {
             $readLength = min(
                 ($this->maxSize ?? PHP_INT_MAX) - $this->uncompressedSize,
diff --git a/vendor/maennchen/zipstream-php/src/Time.php b/vendor/maennchen/zipstream-php/src/Time.php
index 1b4121c..f8fd7b2 100644
--- a/vendor/maennchen/zipstream-php/src/Time.php
+++ b/vendor/maennchen/zipstream-php/src/Time.php
@@ -29,11 +29,11 @@ abstract class Time
         [$year, $month, $day, $hour, $minute, $second] = explode(' ', $dateTime->format('Y n j G i s'));
 
         return
-            ((int) $year << 25) |
-            ((int) $month << 21) |
-            ((int) $day << 16) |
-            ((int) $hour << 11) |
-            ((int) $minute << 5) |
-            ((int) $second >> 1);
+            ((int) $year << 25)
+            | ((int) $month << 21)
+            | ((int) $day << 16)
+            | ((int) $hour << 11)
+            | ((int) $minute << 5)
+            | ((int) $second >> 1);
     }
 }
diff --git a/vendor/maennchen/zipstream-php/src/Zip64/ExtendedInformationExtraField.php b/vendor/maennchen/zipstream-php/src/Zip64/ExtendedInformationExtraField.php
index aaac51c..e73a010 100644
--- a/vendor/maennchen/zipstream-php/src/Zip64/ExtendedInformationExtraField.php
+++ b/vendor/maennchen/zipstream-php/src/Zip64/ExtendedInformationExtraField.php
@@ -23,10 +23,10 @@ abstract class ExtendedInformationExtraField
             new PackField(format: 'v', value: self::TAG),
             new PackField(
                 format: 'v',
-                value: ($originalSize === null ? 0 : 8) +
-                    ($compressedSize === null ? 0 : 8) +
-                    ($relativeHeaderOffset === null ? 0 : 8) +
-                    ($diskStartNumber === null ? 0 : 4)
+                value: ($originalSize === null ? 0 : 8)
+                    + ($compressedSize === null ? 0 : 8)
+                    + ($relativeHeaderOffset === null ? 0 : 8)
+                    + ($diskStartNumber === null ? 0 : 4)
             ),
             ...($originalSize === null ? [] : [
                 new PackField(format: 'P', value: $originalSize),
diff --git a/vendor/maennchen/zipstream-php/src/ZipStream.php b/vendor/maennchen/zipstream-php/src/ZipStream.php
index d7b939d..c509a68 100644
--- a/vendor/maennchen/zipstream-php/src/ZipStream.php
+++ b/vendor/maennchen/zipstream-php/src/ZipStream.php
@@ -714,9 +714,9 @@ class ZipStream
         }
 
         // Add 64bit headers (if applicable)
-        if (count($this->centralDirectoryRecords) >= 0xFFFF ||
-            $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF ||
-            $sizeOfCentralDirectory > 0xFFFFFFFF) {
+        if (count($this->centralDirectoryRecords) >= 0xFFFF
+            || $centralDirectoryStartOffsetOnDisk > 0xFFFFFFFF
+            || $sizeOfCentralDirectory > 0xFFFFFFFF) {
             if (!$this->enableZip64) {
                 throw new OverflowException();
             }
diff --git a/vendor/maennchen/zipstream-php/test/CallbackOutputTest.php b/vendor/maennchen/zipstream-php/test/CallbackOutputTest.php
index 1f185fe..23e3a4a 100644
--- a/vendor/maennchen/zipstream-php/test/CallbackOutputTest.php
+++ b/vendor/maennchen/zipstream-php/test/CallbackOutputTest.php
@@ -24,7 +24,9 @@ final class CallbackOutputTest extends TestCase
         $buf = '';
         $zip = new ZipStream(
             outputStream: CallbackStreamWrapper::open(
-                static function (string $chunk) use (&$buf): void { $buf .= $chunk; }
+                static function (string $chunk) use (&$buf): void {
+                    $buf .= $chunk;
+                }
             ),
             sendHttpHeaders: false
         );
@@ -53,10 +55,14 @@ final class CallbackOutputTest extends TestCase
         $buf2 = '';
 
         $stream1 = CallbackStreamWrapper::open(
-            static function (string $chunk) use (&$buf1): void { $buf1 .= $chunk; }
+            static function (string $chunk) use (&$buf1): void {
+                $buf1 .= $chunk;
+            }
         );
         $stream2 = CallbackStreamWrapper::open(
-            static function (string $chunk) use (&$buf2): void { $buf2 .= $chunk; }
+            static function (string $chunk) use (&$buf2): void {
+                $buf2 .= $chunk;
+            }
         );
 
         $this->assertIsResource($stream1);
@@ -112,7 +118,8 @@ final class CallbackOutputTest extends TestCase
     public function testStreamPositionTracking(): void
     {
         $stream = CallbackStreamWrapper::open(
-            static function (string $chunk): void { /* no-op */ }
+            static function (string $chunk): void { // no-op
+            }
         );
 
         $this->assertSame(0, ftell($stream));
@@ -129,7 +136,8 @@ final class CallbackOutputTest extends TestCase
     public function testInvalidModeRejection(): void
     {
         $stream = CallbackStreamWrapper::open(
-            static function (string $chunk): void { /* no-op */ }
+            static function (string $chunk): void { // no-op
+            }
         );
 
         // Close the stream first
@@ -143,7 +151,8 @@ final class CallbackOutputTest extends TestCase
     public function testStreamStatistics(): void
     {
         $stream = CallbackStreamWrapper::open(
-            static function (string $chunk): void { /* no-op */ }
+            static function (string $chunk): void { // no-op
+            }
         );
 
         fwrite($stream, 'test data');
diff --git a/vendor/maennchen/zipstream-php/test/CentralDirectoryFileHeaderTest.php b/vendor/maennchen/zipstream-php/test/CentralDirectoryFileHeaderTest.php
index 5457b4f..3e9e385 100644
--- a/vendor/maennchen/zipstream-php/test/CentralDirectoryFileHeaderTest.php
+++ b/vendor/maennchen/zipstream-php/test/CentralDirectoryFileHeaderTest.php
@@ -35,26 +35,26 @@ class CentralDirectoryFileHeaderTest extends TestCase
 
         $this->assertSame(
             bin2hex($header),
-            '504b0102' . // 4 bytes; central file header signature
-            '0306' . // 2 bytes; version made by
-            '2d00' . // 2 bytes; version needed to extract
-            '2222' . // 2 bytes; general purpose bit flag
-            '0800' . // 2 bytes; compression method
-            '2008' . // 2 bytes; last mod file time
-            '2154' . // 2 bytes; last mod file date
-            '11111111' . // 4 bytes; crc-32
-            '77777777' . // 4 bytes; compressed size
-            '99999999' . // 4 bytes; uncompressed size
-            '0800' . // 2 bytes; file name length (n)
-            '0c00' . // 2 bytes; extra field length (m)
-            '0c00' . // 2 bytes; file comment length (o)
-            '0000' . // 2 bytes; disk number start
-            '0000' . // 2 bytes; internal file attributes
-            '20000000' . // 4 bytes; external file attributes
-            '34120000' . // 4 bytes; relative offset of local header
-            '746573742e706e67' . // n bytes; file name
-            '736f6d6520636f6e74656e74' . // m bytes; extra field
-            '736f6d6520636f6d6d656e74' // o bytes; file comment
+            '504b0102' // 4 bytes; central file header signature
+            . '0306' // 2 bytes; version made by
+            . '2d00' // 2 bytes; version needed to extract
+            . '2222' // 2 bytes; general purpose bit flag
+            . '0800' // 2 bytes; compression method
+            . '2008' // 2 bytes; last mod file time
+            . '2154' // 2 bytes; last mod file date
+            . '11111111' // 4 bytes; crc-32
+            . '77777777' // 4 bytes; compressed size
+            . '99999999' // 4 bytes; uncompressed size
+            . '0800' // 2 bytes; file name length (n)
+            . '0c00' // 2 bytes; extra field length (m)
+            . '0c00' // 2 bytes; file comment length (o)
+            . '0000' // 2 bytes; disk number start
+            . '0000' // 2 bytes; internal file attributes
+            . '20000000' // 4 bytes; external file attributes
+            . '34120000' // 4 bytes; relative offset of local header
+            . '746573742e706e67' // n bytes; file name
+            . '736f6d6520636f6e74656e74' // m bytes; extra field
+            . '736f6d6520636f6d6d656e74' // o bytes; file comment
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/DataDescriptorTest.php b/vendor/maennchen/zipstream-php/test/DataDescriptorTest.php
index cc886c7..349f87d 100644
--- a/vendor/maennchen/zipstream-php/test/DataDescriptorTest.php
+++ b/vendor/maennchen/zipstream-php/test/DataDescriptorTest.php
@@ -17,10 +17,10 @@ class DataDescriptorTest extends TestCase
                 compressedSize: 0x77777777,
                 uncompressedSize: 0x99999999,
             )),
-            '504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
-            '11111111' . // 4 bytes; CRC-32 of uncompressed data
-            '77777777' . // 4 bytes; Compressed size
-            '99999999' // 4 bytes; Uncompressed size
+            '504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
+            . '11111111' // 4 bytes; CRC-32 of uncompressed data
+            . '77777777' // 4 bytes; Compressed size
+            . '99999999' // 4 bytes; Uncompressed size
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/EndOfCentralDirectoryTest.php b/vendor/maennchen/zipstream-php/test/EndOfCentralDirectoryTest.php
index be0a907..0b808d8 100644
--- a/vendor/maennchen/zipstream-php/test/EndOfCentralDirectoryTest.php
+++ b/vendor/maennchen/zipstream-php/test/EndOfCentralDirectoryTest.php
@@ -21,15 +21,15 @@ class EndOfCentralDirectoryTest extends TestCase
                 centralDirectoryStartOffsetOnDisk: 0x33,
                 zipFileComment: 'foo',
             )),
-            '504b0506' . // 4 bytes; end of central dir signature 0x06054b50
-            '0000' . // 2 bytes; number of this disk
-            '0000' . // 2 bytes; number of the disk with the start of the central directory
-            '1000' . // 2 bytes; total number of entries in the central directory on this disk
-            '1000' . // 2 bytes; total number of entries in the central directory
-            '22000000' . // 4 bytes; size of the central directory
-            '33000000' . // 4 bytes; offset of start of central directory with respect to the starting disk number
-            '0300' . // 2 bytes; .ZIP file comment length
-            bin2hex('foo')
+            '504b0506' // 4 bytes; end of central dir signature 0x06054b50
+            . '0000' // 2 bytes; number of this disk
+            . '0000' // 2 bytes; number of the disk with the start of the central directory
+            . '1000' // 2 bytes; total number of entries in the central directory on this disk
+            . '1000' // 2 bytes; total number of entries in the central directory
+            . '22000000' // 4 bytes; size of the central directory
+            . '33000000' // 4 bytes; offset of start of central directory with respect to the starting disk number
+            . '0300' // 2 bytes; .ZIP file comment length
+            . bin2hex('foo')
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/EndlessCycleStream.php b/vendor/maennchen/zipstream-php/test/EndlessCycleStream.php
index d9e7df1..1020c02 100644
--- a/vendor/maennchen/zipstream-php/test/EndlessCycleStream.php
+++ b/vendor/maennchen/zipstream-php/test/EndlessCycleStream.php
@@ -97,7 +97,7 @@ class EndlessCycleStream implements StreamInterface
         throw new RuntimeException('Infinite Stream!');
     }
 
-    public function getMetadata(?string $key = null): array|null
+    public function getMetadata(?string $key = null): ?array
     {
         return $key !== null ? null : [];
     }
diff --git a/vendor/maennchen/zipstream-php/test/LocalFileHeaderTest.php b/vendor/maennchen/zipstream-php/test/LocalFileHeaderTest.php
index 196dd0f..6510e78 100644
--- a/vendor/maennchen/zipstream-php/test/LocalFileHeaderTest.php
+++ b/vendor/maennchen/zipstream-php/test/LocalFileHeaderTest.php
@@ -29,19 +29,19 @@ class LocalFileHeaderTest extends TestCase
 
         $this->assertSame(
             bin2hex((string) $header),
-            '504b0304' . // 4 bytes; Local file header signature
-            '2d00' . // 2 bytes; Version needed to extract (minimum)
-            '2222' . // 2 bytes; General purpose bit flag
-            '0800' . // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
-            '2008' . // 2 bytes; File last modification time
-            '2154' . // 2 bytes; File last modification date
-            '11111111' . // 4 bytes; CRC-32 of uncompressed data
-            '77777777' . // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
-            '99999999' . // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
-            '0800' . // 2 bytes; File name length (n)
-            '0c00' . // 2 bytes; Extra field length (m)
-            '746573742e706e67' . // n bytes; File name
-            '736f6d6520636f6e74656e74' // m bytes; Extra field
+            '504b0304' // 4 bytes; Local file header signature
+            . '2d00' // 2 bytes; Version needed to extract (minimum)
+            . '2222' // 2 bytes; General purpose bit flag
+            . '0800' // 2 bytes; Compression method; e.g. none = 0, DEFLATE = 8
+            . '2008' // 2 bytes; File last modification time
+            . '2154' // 2 bytes; File last modification date
+            . '11111111' // 4 bytes; CRC-32 of uncompressed data
+            . '77777777' // 4 bytes; Compressed size (or 0xffffffff for ZIP64)
+            . '99999999' // 4 bytes; Uncompressed size (or 0xffffffff for ZIP64)
+            . '0800' // 2 bytes; File name length (n)
+            . '0c00' // 2 bytes; Extra field length (m)
+            . '746573742e706e67' // n bytes; File name
+            . '736f6d6520636f6e74656e74' // m bytes; Extra field
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/Tempfile.php b/vendor/maennchen/zipstream-php/test/Tempfile.php
index 7ef9c61..f03ee6c 100644
--- a/vendor/maennchen/zipstream-php/test/Tempfile.php
+++ b/vendor/maennchen/zipstream-php/test/Tempfile.php
@@ -6,7 +6,7 @@ namespace ZipStream\Test;
 
 trait Tempfile
 {
-    protected string|null $tempfile;
+    protected ?string $tempfile;
 
     /**
      * @var resource
diff --git a/vendor/maennchen/zipstream-php/test/Zip64/DataDescriptorTest.php b/vendor/maennchen/zipstream-php/test/Zip64/DataDescriptorTest.php
index 49fb2cc..8864b85 100644
--- a/vendor/maennchen/zipstream-php/test/Zip64/DataDescriptorTest.php
+++ b/vendor/maennchen/zipstream-php/test/Zip64/DataDescriptorTest.php
@@ -19,10 +19,10 @@ class DataDescriptorTest extends TestCase
 
         $this->assertSame(
             bin2hex($descriptor),
-            '504b0708' . // 4 bytes; Optional data descriptor signature = 0x08074b50
-            '11111111' . // 4 bytes; CRC-32 of uncompressed data
-            '6666666677777777' . // 8 bytes; Compressed size
-            '8888888899999999' // 8 bytes; Uncompressed size
+            '504b0708' // 4 bytes; Optional data descriptor signature = 0x08074b50
+            . '11111111' // 4 bytes; CRC-32 of uncompressed data
+            . '6666666677777777' // 8 bytes; Compressed size
+            . '8888888899999999' // 8 bytes; Uncompressed size
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryLocatorTest.php b/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryLocatorTest.php
index 271a298..0f905db 100644
--- a/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryLocatorTest.php
+++ b/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryLocatorTest.php
@@ -19,10 +19,10 @@ class EndOfCentralDirectoryLocatorTest extends TestCase
 
         $this->assertSame(
             bin2hex($descriptor),
-            '504b0607' . // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
-            '11111111' . // 4 bytes; number of the disk with the start of the zip64 end of central directory
-            '3333333322222222' . // 28 bytes; relative offset of the zip64 end of central directory record
-            '44444444' // 4 bytes;total number of disks
+            '504b0607' // 4 bytes; zip64 end of central dir locator signature - 0x07064b50
+            . '11111111' // 4 bytes; number of the disk with the start of the zip64 end of central directory
+            . '3333333322222222' // 28 bytes; relative offset of the zip64 end of central directory record
+            . '44444444' // 4 bytes;total number of disks
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryTest.php b/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryTest.php
index b86fb17..ef046f9 100644
--- a/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryTest.php
+++ b/vendor/maennchen/zipstream-php/test/Zip64/EndOfCentralDirectoryTest.php
@@ -25,17 +25,17 @@ class EndOfCentralDirectoryTest extends TestCase
 
         $this->assertSame(
             bin2hex($descriptor),
-            '504b0606' . // 4 bytes;zip64 end of central dir signature - 0x06064b50
-            '2f00000000000000' . // 8 bytes; size of zip64 end of central directory record
-            '3333' . // 2 bytes; version made by
-            '4444' . // 2 bytes; version needed to extract
-            '55555555' . // 4 bytes; number of this disk
-            '66666666' . // 4 bytes; number of the disk with the start of the central directory
-            '8888888877777777' . // 8 bytes; total number of entries in the central directory on this disk
-            'aaaaaaaa99999999' . // 8 bytes; total number of entries in the central directory
-            'ccccccccbbbbbbbb' . // 8 bytes; size of the central directory
-            'eeeeeeeedddddddd' . // 8 bytes; offset of start of central directory with respect to the starting disk number
-            bin2hex('foo')
+            '504b0606' // 4 bytes;zip64 end of central dir signature - 0x06064b50
+            . '2f00000000000000' // 8 bytes; size of zip64 end of central directory record
+            . '3333' // 2 bytes; version made by
+            . '4444' // 2 bytes; version needed to extract
+            . '55555555' // 4 bytes; number of this disk
+            . '66666666' // 4 bytes; number of the disk with the start of the central directory
+            . '8888888877777777' // 8 bytes; total number of entries in the central directory on this disk
+            . 'aaaaaaaa99999999' // 8 bytes; total number of entries in the central directory
+            . 'ccccccccbbbbbbbb' // 8 bytes; size of the central directory
+            . 'eeeeeeeedddddddd' // 8 bytes; offset of start of central directory with respect to the starting disk number
+            . bin2hex('foo')
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/Zip64/ExtendedInformationExtraFieldTest.php b/vendor/maennchen/zipstream-php/test/Zip64/ExtendedInformationExtraFieldTest.php
index 904783d..4dde274 100644
--- a/vendor/maennchen/zipstream-php/test/Zip64/ExtendedInformationExtraFieldTest.php
+++ b/vendor/maennchen/zipstream-php/test/Zip64/ExtendedInformationExtraFieldTest.php
@@ -20,12 +20,12 @@ class ExtendedInformationExtraFieldTest extends TestCase
 
         $this->assertSame(
             bin2hex($extraField),
-            '0100' . // 2 bytes; Tag for this "extra" block type
-            '1c00' . // 2 bytes; Size of this "extra" block
-            '6666666677777777' . // 8 bytes; Original uncompressed file size
-            '8888888899999999' . // 8 bytes; Size of compressed data
-            '1111111122222222' . // 8 bytes; Offset of local header record
-            '33333333' // 4 bytes; Number of the disk on which this file starts
+            '0100' // 2 bytes; Tag for this "extra" block type
+            . '1c00' // 2 bytes; Size of this "extra" block
+            . '6666666677777777' // 8 bytes; Original uncompressed file size
+            . '8888888899999999' // 8 bytes; Size of compressed data
+            . '1111111122222222' // 8 bytes; Offset of local header record
+            . '33333333' // 4 bytes; Number of the disk on which this file starts
         );
     }
 
@@ -35,8 +35,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
 
         $this->assertSame(
             bin2hex($extraField),
-            '0100' . // 2 bytes; Tag for this "extra" block type
-            '0000' // 2 bytes; Size of this "extra" block
+            '0100' // 2 bytes; Tag for this "extra" block type
+            . '0000' // 2 bytes; Size of this "extra" block
         );
     }
 }
diff --git a/vendor/maennchen/zipstream-php/test/ZipStreamTest.php b/vendor/maennchen/zipstream-php/test/ZipStreamTest.php
index f4ead1d..421b59e 100644
--- a/vendor/maennchen/zipstream-php/test/ZipStreamTest.php
+++ b/vendor/maennchen/zipstream-php/test/ZipStreamTest.php
@@ -62,10 +62,10 @@ class ZipStreamTest extends TestCase
 
         $name = 'árvíztűrő tükörfúrógép.txt';
         $content = 'Sample String Data';
-        $comment =
-            'Filename has every special characters ' .
-            'from Hungarian language in lowercase. ' .
-            'In uppercase: ÁÍŰŐÜÖÚÓÉ';
+        $comment
+            = 'Filename has every special characters '
+            . 'from Hungarian language in lowercase. '
+            . 'In uppercase: ÁÍŰŐÜÖÚÓÉ';
 
         $zip->addFile(fileName: $name, data: $content, comment: $comment);
         $zip->finish();
@@ -327,11 +327,14 @@ class ZipStreamTest extends TestCase
             defaultEnableZeroHeader: false,
         );
 
-        if (file_exists('/dev/null')) {
-            $streamUnseekable = fopen('/dev/null', 'w+');
-        } elseif (file_exists('NUL')) {
-            $streamUnseekable = fopen('NUL', 'w+');
-        } else {
+        foreach (['cat', 'more'] as $cmd) {
+            $streamUnseekable = @popen($cmd, 'w');
+            if ($streamUnseekable !== false) {
+                break;
+            }
+        }
+
+        if ($streamUnseekable === false) {
             $this->markTestSkipped('Needs file /dev/null');
         }
 
@@ -546,12 +549,24 @@ class ZipStreamTest extends TestCase
             );
         }
 
+        $zip->addDirectory('mydir/');
+
         $zip->finish();
 
         $tmpDir = $this->validateAndExtractZip($this->tempfile);
 
         $files = $this->getRecursiveFileList($tmpDir);
         $this->assertSame(['sample0', 'sample1', 'sample2', 'sample3'], $files);
+
+        $this->assertFileDoesNotContain(
+            $this->tempfile,
+            PackField::pack(
+                new PackField(format: 'V', value: 0x00000000), // CRC-32 = 0 (directory)
+                new PackField(format: 'V', value: 0xFFFFFFFF), // compressedSize — bug sentinel
+                new PackField(format: 'V', value: 0xFFFFFFFF), // uncompressedSize — bug sentinel
+                new PackField(format: 'v', value: 6),          // filename length = strlen('mydir/')
+            )
+        );
     }
 
     #[Group('slow')]
diff --git a/vendor/maennchen/zipstream-php/test/Zs/ExtendedInformationExtraFieldTest.php b/vendor/maennchen/zipstream-php/test/Zs/ExtendedInformationExtraFieldTest.php
index 2b8dbed..36153a7 100644
--- a/vendor/maennchen/zipstream-php/test/Zs/ExtendedInformationExtraFieldTest.php
+++ b/vendor/maennchen/zipstream-php/test/Zs/ExtendedInformationExtraFieldTest.php
@@ -15,8 +15,8 @@ class ExtendedInformationExtraFieldTest extends TestCase
 
         $this->assertSame(
             bin2hex((string) $extraField),
-            '5356' . // 2 bytes; Tag for this "extra" block type
-            '0000' // 2 bytes; TODO: Document
+            '5356' // 2 bytes; Tag for this "extra" block type
+            . '0000' // 2 bytes; TODO: Document
         );
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/CHANGELOG.md b/vendor/phpoffice/phpspreadsheet/CHANGELOG.md
index 4e36a8d..049eefd 100644
--- a/vendor/phpoffice/phpspreadsheet/CHANGELOG.md
+++ b/vendor/phpoffice/phpspreadsheet/CHANGELOG.md
@@ -3,9 +3,13 @@
 All notable changes to this project will be documented in this file.
 
 The format is based on [Keep a Changelog](https://keepachangelog.com)
-and this project adheres to [Semantic Versioning](https://semver.org). Thia is always true of the master branch. Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
+and this project adheres to [Semantic Versioning](https://semver.org). This is always true of the master branch.
+Dropping support for EOL versions of PHP will not be considered
+a breaking change.
 
-## TBD - 5.3.0
+Some earlier branches remain supported and security fixes are applied to them; if the security fix represents a breaking change, it may have to be applied as a minor or patch version.
+
+## TBD - 5.11.0
 
 ### Added
 
@@ -31,6 +35,210 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 - Nothing yet.
 
+## 2026-09-15 - 5.10.0
+
+### Added
+
+- XLSX password-to-open encryption and decryption using Office Agile encryption. [Issue #3878](https://github.com/PHPOffice/PhpSpreadsheet/issues/3878) [PR #4975](https://github.com/PHPOffice/PhpSpreadsheet/pull/4975) [PR #4988](https://github.com/PHPOffice/PhpSpreadsheet/pull/4988)
+- Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941)
+- Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534)
+
+### Changed
+
+- Performance: avoid `Worksheet::getStyle()` on every `Cell::setValueExplicit()` unless quote-prefix must change. On a dense 40k-cell populate+save microbenchmark this cut wall time by ~5%. [PR #4958](https://github.com/PHPOffice/PhpSpreadsheet/pull/4958)
+- Infrastructure changes for documentation. [PR #4973](https://github.com/PHPOffice/PhpSpreadsheet/pull/4973)
+
+### Fixed
+
+- Security patches.
+- Xls Writer now creates conformant CFB/DIFAT metadata at FAT-sector boundaries. [Issue #4811](https://github.com/PHPOffice/PhpSpreadsheet/issues/4811) [PR #4983](https://github.com/PHPOffice/PhpSpreadsheet/pull/4983)
+- Ods Reader/Writer Drawings. [Issue #4809](https://github.com/PHPOffice/PhpSpreadsheet/issues/4809) [PR #4956](https://github.com/PHPOffice/PhpSpreadsheet/pull/4956)
+- Ods Writer no longer converts cell references or commas inside string literals, so `="THIS IS E1"` is written unchanged instead of as `="THIS IS [.E1]"`. [Issue #4454](https://github.com/PHPOffice/PhpSpreadsheet/issues/4454) [PR #4962](https://github.com/PHPOffice/PhpSpreadsheet/pull/4962)
+- Ods Reader/Writer slight improvement for Date styles. [PR #4960](https://github.com/PHPOffice/PhpSpreadsheet/pull/4960)
+- BETAINV/BETA.INV no longer abandons its search when the Beta CDF underflows to zero (wrong results for alpha above about 1080). [PR #4954](https://github.com/PHPOffice/PhpSpreadsheet/pull/4954)
+- GAMMA.INV, the GAMMA.DIST/CHISQ.DIST/F.DIST densities, and GAMMALN no longer fail or return wrong results for large shape parameters / degrees of freedom. [PR #4953](https://github.com/PHPOffice/PhpSpreadsheet/pull/4953)
+- GAMMAINV/GAMMA.INV no longer clamps upper-tail quantiles beyond alpha*beta*5. [PR #4946](https://github.com/PHPOffice/PhpSpreadsheet/pull/4946)
+- Correct incomplete gamma convergence for GAMMA.DIST/CHISQ.DIST family (wrong once the series argument reached ~32). [PR #4945](https://github.com/PHPOffice/PhpSpreadsheet/pull/4945)
+- Fix problem with VLOOKUP and whole-column ranges. [Issue #4969](https://github.com/PHPOffice/PhpSpreadsheet/issues/4969) [PR #4967](https://github.com/PHPOffice/PhpSpreadsheet/pull/4967)
+- Problem with Xlsx Writer and header-row table. [PR #4968](https://github.com/PHPOffice/PhpSpreadsheet/pull/4968)
+- Throw when Ods Reader encounters invalid Xml. [PR #4986](https://github.com/PHPOffice/PhpSpreadsheet/pull/4986)
+- Update Sheetname in Charts when Sheetname changes. [Issue #744](https://github.com/PHPOffice/PhpSpreadsheet/issues/744) [PR #4984](https://github.com/PHPOffice/PhpSpreadsheet/pull/4984)
+
+## 2026-07-12 - 5.9.0
+
+### Removed
+
+- **Drop support for PHP 8.1**, according to [our published guidelines](https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support). [PR #4907](https://github.com/PHPOffice/PhpSpreadsheet/pull/4907)
+
+### Added
+
+- Navigate Cell as Cursor. [Issue #863](https://github.com/PHPOffice/PhpSpreadsheet/issues/863) [PR #4909](https://github.com/PHPOffice/PhpSpreadsheet/pull/4909)
+- Chart DataTables. [Issue #413](https://github.com/PHPOffice/PhpSpreadsheet/issues/413) [PR #4911](https://github.com/PHPOffice/PhpSpreadsheet/pull/4911)
+- Permit Ignoring "Misleading Format" Tag. [PR #4914](https://github.com/PHPOffice/PhpSpreadsheet/pull/4914)
+
+### Changed
+
+- Restructure parsing logic in Reader/Xlsx. [PR #4830](https://github.com/PHPOffice/PhpSpreadsheet/pull/4830)
+
+### Fixed
+
+- Small improvement for Radar Charts. [Issue #661](https://github.com/PHPOffice/PhpSpreadsheet/issues/661) [PR #4908](https://github.com/PHPOffice/PhpSpreadsheet/pull/4908)
+- Allow Use of Multiple Ranges When Setting Styles. [Issue #411](https://github.com/PHPOffice/PhpSpreadsheet/issues/411) [PR #4910](https://github.com/PHPOffice/PhpSpreadsheet/pull/4910)
+- Small change to Writer Html. [Issue #434](https://github.com/PHPOffice/PhpSpreadsheet/issues/434) [PR #4912](https://github.com/PHPOffice/PhpSpreadsheet/pull/4912)
+- Avoid hard-coding some chart Xml attributes. [PR #4915](https://github.com/PHPOffice/PhpSpreadsheet/pull/4915)
+- Readers should directly access readFilter. [PR #4919](https://github.com/PHPOffice/PhpSpreadsheet/pull/4919)
+- Very minor changes to Worksheet, Reader/Xlsx, and Worksheet/AutoFilter. [Issue #4917](https://github.com/PHPOffice/PhpSpreadsheet/issues/4917) [PR #4926](https://github.com/PHPOffice/PhpSpreadsheet/pull/4926)
+- Consistent HighestRow/Column after row/columnDelete. [Issue #943](https://github.com/PHPOffice/PhpSpreadsheet/issues/943) [PR #4925](https://github.com/PHPOffice/PhpSpreadsheet/pull/4925)
+
+## 2026-06-06 - 5.8.0
+
+### Security Note
+
+- File::prohibitWrappers and Drawing::setPath now reject phar paths with extra leading slashes (e.g. phar:///…) that escaped the prior parse_url-based filter. No security exploit was possible even with the extra slashes. [PR #4876](https://github.com/PHPOffice/PhpSpreadsheet/pull/4876)
+
+### Added
+
+- Optional method to increase Calculation Engine's parsing speed. [PR #4829](https://github.com/PHPOffice/PhpSpreadsheet/pull/4829)
+- Html Writer/Reader new data attributes.  [PR #4858](https://github.com/PHPOffice/PhpSpreadsheet/pull/4858)
+- IReader2 interface extending IReader with listWorksheetInfo/Names. [Issue #4883](https://github.com/PHPOffice/PhpSpreadsheet/issues/4883) [PR #4886](https://github.com/PHPOffice/PhpSpreadsheet/pull/4886)
+
+### Changed
+
+- Helper/Handler is now internal and final. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
+
+### Fixed
+
+- Html Writer handle text colors a bit better. [PR #4855](https://github.com/PHPOffice/PhpSpreadsheet/pull/4855)
+- Html Writer apply rotation to images. [Issue #4875](https://github.com/PHPOffice/PhpSpreadsheet/issues/4875) [PR #4877](https://github.com/PHPOffice/PhpSpreadsheet/pull/4877)
+- Work around Php8.6 deprecation in mpdf/mpdf. [PR #4878](https://github.com/PHPOffice/PhpSpreadsheet/pull/4878)
+- Ability to reuse disconnected spreadsheet. [PR #4880](https://github.com/PHPOffice/PhpSpreadsheet/pull/4880)
+- Allow int parameter for getChartIndex. [PR #4896](https://github.com/PHPOffice/PhpSpreadsheet/pull/4896)
+
+## 2026-04-19 - 5.7.0
+
+### Fixed
+
+- Security patches.
+
+## 2026-04-09 - 5.6.0
+
+### Added
+
+- Make Reader/Csv Extendable, add new preferred Reader/CsvNoEscape class. [Issue #4836](https://github.com/PHPOffice/PhpSpreadsheet/issues/4836) [PR #4837](https://github.com/PHPOffice/PhpSpreadsheet/pull/4837) [PR #4845](https://github.com/PHPOffice/PhpSpreadsheet/pull/4845)
+- XLOOKUP function. [Issue #1453](https://github.com/PHPOffice/PhpSpreadsheet/issues/1453) [PR #4844](https://github.com/PHPOffice/PhpSpreadsheet/pull/4844)
+- Introduction of a benchmark test suite, independent of the default unit test suite. Users can use it as a template for experimenting and making decisions concerning performance. [PR #4824](https://github.com/PHPOffice/PhpSpreadsheet/pull/4824)
+
+### Deprecated
+
+- Collection/Cells::MAX_COLUMN_ID - use Cell/AddressRange::MAX_COLUMN_INT.
+- Writer/Xls/Worksheet constants MAX_XLS_COLUMN, MAX_XLS_COLUMN_STRING, MAX_XLS_ROW - use Cell/AddressRange MAX_COLUMN_INT_XLS, MAX_COLUMN_XLS, MAX_ROW_XLS
+
+### Fixed
+
+- Consistent handling of row and column limits. [PR #4820](https://github.com/PHPOffice/PhpSpreadsheet/pull/4820)
+- Problems with Html Conditional Formatting Colorscale. [Issue #4838](https://github.com/PHPOffice/PhpSpreadsheet/issues/4838) [PR #4839](https://github.com/PHPOffice/PhpSpreadsheet/pull/4839)
+- Fixed an issue where Date detection could misclassify invalid numeric values as dates. [PR #4841](https://github.com/PHPOffice/PhpSpreadsheet/pull/4841)
+- Ods Reader/Writer Integer Styles with Leading Zeros. [Issue #1606](https://github.com/PHPOffice/PhpSpreadsheet/issues/1606) [PR #4822](https://github.com/PHPOffice/PhpSpreadsheet/pull/4822)
+- Xlsx Writer Data URI for Images. [Issue #4823](https://github.com/PHPOffice/PhpSpreadsheet/issues/4823) [PR #4831](https://github.com/PHPOffice/PhpSpreadsheet/pull/4831)
+- Confusion Checking for Union Arguments. [Issue #4832](https://github.com/PHPOffice/PhpSpreadsheet/issues/4832) [PR #4835](https://github.com/PHPOffice/PhpSpreadsheet/pull/4835)
+- Reader Xlsx Hyperlink with Anchor. [Issue #4842](https://github.com/PHPOffice/PhpSpreadsheet/issues/4842) [PR #4843](https://github.com/PHPOffice/PhpSpreadsheet/pull/4843)
+
+## 2026-02-28 - 5.5.0
+
+### Added
+
+- Much improved handling of Styles by Ods Reader. The relevant changes are listed at the end of the "Fixed" section below.
+- Option to use OldCalculatedValue in ToArray and Relatives. [Issue #1810](https://github.com/PHPOffice/PhpSpreadsheet/issues/1810) [PR #4787](https://github.com/PHPOffice/PhpSpreadsheet/pull/4787)
+- Add checkbox style (Xlsx and Html). [PR #4781](https://github.com/PHPOffice/PhpSpreadsheet/pull/4781)
+- Option to whitelist external images. [PR #4793](https://github.com/PHPOffice/PhpSpreadsheet/pull/4793)
+- Writer/Html add ability to set line ending. [PR #4779](https://github.com/PHPOffice/PhpSpreadsheet/pull/4779)
+- Writer/Html optionally save formulas as data attributes. [PR #4783](https://github.com/PHPOffice/PhpSpreadsheet/pull/4783)
+- User-supplied headers and footers in PDF. [Issue #3159](https://github.com/PHPOffice/PhpSpreadsheet/issues/3159) [PR #4789](https://github.com/PHPOffice/PhpSpreadsheet/pull/4789)
+
+### Deprecated
+
+- Writer/Html constant BODY_LINE no longer makes sense with a configurable line ending. No replacement.
+- Calculation classes FormulaParser and FormulaToken are unused. No replacement.
+- Writer/Xls/Worksheet methods insertBitMap, positionImage, writeObjPicture, processBitmapGd, and processBitmap are unused. No replacement.
+
+### Fixed
+
+- Improve performance in value binders. [PR #4780](https://github.com/PHPOffice/PhpSpreadsheet/pull/4780)
+- Handle Unions as Function Arguments. [Issue #4656](https://github.com/PHPOffice/PhpSpreadsheet/issues/4656) [Issue #316](https://github.com/PHPOffice/PhpSpreadsheet/issues/316) [Issue #503](https://github.com/PHPOffice/PhpSpreadsheet/issues/503) [PR #4657](https://github.com/PHPOffice/PhpSpreadsheet/pull/4657)
+- Unexpected Behavior of CONCATENATE. [Issue #4061](https://github.com/PHPOffice/PhpSpreadsheet/issues/4061) [PR #4797](https://github.com/PHPOffice/PhpSpreadsheet/pull/4797)
+- Image Css size in millimeters. [Issue #4800](https://github.com/PHPOffice/PhpSpreadsheet/issues/4800) [PR #4801](https://github.com/PHPOffice/PhpSpreadsheet/pull/4801)
+- Ods Reader column misalignment. [Issue #4802](https://github.com/PHPOffice/PhpSpreadsheet/issues/4802) [PR #4803](https://github.com/PHPOffice/PhpSpreadsheet/pull/4803)
+- Ods improved handling of number formats. [Issue #3961](https://github.com/PHPOffice/PhpSpreadsheet/issues/3961) [Issue #4798](https://github.com/PHPOffice/PhpSpreadsheet/issues/4798) [PR #4806](https://github.com/PHPOffice/PhpSpreadsheet/pull/4806)
+- Ods Reader fonts and fills. [Issue #2622](https://github.com/PHPOffice/PhpSpreadsheet/issues/2622) [Issue #1191](https://github.com/PHPOffice/PhpSpreadsheet/issues/1191) [PR #4810](https://github.com/PHPOffice/PhpSpreadsheet/pull/4810)
+- Ods Reader alignment and cell protection. [PR #4813](https://github.com/PHPOffice/PhpSpreadsheet/pull/4813)
+- Ods Reader borders. [PR #4814](https://github.com/PHPOffice/PhpSpreadsheet/pull/4814)
+- Ods Reader column styles (partial). [PR #4815](https://github.com/PHPOffice/PhpSpreadsheet/pull/4815)
+
+## 2026-01-10 - 5.4.0
+
+### Added
+
+- Store image in cell (Xlsx only). [Issue #4014](https://github.com/PHPOffice/PhpSpreadsheet/issues/4014) [Issue #4034](https://github.com/PHPOffice/PhpSpreadsheet/issues/4034) [Issue #913](https://github.com/PHPOffice/PhpSpreadsheet/issues/913) [PR #4677](https://github.com/PHPOffice/PhpSpreadsheet/pull/4677)
+- Passthrough support (with some restrictions) for otherwise unsupported drawing elements. [Issue #4037](https://github.com/PHPOffice/PhpSpreadsheet/issues/4037) [Issue #4704](https://github.com/PHPOffice/PhpSpreadsheet/issues/4704) [PR #4712](https://github.com/PHPOffice/PhpSpreadsheet/pull/4712)
+- Set all locale variables at once in a threadsafe manner. [Issue #954](https://github.com/PHPOffice/PhpSpreadsheet/issues/954) [PR #4760](https://github.com/PHPOffice/PhpSpreadsheet/pull/4760)
+- Reader/Html add ability to suppress warning messages from loadhtml. [Issue #647](https://github.com/PHPOffice/PhpSpreadsheet/issues/647) [Issue #849](https://github.com/PHPOffice/PhpSpreadsheet/issues/849) [PR #4761](https://github.com/PHPOffice/PhpSpreadsheet/pull/4761)
+
+### Changed
+
+- Evaluation of WEBSERVICE no longer requires external client, but will use oldCalculatedValue unless the request is for a domain in a user-supplied whitelist. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
+
+### Moved
+
+- Code to merge cell base style with table and conditional styles moved from Html Writer to its own class. [Issue #1058](https://github.com/PHPOffice/PhpSpreadsheet/issues/1058) [PR #4763](https://github.com/PHPOffice/PhpSpreadsheet/pull/4763)
+
+### Deprecated
+
+- Settings methods setHttpClient, unsetHttpClient, getHttpClient, and getRequestFactory are no longer used. No replacement.
+- Reader/Html protected property dataArray, described as used only for testing, is not used for testing. No replacement.
+
+### Fixed
+
+- Slightly better support for escaped characters in Xlsx Reader/Writer. [Discussion #4724](https://github.com/PHPOffice/PhpSpreadsheet/discussions/4724) [PR #4726](https://github.com/PHPOffice/PhpSpreadsheet/pull/4726)
+- CODE/UNICODE and CHAR/UNICHAR. [PR #4727](https://github.com/PHPOffice/PhpSpreadsheet/pull/4727)
+- Minor changes to TextGrid. [PR #4735](https://github.com/PHPOffice/PhpSpreadsheet/pull/4735) [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
+- Single-character table names. [Issue #4739](https://github.com/PHPOffice/PhpSpreadsheet/issues/4739) [PR #4740](https://github.com/PHPOffice/PhpSpreadsheet/pull/4740)
+- Improvements to SORT and SORTBY. [PR #4743](https://github.com/PHPOffice/PhpSpreadsheet/pull/4743)
+- Coverage-related changes in Shared. [PR #4745](https://github.com/PHPOffice/PhpSpreadsheet/pull/4745)
+- ListWorksheetInfo improvements for Xlsx and Ods. [Issue #3255](https://github.com/PHPOffice/PhpSpreadsheet/issues/3255) [PR #4746](https://github.com/PHPOffice/PhpSpreadsheet/pull/4746)
+- Fix functions related to Student-T distribution. [Issue #4167](https://github.com/PHPOffice/PhpSpreadsheet/issues/4167) [PR #4748](https://github.com/PHPOffice/PhpSpreadsheet/pull/4748)
+- Fix drawing hyperlinks. [Issue #993](https://github.com/PHPOffice/PhpSpreadsheet/issues/993) [PR #4764](https://github.com/PHPOffice/PhpSpreadsheet/pull/4764)
+- Fix clone spreadsheet with defined names. [PR #4753](https://github.com/PHPOffice/PhpSpreadsheet/pull/4753)
+- Changes to WEBSERVICE. [PR #4751](https://github.com/PHPOffice/PhpSpreadsheet/pull/4751)
+- More consistent handling of unsupported functions. [Issue #606](https://github.com/PHPOffice/PhpSpreadsheet/issues/606) [PR #4772](https://github.com/PHPOffice/PhpSpreadsheet/pull/4772)
+- Chart shadow `kx` and `ky`. [PR #4770](https://github.com/PHPOffice/PhpSpreadsheet/pull/4770)
+- SUBTOTAL and hidden rows. [Issue #820](https://github.com/PHPOffice/PhpSpreadsheet/issues/820) [PR #4765](https://github.com/PHPOffice/PhpSpreadsheet/pull/4765)
+- Fix some hyperlink problems. [Issue #3889](https://github.com/PHPOffice/PhpSpreadsheet/issues/3889) [Issue #2464](https://github.com/PHPOffice/PhpSpreadsheet/issues/2464) [PR #4771](https://github.com/PHPOffice/PhpSpreadsheet/pull/4771)
+- Xls Writer and empty RichText. [Issue #918](https://github.com/PHPOffice/PhpSpreadsheet/issues/918) [PR #4769](https://github.com/PHPOffice/PhpSpreadsheet/pull/4769)
+- Strings that look like huge floating-point numbers. [Issue #4766](https://github.com/PHPOffice/PhpSpreadsheet/issues/4766) [PR #4768](https://github.com/PHPOffice/PhpSpreadsheet/pull/4768)
+- Rowspan in Html. [Issue #1319](https://github.com/PHPOffice/PhpSpreadsheet/issues/1319) [PR #4767](https://github.com/PHPOffice/PhpSpreadsheet/pull/4767)
+- Mpdf styling of multi-line strings. [Issue #4773](https://github.com/PHPOffice/PhpSpreadsheet/issues/4773) [PR #4775](https://github.com/PHPOffice/PhpSpreadsheet/pull/4775)
+
+## 2025-11-24 - 5.3.0
+
+### Added
+
+- Formal support for Php8.5.
+- Limited Printarea support for Html/Pdf. [Issue #3941](https://github.com/PHPOffice/PhpSpreadsheet/issues/3941) [PR #4711](https://github.com/PHPOffice/PhpSpreadsheet/pull/4711)
+- Implement missing `INFO` function. [PR #4709](https://github.com/PHPOffice/PhpSpreadsheet/pull/4709)
+- Implement missing `BAHTTEXT` function. [PR #4715](https://github.com/PHPOffice/PhpSpreadsheet/pull/4715)
+
+### Deprecated
+
+- $dataType, the second parameter of Cell::setValueExplicit, is currently optional. Omitting it is deprecated, and it will be required in a future release.
+
+### Fixed
+
+- Protected ranges and insert/delete rows/columns. [Issue #4695](https://github.com/PHPOffice/PhpSpreadsheet/issues/4695) [PR #4702](https://github.com/PHPOffice/PhpSpreadsheet/pull/4702)
+- Unexpected Exception in Php DateTime. [Issue #4696](https://github.com/PHPOffice/PhpSpreadsheet/issues/4696) [Issue #917](https://github.com/PHPOffice/PhpSpreadsheet/issues/917) [PR #4697](https://github.com/PHPOffice/PhpSpreadsheet/pull/4697)
+- Add missing Dutch translation to translation file. [PR #4707](https://github.com/PHPOffice/PhpSpreadsheet/pull/4707)
+- Fix lots of typos throughout codebase. [PR #4705](https://github.com/PHPOffice/PhpSpreadsheet/pull/4705)
+- Apply small code style improvements. [PR #4708](https://github.com/PHPOffice/PhpSpreadsheet/pull/4708)
+
 ## 2025-10-25 - 5.2.0
 
 ### Added
@@ -250,18 +458,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Worksheet::getProtectedCells - use getProtectedCellRanges instead.
 - Writer/Html::isMpdf property - use instanceof Mpdf instead.
 
-### Changed
-
-- Nothing yet.
-
-### Moved
-
-- Nothing yet.
-
-### Deprecated
-
-- Nothing yet.
-
 ### Fixed
 
 - Xls writer Parser Mishandling True/False Argument. [Issue #4331](https://github.com/PHPOffice/PhpSpreadsheet/issues/4331) [PR #4333](https://github.com/PHPOffice/PhpSpreadsheet/pull/4333)
@@ -314,22 +510,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 ## 2024-12-08 - 3.6.0
 
-### Added
-
-- Nothing yet.
-
-### Changed
-
-- Nothing yet.
-
-### Moved
-
-- Nothing yet.
-
-### Deprecated
-
-- Nothing yet.
-
 ### Fixed
 
 - Html Reader/Writer Better Handling of Booleans. [PR #4257](https://github.com/PHPOffice/PhpSpreadsheet/pull/4257)
@@ -342,18 +522,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 ## 2024-11-22 - 3.5.0
 
-### Added
-
-- Nothing yet.
-
 ### Changed
 
 - Settings::libXmlLoaderOptions is ignored. [PR #4233](https://github.com/PHPOffice/PhpSpreadsheet/pull/4233)
 
-### Moved
-
-- Nothing yet.
-
 ### Deprecated
 
 - Settings::setLibXmlLoaderOptions() and Settings::getLibXmlLoaderOptions() are no longer needed - no replacement.
@@ -386,10 +558,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Worksheet::getProtectedCells was deprecated in release 2, but was not properly documented, and not removed in release 3. Use getProtectedCellRanges instead.
 - Writer/Html::isMpdf property was deprecated in release 2, but was not properly documented, and not removed in release 3. Use instanceof Mpdf instead.
 
-### Moved
-
-- Nothing yet.
-
 ### Fixed
 
 - Xls Writer Condtional Rules Applied to Whole Rows or Columns. [Issue #3185](https://github.com/PHPOffice/PhpSpreadsheet/issues/3185) [PR #4152](https://github.com/PHPOffice/PhpSpreadsheet/pull/4152)
@@ -421,10 +589,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Currency and Accounting Wizards are changed to act like Excel, and a new CurrencyBase Wizard is added for for non-Excel formats. [Issue #4125](https://github.com/PHPOffice/PhpSpreadsheet/issues/4125) [Issue #4124](https://github.com/PHPOffice/PhpSpreadsheet/issues/4124) [PR #4127](https://github.com/PHPOffice/PhpSpreadsheet/pull/4127)
 - Images will not be added to spreadsheet if they cannot be validated as images.
 
-### Deprecated
-
-- Nothing yet.
-
 ### Removed
 
 - The following items were deprecated in release 2 and are now removed.
@@ -433,10 +597,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Calculation\Calculation::_translateFormulaToLocale (use method name translateFormulaToLocale without leading underscore).
 - Calculation\Calculation::_translateFormulaToEnglish (use method name translateFormulaToEnglish without leading underscore).
 
-### Moved
-
-- Nothing yet.
-
 ### Fixed
 
 - Several security patches.
@@ -446,22 +606,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 ## 2024-08-07 - 2.2.2
 
-### Added
-
-- Nothing yet.
-
-### Changed
-
-- Nothing yet.
-
-### Deprecated
-
-- Nothing yet.
-
-### Moved
-
-- Nothing yet.
-
 ### Fixed
 
 - Html Reader Preserve Unicode Whitespace. [Issue #1284](https://github.com/PHPOffice/PhpSpreadsheet/issues/1284) [PR #4106](https://github.com/PHPOffice/PhpSpreadsheet/pull/4106)
@@ -500,10 +644,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 - Writer\Xls\Style\ColorMap is no longer needed.
 
-### Moved
-
-- Nothing
-
 ### Fixed
 
 - Incorrect Reader CSV with BOM. [Issue #4028](https://github.com/PHPOffice/PhpSpreadsheet/issues/4028) [PR #4029](https://github.com/PHPOffice/PhpSpreadsheet/pull/4029)
@@ -538,18 +678,10 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Default Style Alignment Property (workaround for bug in non-Excel spreadsheet apps) [Issue #3918](https://github.com/PHPOffice/PhpSpreadsheet/issues/3918) [PR #3924](https://github.com/PHPOffice/PhpSpreadsheet/pull/3924)
 - Additional Support for Date/Time Styles [PR #3939](https://github.com/PHPOffice/PhpSpreadsheet/pull/3939)
 
-### Changed
-
-- Nothing
-
 ### Deprecated
 
 - Reader/Xml trySimpleXMLLoadString should not have had public visibility, and will be removed.
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - IF Empty Arguments. [Issue #3875](https://github.com/PHPOffice/PhpSpreadsheet/issues/3875) [Issue #2146](https://github.com/PHPOffice/PhpSpreadsheet/issues/2146) [PR #3879](https://github.com/PHPOffice/PhpSpreadsheet/pull/3879)
@@ -611,10 +743,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 - Functions `_translateFormulaToLocale` and `_translateFormulaEnglish` are replaced by versions without leading underscore. [PR #3828](https://github.com/PHPOffice/PhpSpreadsheet/pull/3828)
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Take advantage of mitoteam/jpgraph Extended mode to enable rendering of more graphs. [PR #3603](https://github.com/PHPOffice/PhpSpreadsheet/pull/3603)
@@ -682,14 +810,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Allow `CellRange` and `CellAddress` objects for the `range` argument in the `rangeToArray()` method. [PR #3494](https://github.com/PHPOffice/PhpSpreadsheet/pull/3494)
 - Stock charts will now read and reproduce `upDownBars` and subsidiary tags; these were previously ignored on read and hard-coded on write. [PR #3515](https://github.com/PHPOffice/PhpSpreadsheet/pull/3515)
 
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Updates Cell formula absolute ranges/references, and Defined Name absolute ranges/references when inserting/deleting rows/columns. [Issue #3368](https://github.com/PHPOffice/PhpSpreadsheet/issues/3368) [PR #3402](https://github.com/PHPOffice/PhpSpreadsheet/pull/3402)
@@ -747,10 +867,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 - Rationalisation of Pre-defined Currency Format Masks [PR #3377](https://github.com/PHPOffice/PhpSpreadsheet/pull/3377)
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Calculation Engine doesn't evaluate Defined Name when default cell A1 is quote-prefixed [Issue #3335](https://github.com/PHPOffice/PhpSpreadsheet/issues/3335) [PR #3336](https://github.com/PHPOffice/PhpSpreadsheet/pull/3336)
@@ -764,22 +880,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 ## 1.27.1 - 2023-02-08
 
-### Added
-
-- Nothing
-
-### Changed
-
-- Nothing
-
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fix Composer --dev dependency issue with dealerdirect/phpcodesniffer-composer-installer renaming their `master` branch to `main`
@@ -794,14 +894,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Support for Structured References in the Calculation Engine [PR #3261](https://github.com/PHPOffice/PhpSpreadsheet/pull/3261)
 - Limited Support for Form Controls [PR #3130](https://github.com/PHPOffice/PhpSpreadsheet/pull/3130) [Issue #2396](https://github.com/PHPOffice/PhpSpreadsheet/issues/2396) [Issue #1770](https://github.com/PHPOffice/PhpSpreadsheet/issues/1770) [Issue #2388](https://github.com/PHPOffice/PhpSpreadsheet/issues/2388) [Issue #2904](https://github.com/PHPOffice/PhpSpreadsheet/issues/2904) [Issue #2661](https://github.com/PHPOffice/PhpSpreadsheet/issues/2661)
 
-### Changed
-
-- Nothing
-
-### Deprecated
-
-- Nothing
-
 ### Removed
 
 - Shared/JAMA is removed. [PR #3260](https://github.com/PHPOffice/PhpSpreadsheet/pull/3260)
@@ -827,10 +919,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Xlsx Reader support for Pivot Tables [PR #2829](https://github.com/PHPOffice/PhpSpreadsheet/pull/2829)
 - Permit Date/Time Entered on Spreadsheet to be calculated as Float [Issue #1416](https://github.com/PHPOffice/PhpSpreadsheet/issues/1416) [PR #3121](https://github.com/PHPOffice/PhpSpreadsheet/pull/3121)
 
-### Changed
-
-- Nothing
-
 ### Deprecated
 
 - Direct update of Calculation::suppressFormulaErrors is replaced with setter.
@@ -838,10 +926,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - ExcelError public static variable errorCodes replaced with constant ERROR_CODES.
 - NumberFormat constant FORMAT_DATE_YYYYMMDD2 replaced with existing identical FORMAT_DATE_YYYYMMDD.
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fixed handling for `_xlws` prefixed functions from Office365 [Issue #3245](https://github.com/PHPOffice/PhpSpreadsheet/issues/3245) [PR #3247](https://github.com/PHPOffice/PhpSpreadsheet/pull/3247)
@@ -870,22 +954,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 
 ## 1.25.2 - 2022-09-25
 
-### Added
-
-- Nothing
-
-### Changed
-
-- Nothing
-
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Composer dependency clash with ezyang/htmlpurifier
@@ -918,10 +986,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Misspelled Properties::LINE_STYLE_DASH_SQUERE_DOT deprecated in favor of LINE_STYLE_DASH_SQUARE_DOT.
 - Clone not permitted for Spreadsheet. Spreadsheet->copy() can be used instead.
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fix update to defined names when inserting/deleting rows/columns [Issue #3076](https://github.com/PHPOffice/PhpSpreadsheet/issues/3076) [PR #3077](https://github.com/PHPOffice/PhpSpreadsheet/pull/3077)
@@ -950,18 +1014,6 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a
 - Support for SimpleCache Interface versions 1.0, 2.0 and 3.0
 - Add Chart Axis Option textRotation [Issue #2705](https://github.com/PHPOffice/PhpSpreadsheet/issues/2705) [PR #2940](https://github.com/PHPOffice/PhpSpreadsheet/pull/2940)
 
-### Changed
-
-- Nothing
-
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fix Encoding issue with Html reader (PHP 8.2 deprecation for mb_convert_encoding) [Issue #2942](https://github.com/PHPOffice/PhpSpreadsheet/issues/2942) [PR #2943](https://github.com/PHPOffice/PhpSpreadsheet/pull/2943)
@@ -1002,14 +1054,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
   See [the Discussion section on github](https://github.com/PHPOffice/PhpSpreadsheet/discussions/2821) for details of performance across versions
 - Improved performance for removing rows/columns from a worksheet
 
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Xls Reader resolving absolute named ranges to relative ranges [Issue #2826](https://github.com/PHPOffice/PhpSpreadsheet/issues/2826) [PR #2827](https://github.com/PHPOffice/PhpSpreadsheet/pull/2827)
@@ -1089,10 +1133,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
   This change provides more consistency in the methods (not every "by cell address" method has an equivalent "byColumnAndRow" method);
   and the "by cell address" methods often provide more flexibility, such as allowing a range of cells, or referencing them by passing the defined name of a named range as the argument.
 
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Make allowance for the AutoFilter dropdown icon in the first row of an Autofilter range when using Autosize columns. [Issue #2413](https://github.com/PHPOffice/PhpSpreadsheet/issues/2413) [PR #2754](https://github.com/PHPOffice/PhpSpreadsheet/pull/2754)
@@ -1160,14 +1200,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 - Extract some methods from the Calculation Engine into dedicated classes [#2537](https://github.com/PHPOffice/PhpSpreadsheet/issues/2537)
 - Eliminate calls to `flattenSingleValue()` that are no longer required when we're checking for array values as arguments [#2590](https://github.com/PHPOffice/PhpSpreadsheet/issues/2590)
 
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fixed `ReferenceHelper@insertNewBefore` behavior when removing column before last column with null value [PR #2541](https://github.com/PHPOffice/PhpSpreadsheet/pull/2541)
@@ -1202,14 +1234,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 
 - Xlsx Writer will evaluate AutoFilter only if it is as yet unevaluated, or has changed since it was last evaluated [PR #2414](https://github.com/PHPOffice/PhpSpreadsheet/pull/2414)
 
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Rounding in `NumberFormatter` [Issue #2385](https://github.com/PHPOffice/PhpSpreadsheet/issues/2385) [PR #2399](https://github.com/PHPOffice/PhpSpreadsheet/pull/2399)
@@ -1240,14 +1264,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 - Drop support for PHP 7.2, according to https://phpspreadsheet.readthedocs.io/en/latest/#php-version-support
 - Use native typing for objects that were already documented as such
 
-### Deprecated
-
-- Nothing
-
-### Removed
-
-- Nothing
-
 ### Fixed
 
 - Fixed null conversation for strToUpper [#2292](https://github.com/PHPOffice/PhpSpreadsheet/issues/2292)
@@ -1281,18 +1297,10 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 - Ability to stream to an Amazon S3 bucket [Issue #2249](https://github.com/PHPOffice/PhpSpreadsheet/issues/2249)
 - Provided a Size Helper class to validate size values (pt, px, em) [PR #1694](https://github.com/PHPOffice/PhpSpreadsheet/pull/1694)
 
-### Changed
-
-- Nothing.
-
 ### Deprecated
 
 - PHP 8.1 will deprecate auto_detect_line_endings. As a result of this change, Csv Reader using some release after PHP8.1 will no longer be able to handle a Csv with Mac line endings.
 
-### Removed
-
-- Nothing.
-
 ### Fixed
 
 - Unexpected format in Xlsx Timestamp [Issue #2331](https://github.com/PHPOffice/PhpSpreadsheet/issues/2331) [PR #2332](https://github.com/PHPOffice/PhpSpreadsheet/pull/2332)
@@ -1415,10 +1423,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 
   This makes the logic in these classes easier to maintain; and will reduce the memory footprint required to execute formulae when calling these functions.
 
-### Removed
-
-- Nothing.
-
 ### Fixed
 
 - Avoid Duplicate Titles When Reading Multiple HTML Files.[Issue #1823](https://github.com/PHPOffice/PhpSpreadsheet/issues/1823) [PR #1829](https://github.com/PHPOffice/PhpSpreadsheet/pull/1829)
@@ -1448,10 +1452,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 
 - All Excel Function implementations in `Calculation\Database`, `Calculation\DateTime`, `Calculation\Engineering`, `Calculation\Financial`, `Calculation\Logical`, `Calculation\LookupRef`, `Calculation\MathTrig`, `Calculation\Statistical`, `Calculation\TextData` and `Calculation\Web` have been moved to dedicated classes for individual functions or groups of related functions. See the docblocks against all the deprecated methods for details of the new methods to call instead. At some point, these old classes will be deleted.
 
-### Removed
-
-- Nothing.
-
 ### Fixed
 
 - Fixed issue with absolute path in worksheets' Target [PR #1769](https://github.com/PHPOffice/PhpSpreadsheet/pull/1769)
@@ -1511,10 +1511,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 - **IMPORTANT NOTE:** This Introduces a **BC break** in the handling of named ranges. Previously, a named range cell reference of `B2` would be treated identically to a named range cell reference of `$B2` or `B$2` or `$B$2` because the calculation engine treated then all as absolute references. These changes "fix" that, so the calculation engine now handles relative references in named ranges correctly.
   This change that resolves previously incorrect behaviour in the calculation may affect users who have dynamically defined named ranges using relative references when they should have used absolute references.
 
-### Removed
-
-- Nothing.
-
 ### Fixed
 
 - PrintArea causes exception [#1544](https://github.com/phpoffice/phpspreadsheet/pull/1544)
@@ -1525,10 +1521,6 @@ Note that this will be the last 1.x branch release before the 2.x release. We wi
 
 ## 1.14.1 - 2020-07-19
 
-### Added
-
-- nothing
-
 ### Fixed
 
 - WEBSERVICE is HTTP client agnostic and must be configured via `Settings::setHttpClient()` [#1562](https://github.com/PHPOffice/PhpSpreadsheet/issues/1562)
diff --git a/vendor/phpoffice/phpspreadsheet/CONTRIBUTING.md b/vendor/phpoffice/phpspreadsheet/CONTRIBUTING.md
index 6cbf9fb..a8d9237 100644
--- a/vendor/phpoffice/phpspreadsheet/CONTRIBUTING.md
+++ b/vendor/phpoffice/phpspreadsheet/CONTRIBUTING.md
@@ -43,7 +43,7 @@ This makes it easier to see exactly what is being tested when reviewing the PR.
 4. By default, Github removes markdown headings in the Release Notes. You can either edit to restore these, or, probably preferably, change the default comment character on your system - `git config core.commentChar ";"`.
 
 > **Note:** Tagged releases are made from the `master` branch. Only in an emergency should a tagged release be made from the `release` branch. (i.e. cherry-picked hot-fixes.) However, there are 4 branches which have been updated to apply security patches, and those may be tagged if future security updates are needed.
-- release1291 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
-- release210 (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
-- release222
-- release390
+- 1.30.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
+- 2.1.x (no further updates aside from security patches, including code changes needed for Php 8.5 compatibility)
+- 2.4.x (no further updates aside from security patches)
+- 3.10.x
diff --git a/vendor/phpoffice/phpspreadsheet/LICENSE b/vendor/phpoffice/phpspreadsheet/LICENSE
index 04a90f0..e4bc4a3 100644
--- a/vendor/phpoffice/phpspreadsheet/LICENSE
+++ b/vendor/phpoffice/phpspreadsheet/LICENSE
@@ -1,6 +1,6 @@
 MIT License
 
-Copyright (c) 2019-2025 PhpSpreadsheet Authors
+Copyright (c) 2019-2026 PhpSpreadsheet Authors
 
 Permission is hereby granted, free of charge, to any person obtaining a copy
 of this software and associated documentation files (the "Software"), to deal
diff --git a/vendor/phpoffice/phpspreadsheet/README.md b/vendor/phpoffice/phpspreadsheet/README.md
index 8286e3a..1a7488f 100644
--- a/vendor/phpoffice/phpspreadsheet/README.md
+++ b/vendor/phpoffice/phpspreadsheet/README.md
@@ -4,7 +4,7 @@
 [![Code Coverage](https://coveralls.io/repos/github/PHPOffice/PhpSpreadsheet/badge.svg?branch=master)](https://coveralls.io/github/PHPOffice/PhpSpreadsheet?branch=master)
 [![Total Downloads](https://img.shields.io/packagist/dt/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
 [![Latest Stable Version](https://img.shields.io/github/v/release/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
-[![License](https://img.shields.io/github/license/PHPOffice/PhpSpreadsheet)](https://packagist.org/packages/phpoffice/phpspreadsheet)
+[![License](https://poser.pugx.org/phpoffice/phpspreadsheet/license)](https://packagist.org/packages/phpoffice/phpspreadsheet)
 [![Join the chat at https://gitter.im/PHPOffice/PhpSpreadsheet](https://img.shields.io/badge/GITTER-join%20chat-green.svg)](https://gitter.im/PHPOffice/PhpSpreadsheet)
 
 PhpSpreadsheet is a library written in pure PHP and offers a set of classes that
@@ -16,8 +16,9 @@ This is the master branch, and is maintained for security and bug fixes.
 
 LTS: For maintained branches, support for PHP versions will only be maintained for a period of six months beyond the
 [end of life](https://www.php.net/supported-versions) of that PHP version.
+Dropping support for versions after that date will not be considered a breaking change.
 
-Currently the required PHP minimum version is PHP __8.1__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2026.
+Currently the required PHP minimum version is PHP __8.2__, and we [will support that version](https://www.php.net/supported-versions.php) until 30th June 2027.
 
 See the `composer.json` for other requirements.
 
diff --git a/vendor/phpoffice/phpspreadsheet/composer.json b/vendor/phpoffice/phpspreadsheet/composer.json
index d26157e..19cbcc0 100644
--- a/vendor/phpoffice/phpspreadsheet/composer.json
+++ b/vendor/phpoffice/phpspreadsheet/composer.json
@@ -13,7 +13,7 @@
     ],
     "config": {
         "platform": {
-            "php" : "8.1.99"
+            "php": "8.2.99"
         },
         "process-timeout": 600,
         "sort-packages": true,
@@ -42,6 +42,9 @@
         },
         {
             "name": "Adrien Crivelli"
+        },
+        {
+            "name": "Owen Leibman"
         }
     ],
     "scripts": {
@@ -66,10 +69,11 @@
         ]
     },
     "require": {
-        "php": "^8.1",
+        "php": "^8.2",
         "ext-ctype": "*",
         "ext-dom": "*",
         "ext-fileinfo": "*",
+        "ext-filter": "*",
         "ext-gd": "*",
         "ext-iconv": "*",
         "ext-libxml": "*",
@@ -84,11 +88,11 @@
         "maennchen/zipstream-php": "^2.1 || ^3.0",
         "markbaker/complex": "^3.0",
         "markbaker/matrix": "^3.0",
-        "psr/http-client": "^1.0",
-        "psr/http-factory": "^1.0",
         "psr/simple-cache": "^1.0 || ^2.0 || ^3.0"
     },
     "require-dev": {
+        "ext-intl": "*",
+        "ext-openssl": "*",
         "dealerdirect/phpcodesniffer-composer-installer": "dev-main",
         "dompdf/dompdf": "^2.0 || ^3.0",
         "friendsofphp/php-cs-fixer": "^3.2",
@@ -98,12 +102,13 @@
         "phpstan/phpstan": "^1.1 || ^2.0",
         "phpstan/phpstan-deprecation-rules": "^1.0 || ^2.0",
         "phpstan/phpstan-phpunit": "^1.0 || ^2.0",
-        "phpunit/phpunit": "^10.5",
+        "phpunit/phpunit": "^10.5 || ^11.0",
         "squizlabs/php_codesniffer": "^3.7",
-        "tecnickcom/tcpdf": "^6.5"
+        "tecnickcom/tcpdf": ">=6.8.0 <7.0.0"
     },
     "suggest": {
-        "ext-intl": "PHP Internationalization Functions, regquired for NumberFormat Wizard",
+        "ext-intl": "PHP Internationalization Functions, required for NumberFormat Wizard and StringHelper::setLocale()",
+        "ext-openssl": "Handline Agile-encrypted Xlsx spreadsheets",
         "mpdf/mpdf": "Option for rendering PDF with PDF Writer",
         "dompdf/dompdf": "Option for rendering PDF with PDF Writer",
         "tecnickcom/tcpdf": "Option for rendering PDF with PDF Writer",
@@ -117,7 +122,8 @@
     "autoload-dev": {
         "psr-4": {
             "PhpOffice\\PhpSpreadsheetTests\\": "tests/PhpSpreadsheetTests",
-            "PhpOffice\\PhpSpreadsheetInfra\\": "infra"
+            "PhpOffice\\PhpSpreadsheetInfra\\": "infra",
+            "PhpOffice\\PhpSpreadsheetBenchmarks\\": "tests/Benchmark"
         }
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/BinaryComparison.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/BinaryComparison.php
index 1f69794..38c298f 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/BinaryComparison.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/BinaryComparison.php
@@ -2,6 +2,7 @@
 
 namespace PhpOffice\PhpSpreadsheet\Calculation;
 
+use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
 use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
 class BinaryComparison
@@ -41,17 +42,25 @@ class BinaryComparison
         return strcmp($str1, $str2);
     }
 
-    public static function compare(mixed $operand1, mixed $operand2, string $operator): bool
+    public static function compare(mixed $operand1, mixed $operand2, string $operator): bool|string
     {
         //    Simple validate the two operands if they are string values
         if (is_string($operand1) && $operand1 > '' && $operand1[0] == Calculation::FORMULA_STRING_QUOTE) {
             $operand1 = Calculation::unwrapResult($operand1);
         }
+        if (ErrorValue::isError($operand1, true)) {
+            /** @var string $operand1 */
+            return $operand1;
+        }
         if (is_string($operand2) && $operand2 > '' && $operand2[0] == Calculation::FORMULA_STRING_QUOTE) {
             $operand2 = Calculation::unwrapResult($operand2);
         }
+        if (ErrorValue::isError($operand2, true)) {
+            /** @var string $operand2 */
+            return $operand2;
+        }
 
-        // Use case insensitive comparaison if not OpenOffice mode
+        // Use case-insensitive comparison if not OpenOffice mode
         if (Functions::getCompatibilityMode() != Functions::COMPATIBILITY_OPENOFFICE) {
             if (is_string($operand1)) {
                 $operand1 = StringHelper::strToUpper($operand1);
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Calculation.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Calculation.php
index abdd4c8..faaf440 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Calculation.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Calculation.php
@@ -2,6 +2,7 @@
 
 namespace PhpOffice\PhpSpreadsheet\Calculation;
 
+use Composer\Pcre\Preg; // many pregs in this program use u modifier, which has side-effects which make it unsuitable for this
 use PhpOffice\PhpSpreadsheet\Calculation\Engine\BranchPruner;
 use PhpOffice\PhpSpreadsheet\Calculation\Engine\CyclicReferenceStack;
 use PhpOffice\PhpSpreadsheet\Calculation\Engine\Logger;
@@ -13,6 +14,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell;
 use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
 use PhpOffice\PhpSpreadsheet\Cell\DataType;
 use PhpOffice\PhpSpreadsheet\DefinedName;
+use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
 use PhpOffice\PhpSpreadsheet\NamedRange;
 use PhpOffice\PhpSpreadsheet\ReferenceHelper;
 use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
@@ -36,7 +38,7 @@ class Calculation extends CalculationLocale
     const CALCULATION_REGEXP_OPENBRACE = '\(';
     //    Function (allow for the old @ symbol that could be used to prefix a function, but we'll ignore it)
     const CALCULATION_REGEXP_FUNCTION = '@?(?:_xlfn\.)?(?:_xlws\.)?((?:__xludf\.)?[\p{L}][\p{L}\p{N}\.]*)[\s]*\(';
-    //    Cell reference (cell or range of cells, with or without a sheet reference)
+    //    Cell reference, with or without a sheet reference)
     const CALCULATION_REGEXP_CELLREF = '((([^\s,!&%^\/\*\+<>=:`-]*)|(\'(?:[^\']|\'[^!])+?\')|(\"(?:[^\"]|\"[^!])+?\"))!)?\$?\b([a-z]{1,3})\$?(\d{1,7})(?![\w.])';
     // Used only to detect spill operator #
     const CALCULATION_REGEXP_CELLREF_SPILL = '/' . self::CALCULATION_REGEXP_CELLREF . '#/i';
@@ -88,9 +90,22 @@ class Calculation extends CalculationLocale
      */
     private bool $calculationCacheEnabled = true;
 
+    /**
+     * Maximum number of entries in the formula token cache.
+     * Default 0 (disabled). Set via setFormulaTokenCacheMaxSize() to enable.
+     */
+    private int $formulaTokenCacheMaxSize = 0;
+
+    /**
+     * Cache of parsed formula tokens, keyed by the raw formula string.
+     *
+     * @var array|bool>
+     */
+    private array $formulaTokenCache = [];
+
     private BranchPruner $branchPruner;
 
-    private bool $branchPruningEnabled = true;
+    protected bool $branchPruningEnabled = true;
 
     /**
      * List of operators that can be used within formulae
@@ -239,6 +254,7 @@ class Calculation extends CalculationLocale
     {
         $this->clearCalculationCache();
         $this->branchPruner->clearBranchStore();
+        $this->formulaTokenCache = [];
     }
 
     /**
@@ -364,6 +380,44 @@ class Calculation extends CalculationLocale
         $this->calculationCache = [];
     }
 
+    /**
+     * Clear the formula token cache.
+     */
+    public function clearFormulaTokenCache(): void
+    {
+        $this->formulaTokenCache = [];
+    }
+
+    /**
+     * Get the current number of entries in the formula token cache.
+     */
+    public function getFormulaTokenCacheSize(): int
+    {
+        return count($this->formulaTokenCache);
+    }
+
+    /**
+     * Set the maximum number of entries in the formula token cache.
+     * Set to 0 to disable caching (default), or a positive integer to enable.
+     */
+    public function setFormulaTokenCacheMaxSize(int $size): self
+    {
+        $this->formulaTokenCacheMaxSize = max(0, $size);
+        if ($this->formulaTokenCacheMaxSize === 0) {
+            $this->formulaTokenCache = [];
+        }
+
+        return $this;
+    }
+
+    /**
+     * Get the maximum number of entries allowed in the formula token cache.
+     */
+    public function getFormulaTokenCacheMaxSize(): int
+    {
+        return $this->formulaTokenCacheMaxSize;
+    }
+
     /**
      * Clear calculation cache for a specified worksheet.
      */
@@ -415,7 +469,7 @@ class Calculation extends CalculationLocale
     {
         if (is_string($value)) {
             //    Error values cannot be "wrapped"
-            if (preg_match('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
+            if (Preg::isMatch('/^' . self::CALCULATION_REGEXP_ERROR . '$/i', $value, $match)) {
                 //    Return Excel errors "as is"
                 return $value;
             }
@@ -494,13 +548,13 @@ class Calculation extends CalculationLocale
         try {
             $value = $cell->getValue();
             if (is_string($value) && $cell->getDataType() === DataType::TYPE_FORMULA) {
-                $value = preg_replace_callback(
+                $value = Preg::replaceCallback(
                     self::CALCULATION_REGEXP_CELLREF_SPILL,
                     fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
                     $value
                 );
             }
-            $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore-line
+            $result = self::unwrapResult($this->_calculateFormulaValue($value, $cell->getCoordinate(), $cell)); //* @phpstan-ignore argument.type ($value can be mixed not string)
             if ($this->spreadsheet === null) {
                 throw new Exception('null spreadsheet in calculateCellValue');
             }
@@ -520,7 +574,7 @@ class Calculation extends CalculationLocale
                 $cellAddress = array_pop($this->cellStack);
             }
             if ($this->spreadsheet !== null && is_array($cellAddress) && array_key_exists('sheet', $cellAddress)) {
-                $sheetName = $cellAddress['sheet'] ?? null;
+                $sheetName = $cellAddress['sheet'];
                 $testSheet = is_string($sheetName) ? $this->spreadsheet->getSheetByName($sheetName) : null;
                 if ($testSheet !== null && array_key_exists('cell', $cellAddress)) {
                     /** @var array{cell: string} $cellAddress */
@@ -557,11 +611,17 @@ class Calculation extends CalculationLocale
      */
     public function parseFormula(string $formula): array|bool
     {
-        $formula = preg_replace_callback(
+        // Check the formula token cache first (only when caching is enabled)
+        if ($this->formulaTokenCacheMaxSize > 0 && isset($this->formulaTokenCache[$formula])) {
+            return $this->formulaTokenCache[$formula];
+        }
+
+        $originalFormula = $formula;
+        $formula = Preg::replaceCallback(
             self::CALCULATION_REGEXP_CELLREF_SPILL,
             fn (array $matches) => 'ANCHORARRAY(' . substr($matches[0], 0, -1) . ')',
             $formula
-        ) ?? $formula;
+        );
         //    Basic validation that this is indeed a formula
         //    We return an empty array if not
         $formula = trim($formula);
@@ -574,7 +634,21 @@ class Calculation extends CalculationLocale
         }
 
         //    Parse the formula and return the token stack
-        return $this->internalParseFormula($formula);
+        $result = $this->internalParseFormula($formula);
+
+        // Cache the result when caching is enabled (clear cache if it exceeds the maximum size)
+        if ($this->formulaTokenCacheMaxSize > 0) {
+            // Phpstan says if condition is always false,
+            // but coverage report says next statement is covered.
+            if (count($this->formulaTokenCache) >= $this->formulaTokenCacheMaxSize) {
+                $this->formulaTokenCache = [];
+            }
+            // Cache key is the original formula string (before ANCHORARRAY transformation)
+            // to ensure consistent lookup regardless of internal transformations.
+            $this->formulaTokenCache[$originalFormula] = $result;
+        }
+
+        return $result;
     }
 
     /**
@@ -657,8 +731,9 @@ class Calculation extends CalculationLocale
             return self::wrapResult((string) $formula);
         }
 
+        // https://www.reddit.com/r/excel/comments/chr41y/cmd_formula_stopped_working_since_last_update/
         if (preg_match('/^=\s*cmd\s*\|/miu', $formula) !== 0) {
-            return self::wrapResult($formula);
+            return ExcelError::REF(); // returns #BLOCKED in newer versions
         }
 
         //    Basic validation that this is indeed a formula
@@ -914,13 +989,14 @@ class Calculation extends CalculationLocale
                 $pad = $rpad = ', ';
                 foreach ($value as $row) {
                     if (is_array($row)) {
-                        $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row));
+                        $returnMatrix[] = implode($pad, array_map([$this, 'showValue'], $row)); // @phpstan-ignore argument.type (array_map can theoretically return array not array)
                         $rpad = '; ';
                     } else {
                         $returnMatrix[] = $this->showValue($row);
                     }
                 }
 
+                /** @var string[] $returnMatrix */
                 return '{ ' . implode($rpad, $returnMatrix) . ' }';
             } elseif (is_string($value) && (trim($value, self::FORMULA_STRING_QUOTE) == $value)) {
                 return self::FORMULA_STRING_QUOTE . $value . self::FORMULA_STRING_QUOTE;
@@ -1052,6 +1128,50 @@ class Calculation extends CalculationLocale
         '>' => 0, '<' => 0, '=' => 0, '>=' => 0, '<=' => 0, '<>' => 0, //    Comparison
     ];
 
+    /** @param array $matches */
+    private function unionForComma(array $matches): string
+    {
+        $matches5 = (string) $matches[5];
+        // Weirdly, the regexp which get us here for issue 4832
+        // only gets us here for Php8.4+. 8.4 introduced
+        // major changes for PCRE, but I cannot identify
+        // the exact change which caused this discrepancy.
+        // I do plan to update coverage to 8.4 at some point,
+        // and I can remove the coverage annotations after that.
+        // @codeCoverageIgnoreStart
+        if (str_contains($matches5, '(') && !str_contains($matches5, ')')) {
+            if ($this->spreadsheet !== null) {
+                if ($this->spreadsheet->getSheetByName($matches5) === null) {
+                    $matches0 = (string) $matches[0];
+                    $this->debugLog->writeDebugLog('Not Reformulating %s', $matches0);
+
+                    return $matches0;
+                }
+            }
+        }
+        // @codeCoverageIgnoreEnd
+        $matches1 = (string) $matches[1];
+        $matches2 = (string) $matches[2];
+
+        return $matches1 . str_replace(',', '∪', $matches2);
+    }
+
+    private const CELL_OR_CELLRANGE_OR_DEFINED_NAME
+        = '(?:'
+        . self::CALCULATION_REGEXP_CELLREF // cell address
+        . '(?::' . self::CALCULATION_REGEXP_CELLREF . ')?' // optional range address, non-capturing
+        . '|' . self::CALCULATION_REGEXP_DEFINEDNAME
+        . ')'
+        ;
+
+    public const UNIONABLE_COMMAS = '/((?:[,(]|^)\s*)' // comma or open paren or start of string, followed by optional whitespace
+        . '([(]' // open paren
+        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
+        . '(?:\s*,\s*' // optioonal whitespace, comma, optional whitespace, non-capturing
+        . self::CELL_OR_CELLRANGE_OR_DEFINED_NAME // cell address
+        . ')+' // one or more occurrences
+        . '\s*[)])/i'; // optional whitespace, end paren
+
     /**
      * @return array|false
      */
@@ -1060,6 +1180,12 @@ class Calculation extends CalculationLocale
         if (($formula = $this->convertMatrixReferences(trim($formula))) === false) {
             return false;
         }
+
+        $oldFormula = $formula;
+        $formula = Preg::replaceCallback(self::UNIONABLE_COMMAS, $this->unionForComma(...), $formula);
+        if ($oldFormula !== $formula) {
+            $this->debugLog->writeDebugLog('Reformulated as %s', $formula);
+        }
         $phpSpreadsheetFunctions = &self::getFunctionsAddress();
 
         //    If we're using cell caching, then $pCell may well be flushed back to the cache (which detaches the parent worksheet),
@@ -1179,7 +1305,7 @@ class Calculation extends CalculationLocale
                             }
                         }
                     } elseif (is_string($expectedArgumentCount) && $expectedArgumentCount !== '*') {
-                        if (1 !== preg_match('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
+                        if (!Preg::isMatch('/(\d*)([-+,])(\d*)/', $expectedArgumentCount, $argMatch)) {
                             $argMatch = ['', '', '', ''];
                         }
                         switch ($argMatch[2]) {
@@ -1236,16 +1362,14 @@ class Calculation extends CalculationLocale
                     //     because at least the braces are paired up (at this stage in the formula)
                     // MS Excel allows this if the content is cell references; but doesn't allow actual values,
                     //    but at this point, we can't differentiate (so allow both)
-                    return $this->raiseFormulaError('Formula Error: Unexpected ,');
-                    /* The following code may be a better choice, but, with
-                       the other changes for this PR, I can no longer come up
-                       with a test case that gets here
+                    //return $this->raiseFormulaError('Formula Error: Unexpected ,');
+
                     $stack->push('Binary Operator', '∪');
 
                     ++$index;
                     $expectingOperator = false;
 
-                    continue;*/
+                    continue;
                 }
 
                 /** @var array $d */
@@ -1267,11 +1391,12 @@ class Calculation extends CalculationLocale
                 // do we now have a function/variable/number?
                 $expectingOperator = true;
                 $expectingOperand = false;
-                $val = $match[1] ?? ''; //* @phpstan-ignore-line
+                $val = $match[1] ?? '';
                 $length = strlen($val);
 
                 if (preg_match('/^' . self::CALCULATION_REGEXP_FUNCTION . '$/miu', $val, $matches)) {
-                    $val = (string) preg_replace('/\s/u', '', $val);
+                    // $val is known to be valid unicode from statement above, so Preg::replace is okay even with u modifier
+                    $val = Preg::replace('/\s/u', '', $val);
                     if (isset($phpSpreadsheetFunctions[strtoupper($matches[1])]) || isset(self::$controlFunctions[strtoupper($matches[1])])) {    // it's a function
                         $valToUpper = strtoupper($val);
                     } else {
@@ -1410,7 +1535,7 @@ class Calculation extends CalculationLocale
                                 $refSheet = $pCellParent->getParentOrThrow()->getSheetByName($rangeSheetRef);
                             }
 
-                            if (ctype_digit($val) && $val <= 1048576) {
+                            if (ctype_digit($val) && $val <= AddressRange::MAX_ROW) {
                                 //    Row range
                                 $stackItemType = 'Row Reference';
                                 $valx = $val;
@@ -1419,7 +1544,7 @@ class Calculation extends CalculationLocale
                             } elseif (ctype_alpha($val) && strlen($val) <= 3) {
                                 //    Column range
                                 $stackItemType = 'Column Reference';
-                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow($val) : AddressRange::MAX_ROW; //    Max 1,048,576 rows for Excel2007
+                                $endRowColRef = ($refSheet !== null) ? $refSheet->getHighestDataRow() : AddressRange::MAX_ROW; //    Max 1,048,576 rows for Excel2007
                                 $val = "{$rangeWS2}{$val}{$endRowColRef}";
                             }
                             $stackItemReference = $val;
@@ -1445,7 +1570,7 @@ class Calculation extends CalculationLocale
                         // unescape any apostrophes or double quotes in worksheet name
                         $val = str_replace(["''", '""'], ["'", '"'], $val);
                         $column = 'A';
-                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
+                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevop must be non-null), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
                             $column = $pCellParent->getHighestDataColumn($val);
                         }
                         $val = "{$rowRangeReference[2]}{$column}{$rowRangeReference[7]}";
@@ -1459,7 +1584,7 @@ class Calculation extends CalculationLocale
                         // unescape any apostrophes or double quotes in worksheet name
                         $val = str_replace(["''", '""'], ["'", '"'], $val);
                         $row = '1';
-                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) {
+                        if (($testPrevOp !== null && $testPrevOp['value'] === ':') && $pCellParent !== null) { // @phpstan-ignore booleanAnd.alwaysFalse (testPrevOp must be non-null?), booleanAnd.alwaysFalse (ditto), identical.alwaysFalse (ditto)
                             $row = $pCellParent->getHighestDataRow($val);
                         }
                         $val = "{$val}{$row}";
@@ -1610,7 +1735,7 @@ class Calculation extends CalculationLocale
         foreach ($tokens as $tokenIdx => $tokenData) {
             /** @var mixed[] $tokenData */
             $this->processingAnchorArray = false;
-            if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore-line
+            if ($tokenData['type'] === 'Cell Reference' && isset($tokens[$tokenIdx + 1]) && $tokens[$tokenIdx + 1]['type'] === 'Operand Count for Function ANCHORARRAY()') { //* @phpstan-ignore offsetAccess.nonOffsetAccessible ($tokens might be mixed not array)
                 $this->processingAnchorArray = true;
             }
             $token = $tokenData['value'];
@@ -1712,7 +1837,7 @@ class Calculation extends CalculationLocale
                         return $this->raiseFormulaError($e->getMessage(), $e->getCode(), $e);
                     }
                 }
-            } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore-line
+            } elseif (!is_numeric($token) && !is_object($token) && isset($token, self::BINARY_OPERATORS[$token])) { //* @phpstan-ignore offsetAccess.invalidOffset ($token is mixed)
                 // if the token is a binary operator, pop the top two values off the stack, do the operation, and push the result back on the stack
                 //    We must have two operands, error if we don't
                 $operand2Data = $stack->pop();
@@ -1830,7 +1955,7 @@ class Calculation extends CalculationLocale
                             if ($breakNeeded) {
                                 break;
                             }
-                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
+                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow);
                             if ($pCellParent !== null && $this->spreadsheet !== null) {
                                 $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($sheet1), false);
                             } else {
@@ -1901,7 +2026,7 @@ class Calculation extends CalculationLocale
                             } elseif (Information\ErrorValue::isError($operand2)) {
                                 $result = $operand2;
                             } else {
-                                $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore-line
+                                $result = str_replace('""', self::FORMULA_STRING_QUOTE, self::unwrapResult($operand1) . self::unwrapResult($operand2)); //* @phpstan-ignore binaryOp.invalid (unwrapresult can return mixed rather than string)
                                 $result = StringHelper::substring(
                                     $result,
                                     0,
@@ -1934,8 +2059,8 @@ class Calculation extends CalculationLocale
                             $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
                             $stack->push('Error', ExcelError::null(), null);
                         } else {
-                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore-line
-                                . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore-line
+                            $cellRef = Coordinate::stringFromColumnIndex(min($oCol) + 1) . min($oRow) . ':' // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
+                                . Coordinate::stringFromColumnIndex(max($oCol) + 1) . max($oRow); // @phpstan-ignore argument.type ($oCol or $oRow might be empty), argument.type (ditto)
                             $this->debugLog->writeDebugLog('Evaluation Result is %s', $this->showTypeDetails($cellIntersect));
                             $stack->push('Value', $cellIntersect, $cellRef);
                         }
@@ -1950,13 +2075,13 @@ class Calculation extends CalculationLocale
 
                         break;
                 }
-            } elseif (($token === '~') || ($token === '%')) {
+            } elseif (($token === '~') || ($token === '%')) { // @phpstan-ignore booleanOr.alwaysFalse (phpstan says token can't be plain string), identical.alwaysFalse (ditto), identical.alwaysFalse (ditto)
                 // if the token is a unary operator, pop one value off the stack, do the operation, and push it back on
                 if (($arg = $stack->pop()) === null) {
                     return $this->raiseFormulaError('Internal error - Operand value missing from stack');
                 }
                 $arg = $arg['value'];
-                if ($token === '~') {
+                if ($token === '~') { // @phpstan-ignore identical.alwaysFalse (phpstan says token can't be plain string)
                     $this->debugLog->writeDebugLog('Evaluating Negation of %s', $this->showValue($arg));
                     $multiplier = -1;
                 } else {
@@ -1988,20 +2113,27 @@ class Calculation extends CalculationLocale
                 } else {
                     $this->executeNumericBinaryOperation($multiplier, $arg, '*', $stack);
                 }
-            } elseif (preg_match('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
+            } elseif (Preg::isMatch('/^' . self::CALCULATION_REGEXP_CELLREF . '$/i', StringHelper::convertToString($token ?? ''), $matches)) {
                 $cellRef = null;
 
                 /* Phpstan says matches[8/9/10] is never set,
                    and code coverage report seems to confirm.
-                   Appease PhpStan for now;
-                   probably delete this block later.
+                   regex101.com confirms - only 7 capturing groups.
+                   My theory is that this code expected regexp to
+                   match cell *or* cellRange, but it does not
+                   match the latter. Retain the code for now in case
+                   we do want to add the range match later.
+                   Probably delete this block later.
+                   Until delete happens, turn code coverage off.
                 */
                 if (isset($matches[self::$matchIndex8])) {
+                    // @codeCoverageIgnoreStart
                     if ($cell === null) {
                         // We can't access the range, so return a REF error
                         $cellValue = ExcelError::REF();
                     } else {
                         $cellRef = $matches[6] . $matches[7] . ':' . $matches[self::$matchIndex9] . $matches[self::$matchIndex10];
+                        $matches[2] = (string) $matches[2];
                         if ($matches[2] > '') {
                             $matches[2] = trim($matches[2], "\"'");
                             if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
@@ -2026,12 +2158,14 @@ class Calculation extends CalculationLocale
                             $this->debugLog->writeDebugLog('Evaluation Result for cells %s is %s', $cellRef, $this->showTypeDetails($cellValue));
                         }
                     }
+                    // @codeCoverageIgnoreEnd
                 } else {
                     if ($cell === null) {
                         // We can't access the cell, so return a REF error
                         $cellValue = ExcelError::REF();
                     } else {
                         $cellRef = $matches[6] . $matches[7];
+                        $matches[2] = (string) $matches[2];
                         if ($matches[2] > '') {
                             $matches[2] = trim($matches[2], "\"'");
                             if ((str_contains($matches[2], '[')) || (str_contains($matches[2], ']'))) {
@@ -2042,7 +2176,11 @@ class Calculation extends CalculationLocale
                             if ($pCellParent !== null && $this->spreadsheet !== null) {
                                 $cellSheet = $this->spreadsheet->getSheetByName($matches[2]);
                                 if ($cellSheet && !$cellSheet->cellExists($cellRef)) {
-                                    $cellSheet->setCellValue($cellRef, null);
+                                    try {
+                                        $cellSheet->setCellValue($cellRef, null);
+                                    } catch (SpreadsheetException) {
+                                        // do nothing
+                                    }
                                 }
                                 if ($cellSheet && $cellSheet->cellExists($cellRef)) {
                                     $cellValue = $this->extractCellRange($cellRef, $this->spreadsheet->getSheetByName($matches[2]), false);
@@ -2073,7 +2211,7 @@ class Calculation extends CalculationLocale
                         $cellValue = array_shift($cellValue);
                     }
                     if (is_string($cellValue)) {
-                        $cellValue = preg_replace('/"/', '""', $cellValue);
+                        $cellValue = Preg::replace('/"/', '""', $cellValue);
                     }
                     $this->debugLog->writeDebugLog('Scalar Result for cell %s is %s', $cellRef, $this->showTypeDetails($cellValue));
                 }
@@ -2117,7 +2255,7 @@ class Calculation extends CalculationLocale
                         $a = $argCount - $i - 1;
                         if (
                             ($passByReference)
-                            && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore-line
+                            && (isset($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])) //* @phpstan-ignore offsetAccess.nonOffsetAccessible (possibly need to pass 2 arguments to isset)
                             && ($phpSpreadsheetFunctions[$functionName]['passByReference'][$a])
                         ) {
                             /** @var mixed[] $arg */
@@ -2184,7 +2322,7 @@ class Calculation extends CalculationLocale
                     if ($functionName !== 'MKMATRIX') {
                         if ($this->debugLog->getWriteDebugLog()) {
                             krsort($argArrayVals);
-                            $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals)));
+                            $this->debugLog->writeDebugLog('Evaluating %s ( %s )', self::localeFunc($functionName), implode(self::$localeArgumentSeparator . ' ', Functions::flattenArray($argArrayVals))); // @phpstan-ignore argument.type (flattenArray returns array rather than array)
                         }
                     }
 
@@ -2221,7 +2359,7 @@ class Calculation extends CalculationLocale
                 }
             } else {
                 // if the token is a number, boolean, string or an Excel error, push it onto the stack
-                /** @var ?string $token */
+                /** @var null|numeric-string $token */
                 if (isset(self::EXCEL_CONSTANTS[strtoupper($token ?? '')])) {
                     $excelConstant = strtoupper("$token");
                     $stack->push('Constant Value', self::EXCEL_CONSTANTS[$excelConstant]);
@@ -2229,7 +2367,7 @@ class Calculation extends CalculationLocale
                         $branchStore[$storeKey] = self::EXCEL_CONSTANTS[$excelConstant];
                     }
                     $this->debugLog->writeDebugLog('Evaluating Constant %s as %s', $excelConstant, $this->showTypeDetails(self::EXCEL_CONSTANTS[$excelConstant]));
-                } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore-line
+                } elseif ((is_numeric($token)) || ($token === null) || (is_bool($token)) || ($token == '') || ($token[0] == self::FORMULA_STRING_QUOTE) || ($token[0] == '#')) { //* @phpstan-ignore function.alreadyNarrowedType (phpstan says is_bool has argument of *NEVER*?), equal.alwaysFalse (ditto)
                     /** @var array{type: string, reference: ?string} $tokenData */
                     $stack->push($tokenData['type'], $token, $tokenData['reference']);
                     if (isset($storeKey)) {
@@ -2355,7 +2493,7 @@ class Calculation extends CalculationLocale
                 $r = $stack->pop();
                 $result[$x] = $r['value'];
             }
-        } elseif (is_array($operand2) && is_array($operand1)) {
+        } elseif (is_array($operand2) /*&& is_array($operand1)*/) {
             // Operand 1 and Operand 2 are both arrays
             if (!$recursingArrays) {
                 self::checkMatrixOperands($operand1, $operand2, 2);
@@ -2368,7 +2506,7 @@ class Calculation extends CalculationLocale
                 $result[$x] = $r['value'];
             }
         } else {
-            throw new Exception('Neither operand is an arra');
+            throw new Exception('Neither operand is an array');
         }
         //    Log the result details
         $this->debugLog->writeDebugLog('Comparison Evaluation Result is %s', $this->showTypeDetails($result));
@@ -2378,8 +2516,8 @@ class Calculation extends CalculationLocale
         return $result;
     }
 
-    /** @return bool|mixed[] */
-    private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool
+    /** @return array|bool|string */
+    private function executeBinaryComparisonOperation(mixed $operand1, mixed $operand2, string $operation, Stack &$stack, bool $recursingArrays = false): array|bool|string
     {
         //    If we're dealing with matrix operations, we want a matrix result
         if ((is_array($operand1)) || (is_array($operand2))) {
@@ -2581,6 +2719,8 @@ class Calculation extends CalculationLocale
             if (!isset($aReferences[1])) {
                 //    Single cell in range
                 sscanf($aReferences[0], '%[A-Z]%d', $currentCol, $currentRow);
+                /** @var string $currentCol */
+                /** @var int $currentRow */
                 if ($createCell && $worksheet !== null && !$worksheet->cellExists($aReferences[0])) {
                     $worksheet->setCellValue($aReferences[0], null);
                 }
@@ -2600,6 +2740,8 @@ class Calculation extends CalculationLocale
                 foreach ($aReferences as $reference) {
                     // Extract range
                     sscanf($reference, '%[A-Z]%d', $currentCol, $currentRow);
+                    /** @var string $currentCol */
+                    /** @var int $currentRow */
                     if ($createCell && $worksheet !== null && !$worksheet->cellExists($reference)) {
                         $worksheet->setCellValue($reference, null);
                     }
@@ -2760,6 +2902,7 @@ class Calculation extends CalculationLocale
                 // read constant value
                 if (str_contains($constantName, '::')) {
                     [$className, $constantName] = explode('::', $constantName);
+                    /** @var class-string $className */
                     $constantReflector = new ReflectionClassConstant($className, $constantName);
 
                     return $constantReflector->getValue();
@@ -2814,10 +2957,12 @@ class Calculation extends CalculationLocale
         $definedNameValue = $namedRange->getValue();
         $definedNameType = $namedRange->isFormula() ? 'Formula' : 'Range';
         if ($definedNameType === 'Range') {
-            if (preg_match('/^(.*!)?(.*)$/', $definedNameValue, $matches) === 1) {
-                $matches2 = trim($matches[2]);
-                $matches2 = preg_replace('/ +/', ' ∩ ', $matches2) ?? $matches2;
-                $matches2 = preg_replace('/,/', ' ∪ ', $matches2) ?? $matches2;
+            if (Preg::isMatch('/^(.*!)?(.*)$/', $definedNameValue, $matches)) {
+                $matches2 = Preg::replace(
+                    ['/ +/', '/,/'],
+                    [' ∩ ', ' ∪ '],
+                    trim($matches[2])
+                );
                 $definedNameValue = $matches[1] . $matches2;
             }
         }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/CalculationParserOnly.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/CalculationParserOnly.php
new file mode 100644
index 0000000..cfbc928
--- /dev/null
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/CalculationParserOnly.php
@@ -0,0 +1,43 @@
+= count($fieldNames)) {
@@ -143,7 +143,7 @@ abstract class DatabaseAbstract
         }
 
         $rowQuery = array_map(
-            fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore-line
+            fn ($rowValue): string => (count($rowValue) > 1) ? 'AND(' . implode(',', $rowValue) . ')' : ($rowValue[0] ?? ''), // @phpstan-ignore nullCoalesce.offset ($rowValue[0] always exists?)
             $baseQuery
         );
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php
index 9a6faf3..6ee9d7e 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Date.php
@@ -24,7 +24,7 @@ class Date
      * Excel Function:
      *        DATE(year,month,day)
      *
-     * PhpSpreadsheet is a lot more forgiving than MS Excel when passing non numeric values to this function.
+     * PhpSpreadsheet is a lot more forgiving than MS Excel when passing non-numeric values to this function.
      * A Month name or abbreviation (English only at this point) such as 'January' or 'Jan' will still be accepted,
      *     as will a day value with a suffix (e.g. '21st' rather than simply 21); again only English language.
      *
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php
index d29ed6b..74f189e 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/DateValue.php
@@ -46,13 +46,22 @@ class DateValue
             return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $dateValue);
         }
 
+        return self::fromString2($dateValue, null);
+    }
+
+    /**
+     * @return array|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
+     *                        depending on the value of the ReturnDateType flag
+     */
+    public static function fromString2(null|string|int|bool|float $dateValue, ?int $calendar = null): array|string|float|int|DateTime
+    {
         // try to parse as date iff there is at least one digit
         if (is_string($dateValue) && preg_match('/\d/', $dateValue) !== 1) {
             return ExcelError::VALUE();
         }
 
         $dti = new DateTimeImmutable();
-        $baseYear = SharedDateHelper::getExcelCalendar();
+        $baseYear = $calendar ?? SharedDateHelper::getExcelCalendar();
         $dateValue = trim((string) $dateValue, '"');
         //    Strip any ordinals because they're allowed in Excel (English only)
         $dateValue = (string) preg_replace('/(\d)(st|nd|rd|th)([ -\/])/Ui', '$1$3', $dateValue);
@@ -86,7 +95,7 @@ class DateValue
         return self::finalResults($PHPDateArray, $dti, $baseYear);
     }
 
-    /** @param mixed[] $t1 */
+    /** @param list $t1 */
     private static function t1ToString(array $t1, DateTimeImmutable $dti, bool $yearFound): string
     {
         if (count($t1) == 2) {
@@ -118,7 +127,7 @@ class DateValue
         if (!Helpers::dateParseSucceeded($PHPDateArray)) {
             // If original count was 1, we've already returned.
             // If it was 2, we added another.
-            // Therefore, neither of the first 2 stroks below can fail.
+            // Therefore, neither of the first 2 strtoks below can fail.
             $testVal1 = strtok($dateValue, '- ');
             $testVal2 = strtok('- ');
             $testVal3 = strtok('- ') ?: $dti->format('Y');
@@ -160,9 +169,9 @@ class DateValue
             $day = self::getInt($PHPDateArray, 'day');
             $year = self::getInt($PHPDateArray, 'year');
             if (!checkdate($month, $day, $year)) {
-                return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0) : ExcelError::VALUE();
+                return ($year === 1900 && $month === 2 && $day === 29) ? Helpers::returnIn3FormatsFloat(60.0, calendar: $baseYear) : ExcelError::VALUE();
             }
-            $retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true);
+            $retValue = Helpers::returnIn3FormatsArray($PHPDateArray, true, calendar: $baseYear);
         }
 
         return $retValue;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days.php
index a3e5058..43a96b0 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days.php
@@ -50,11 +50,9 @@ class Days
 
         $days = ExcelError::VALUE();
         $diff = $PHPStartDateObject->diff($PHPEndDateObject);
-        if (!is_bool($diff->days)) {
-            $days = $diff->days;
-            if ($diff->invert) {
-                $days = -$days;
-            }
+        $days = $diff->days;
+        if ($diff->invert) {
+            $days = -$days;
         }
 
         return $days;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days360.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days360.php
index 2ccadf5..58eb68d 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days360.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Days360.php
@@ -76,7 +76,7 @@ class Days360
     }
 
     /**
-     * Return the number of days between two dates based on a 360 day calendar.
+     * Return the number of days between two dates based on a 360-day calendar.
      */
     private static function dateDiff360(int $startDay, int $startMonth, int $startYear, int $endDay, int $endMonth, int $endYear, bool $methodUS): int
     {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php
index ee1d486..5fc951c 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/Helpers.php
@@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Exception;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
 use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
+use Throwable;
 
 class Helpers
 {
@@ -29,10 +30,10 @@ class Helpers
      *
      * @return float Excel date/time serial value
      */
-    public static function getDateValue(mixed $dateValue, bool $allowBool = true): float
+    public static function getDateValue(mixed $dateValue, bool $allowBool = true, ?int $calendar = null): float
     {
         if (is_object($dateValue)) {
-            $retval = SharedDateHelper::PHPToExcel($dateValue);
+            $retval = SharedDateHelper::PHPToExcel($dateValue, calendar: $calendar);
             if (is_bool($retval)) {
                 throw new Exception(ExcelError::VALUE());
             }
@@ -40,7 +41,7 @@ class Helpers
             return $retval;
         }
 
-        self::nullFalseTrueToNumber($dateValue, $allowBool);
+        self::nullFalseTrueToNumber($dateValue, $allowBool, $calendar);
         if (!is_numeric($dateValue)) {
             $saveReturnDateType = Functions::getReturnDateType();
             Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
@@ -56,6 +57,12 @@ class Helpers
             throw new Exception(ExcelError::NAN());
         }
 
+        try {
+            SharedDateHelper::excelToDateTimeObject((float) $dateValue, calendar: $calendar);
+        } catch (Throwable) {
+            throw new Exception(ExcelError::NAN());
+        }
+
         return (float) $dateValue;
     }
 
@@ -80,10 +87,10 @@ class Helpers
      *
      * @param float|int $dateValue date to be adjusted
      */
-    public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0): DateTime
+    public static function adjustDateByMonths($dateValue = 0, float $adjustmentMonths = 0, ?int $calendar = null): DateTime
     {
         // Execute function
-        $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue);
+        $PHPDateObject = SharedDateHelper::excelToDateTimeObject($dateValue, calendar: $calendar);
         $oMonth = (int) $PHPDateObject->format('m');
         $oYear = (int) $PHPDateObject->format('Y');
 
@@ -134,7 +141,7 @@ class Helpers
      *
      * @param array{year: int, month: int, day: int, hour: int, minute: int, second: int} $dateArray
      */
-    public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false): DateTime|float|int
+    public static function returnIn3FormatsArray(array $dateArray, bool $noFrac = false, ?int $calendar = null): DateTime|float|int
     {
         $retType = Functions::getReturnDateType();
         if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
@@ -154,58 +161,60 @@ class Helpers
                 $dateArray['day'],
                 $dateArray['hour'],
                 $dateArray['minute'],
-                $dateArray['second']
+                $dateArray['second'],
+                calendar: $calendar
             );
         if ($retType === Functions::RETURNDATE_EXCEL) {
             return $noFrac ? floor($excelDateValue) : $excelDateValue;
         }
         // RETURNDATE_UNIX_TIMESTAMP)
 
-        return SharedDateHelper::excelToTimestamp($excelDateValue);
+        return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
     }
 
     /**
      * Return result in one of three formats.
      */
-    public static function returnIn3FormatsFloat(float $excelDateValue): float|int|DateTime
+    public static function returnIn3FormatsFloat(float $excelDateValue, ?int $calendar = null): float|int|DateTime
     {
         $retType = Functions::getReturnDateType();
         if ($retType === Functions::RETURNDATE_EXCEL) {
             return $excelDateValue;
         }
         if ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
-            return SharedDateHelper::excelToTimestamp($excelDateValue);
+            return SharedDateHelper::excelToTimestamp($excelDateValue, calendar: $calendar);
         }
         // RETURNDATE_PHP_DATETIME_OBJECT
 
-        return SharedDateHelper::excelToDateTimeObject($excelDateValue);
+        return SharedDateHelper::excelToDateTimeObject($excelDateValue, calendar: $calendar);
     }
 
     /**
      * Return result in one of three formats.
      */
-    public static function returnIn3FormatsObject(DateTime $PHPDateObject): DateTime|float|int
+    public static function returnIn3FormatsObject(DateTime $PHPDateObject, ?int $calendar = null): DateTime|float|int
     {
         $retType = Functions::getReturnDateType();
         if ($retType === Functions::RETURNDATE_PHP_DATETIME_OBJECT) {
             return $PHPDateObject;
         }
         if ($retType === Functions::RETURNDATE_EXCEL) {
-            return (float) SharedDateHelper::PHPToExcel($PHPDateObject);
+            return (float) SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
         }
         // RETURNDATE_UNIX_TIMESTAMP
-        $stamp = SharedDateHelper::PHPToExcel($PHPDateObject);
+        $stamp = SharedDateHelper::PHPToExcel($PHPDateObject, calendar: $calendar);
         $stamp = is_bool($stamp) ? ((int) $stamp) : $stamp;
 
-        return SharedDateHelper::excelToTimestamp($stamp);
+        return SharedDateHelper::excelToTimestamp($stamp, calendar: $calendar);
     }
 
-    private static function baseDate(): int
+    private static function baseDate(?int $calendar): int
     {
         if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
             return 0;
         }
-        if (SharedDateHelper::getExcelCalendar() === SharedDateHelper::CALENDAR_MAC_1904) {
+        $calendar ??= SharedDateHelper::getExcelCalendar();
+        if ($calendar === SharedDateHelper::CALENDAR_MAC_1904) {
             return 0;
         }
 
@@ -215,10 +224,10 @@ class Helpers
     /**
      * Many functions accept null/false/true argument treated as 0/0/1.
      */
-    public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true): void
+    public static function nullFalseTrueToNumber(mixed &$number, bool $allowBool = true, ?int $calendar = null): void
     {
         $number = Functions::flattenSingleValue($number);
-        $nullVal = self::baseDate();
+        $nullVal = self::baseDate($calendar);
         if ($number === null) {
             $number = $nullVal;
         } elseif ($allowBool && is_bool($number)) {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php
index a7f415d..86007d4 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeParts.php
@@ -4,7 +4,9 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
 
 use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
 use PhpOffice\PhpSpreadsheet\Calculation\Exception;
+use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
 use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDateHelper;
+use Throwable;
 
 class TimeParts
 {
@@ -44,6 +46,11 @@ class TimeParts
         }
 
         // Execute function
+        try {
+            SharedDateHelper::excelToDateTimeObject($timeValue);
+        } catch (Throwable) {
+            return ExcelError::NAN();
+        }
         $timeValue = fmod($timeValue, 1);
         $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
         SharedDateHelper::roundMicroseconds($timeValue);
@@ -85,6 +92,11 @@ class TimeParts
         }
 
         // Execute function
+        try {
+            SharedDateHelper::excelToDateTimeObject($timeValue);
+        } catch (Throwable) {
+            return ExcelError::NAN();
+        }
         $timeValue = fmod($timeValue, 1);
         $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
         SharedDateHelper::roundMicroseconds($timeValue);
@@ -126,6 +138,11 @@ class TimeParts
         }
 
         // Execute function
+        try {
+            SharedDateHelper::excelToDateTimeObject($timeValue);
+        } catch (Throwable) {
+            return ExcelError::NAN();
+        }
         $timeValue = fmod($timeValue, 1);
         $timeValue = SharedDateHelper::excelToDateTimeObject($timeValue);
         SharedDateHelper::roundMicroseconds($timeValue);
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php
index fc1f72c..f710f51 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/DateTimeExcel/TimeValue.php
@@ -3,7 +3,7 @@
 namespace PhpOffice\PhpSpreadsheet\Calculation\DateTimeExcel;
 
 use Composer\Pcre\Preg;
-use Datetime;
+use DateTime;
 use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
@@ -45,12 +45,12 @@ class TimeValue
      *                                    Date information in time_text is ignored.
      *                         Or can be an array of date/time values
      *
-     * @return array|Datetime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
+     * @return array|DateTime|float|int|string Excel date/time serial value, PHP date/time serial value or PHP date/time object,
      *                        depending on the value of the ReturnDateType flag
      *         If an array of numbers is passed as the argument, then the returned result will also be an array
      *            with the same dimensions
      */
-    public static function fromString(null|array|string|int|bool|float $timeValue): array|string|Datetime|int|float
+    public static function fromString(null|array|string|int|bool|float $timeValue): array|string|DateTime|int|float
     {
         if (is_array($timeValue)) {
             return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $timeValue);
@@ -88,7 +88,7 @@ class TimeValue
             } elseif ($retType === Functions::RETURNDATE_UNIX_TIMESTAMP) {
                 $retValue = (int) SharedDateHelper::excelToTimestamp($excelDateValue + 25569) - 3600;
             } else {
-                $retValue = new Datetime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
+                $retValue = new DateTime('1900-01-01 ' . $PHPDateArray['hour'] . ':' . $PHPDateArray['minute'] . ':' . $PHPDateArray['second']);
             }
         }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Logger.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Logger.php
index e82faf5..d917320 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Logger.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Logger.php
@@ -78,8 +78,8 @@ class Logger
     {
         //    Only write the debug log if logging is enabled
         if ($this->writeDebugLog) {
-            $message = sprintf($message, ...$args); //* @phpstan-ignore-line
-            $cellReference = implode(' -> ', $this->cellStack->showStack());
+            $message = sprintf($message, ...$args); //* @phpstan-ignore argument.type (...$args could contain items that sprintf can't handle)
+            $cellReference = implode(' -> ', $this->cellStack->showStack()); //* @phpstan-ignore argument.type (showStack can return array not array)
             if ($this->echoDebugLog) {
                 echo $cellReference,
                 ($this->cellStack->count() > 0 ? ' => ' : ''),
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php
index 9c4cf72..0b1f0eb 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engine/Operands/StructuredReference.php
@@ -204,8 +204,9 @@ final class StructuredReference implements Operand, Stringable
         /** @var string $reference */
         $reference = str_replace('[' . self::ITEM_SPECIFIER_THIS_ROW . '],', '', $reference);
 
-        foreach ($this->columns as $columnId => $columnName) {
-            $columnName = str_replace("\u{a0}", ' ', $columnName); //* @phpstan-ignore-line
+        foreach ($this->columns as $columnId => $columnNamex) {
+            /** @var string $columnNamex */
+            $columnName = str_replace("\u{a0}", ' ', $columnNamex);
             $reference = $this->adjustRowReference($columnName, $reference, $cell, $columnId);
         }
 
@@ -338,8 +339,9 @@ final class StructuredReference implements Operand, Stringable
     private function getColumnsForColumnReference(string $reference, int $startRow, int $endRow): string
     {
         $columnsSelected = false;
-        foreach ($this->columns as $columnId => $columnName) {
-            $columnName = str_replace("\u{a0}", ' ', $columnName ?? ''); //* @phpstan-ignore-line
+        foreach ($this->columns as $columnId => $columnNamex) {
+            /** @var ?string $columnNamex */
+            $columnName = str_replace("\u{a0}", ' ', $columnNamex ?? '');
             $cellFrom = "{$columnId}{$startRow}";
             $cellTo = "{$columnId}{$endRow}";
             $cellReference = ($cellFrom === $cellTo) ? $cellFrom : "{$cellFrom}:{$cellTo}";
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ConvertDecimal.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ConvertDecimal.php
index 9834bcc..3513840 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ConvertDecimal.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ConvertDecimal.php
@@ -150,7 +150,7 @@ class ConvertDecimal extends ConvertBase
     /**
      * toOctal.
      *
-     * Return an decimal value as octal.
+     * Return a decimal value as octal.
      *
      * Excel Function:
      *        DEC2OCT(x[,places])
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ErfC.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ErfC.php
index 4cca2d6..db104bf 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ErfC.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Engineering/ErfC.php
@@ -18,7 +18,7 @@ class ErfC
      *    Note: In Excel 2007 or earlier, if you input a negative value for the lower bound argument,
      *        the function would return a #NUM! error. However, in Excel 2010, the function algorithm was
      *        improved, so that it can now calculate the function for both positive and negative x values.
-     *            PhpSpreadsheet follows Excel 2010 behaviour, and accepts nagative arguments.
+     *            PhpSpreadsheet follows Excel 2010 behaviour, and accepts negative arguments.
      *
      *    Excel Function:
      *        ERFC(x)
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/Interest.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/Interest.php
index 68ac57b..33031cc 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/Interest.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/Interest.php
@@ -83,7 +83,7 @@ class Interest
      *     =ISPMT(interest_rate, period, number_payments, pv)
      *
      * @param mixed $interestRate is the interest rate for the investment
-     * @param mixed $period is the period to calculate the interest rate.  It must be betweeen 1 and number_payments.
+     * @param mixed $period is the period to calculate the interest rate.  It must be between 1 and number_payments.
      * @param mixed $numberOfPeriods is the number of payments for the annuity
      * @param mixed $principleRemaining is the loan amount or present value of the payments
      */
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/InterestAndPrincipal.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/InterestAndPrincipal.php
deleted file mode 100644
index ea9abb9..0000000
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Constant/Periodic/InterestAndPrincipal.php
+++ /dev/null
@@ -1,44 +0,0 @@
-interest = $interest;
-        $this->principal = $principal;
-    }
-
-    public function interest(): float
-    {
-        return $this->interest;
-    }
-
-    public function principal(): float
-    {
-        return $this->principal;
-    }
-}
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php
index 53668d1..130e4c1 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Financial/CashFlow/Variable/NonPeriodic.php
@@ -166,7 +166,7 @@ class NonPeriodic
         $foundneg = false;
         for ($i = 0; $i < $valCount; ++$i) {
             $fld = $values[$i];
-            if (!is_numeric($fld)) { //* @phpstan-ignore-line
+            if (!is_numeric($fld)) {
                 return ExcelError::VALUE();
             } elseif ($fld > 0) {
                 $foundpos = true;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaParser.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaParser.php
index f07b5b5..eda4787 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaParser.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaParser.php
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
  * 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.
  *
+ * The following links are no longer valid.
  * https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
  * https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
+ *
+ * @deprecated 5.5.0 No replacement.
  */
 class FormulaParser
 {
@@ -463,16 +466,8 @@ class FormulaParser
         $tokenCount = count($tokens1);
         for ($i = 0; $i < $tokenCount; ++$i) {
             $token = $tokens1[$i];
-            if (isset($tokens1[$i - 1])) {
-                $previousToken = $tokens1[$i - 1];
-            } else {
-                $previousToken = null;
-            }
-            if (isset($tokens1[$i + 1])) {
-                $nextToken = $tokens1[$i + 1];
-            } else {
-                $nextToken = null;
-            }
+            $previousToken = $tokens1[$i - 1] ?? null;
+            $nextToken = $tokens1[$i + 1] ?? null;
 
             if ($token->getTokenType() != FormulaToken::TOKEN_TYPE_WHITESPACE) {
                 $tokens2[] = $token;
@@ -518,11 +513,7 @@ class FormulaParser
         $tokenCount = count($tokens2);
         for ($i = 0; $i < $tokenCount; ++$i) {
             $token = $tokens2[$i];
-            if (isset($tokens2[$i - 1])) {
-                $previousToken = $tokens2[$i - 1];
-            } else {
-                $previousToken = null;
-            }
+            $previousToken = $tokens2[$i - 1] ?? null;
 
             if ($token->getTokenType() == FormulaToken::TOKEN_TYPE_OPERATORINFIX && $token->getValue() == '-') {
                 if ($i == 0) {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaToken.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaToken.php
index cc7d48f..b46e846 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaToken.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FormulaToken.php
@@ -21,8 +21,11 @@ namespace PhpOffice\PhpSpreadsheet\Calculation;
  * 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.
  *
+ * The following links are no longer valid.
  * https://ewbi.blogs.com/develops/2007/03/excel_formula_p.html
  * https://ewbi.blogs.com/develops/2004/12/excel_formula_p.html
+ *
+ * @deprecated 5.5.0 No replacement.
  */
 class FormulaToken
 {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FunctionArray.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FunctionArray.php
index fd2d5a0..8f1de75 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FunctionArray.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/FunctionArray.php
@@ -9,7 +9,7 @@ class FunctionArray extends CalculationBase
      *
      * @var array
      */
-    protected static array $phpSpreadsheetFunctions = [
+    protected static array $phpSpreadsheetFunctions = [ // @phpstan-ignore property.defaultValue (Phpstan is wrong)
         'ABS' => [
             'category' => Category::CATEGORY_MATH_AND_TRIG,
             'functionCall' => [MathTrig\Absolute::class, 'evaluate'],
@@ -149,7 +149,7 @@ class FunctionArray extends CalculationBase
         ],
         'BAHTTEXT' => [
             'category' => Category::CATEGORY_TEXT_AND_DATA,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [TextData\Thai::class, 'getBahtText'],
             'argumentCount' => '1',
         ],
         'BASE' => [
@@ -405,6 +405,7 @@ class FunctionArray extends CalculationBase
             'category' => Category::CATEGORY_TEXT_AND_DATA,
             'functionCall' => [TextData\Concatenate::class, 'actualCONCATENATE'],
             'argumentCount' => '1+',
+            'passCellReference' => true,
         ],
         'CONFIDENCE' => [
             'category' => Category::CATEGORY_STATISTICAL,
@@ -919,7 +920,7 @@ class FunctionArray extends CalculationBase
         'FLOOR' => [
             'category' => Category::CATEGORY_MATH_AND_TRIG,
             'functionCall' => [MathTrig\Floor::class, 'floor'],
-            'argumentCount' => '1-2', // Excel requries 2, Ods/Gnumeric 1-2
+            'argumentCount' => '1-2', // Excel requires 2, Ods/Gnumeric 1-2
         ],
         'FLOOR.MATH' => [
             'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -1284,8 +1285,9 @@ class FunctionArray extends CalculationBase
         ],
         'INFO' => [
             'category' => Category::CATEGORY_INFORMATION,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [Information\Info::class, 'getInfo'],
             'argumentCount' => '1',
+            'passCellReference' => true,
         ],
         'INT' => [
             'category' => Category::CATEGORY_MATH_AND_TRIG,
@@ -2335,17 +2337,17 @@ class FunctionArray extends CalculationBase
         ],
         'T.DIST' => [
             'category' => Category::CATEGORY_STATISTICAL,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDist'],
             'argumentCount' => '3',
         ],
         'T.DIST.2T' => [
             'category' => Category::CATEGORY_STATISTICAL,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDot2T'],
             'argumentCount' => '2',
         ],
         'T.DIST.RT' => [
             'category' => Category::CATEGORY_STATISTICAL,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotDistDotRT'],
             'argumentCount' => '2',
         ],
         'TEXT' => [
@@ -2425,12 +2427,12 @@ class FunctionArray extends CalculationBase
         ],
         'T.INV' => [
             'category' => Category::CATEGORY_STATISTICAL,
-            'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
+            'functionCall' => [Statistical\Distributions\StudentT::class, 'tDotInv'],
             'argumentCount' => '2',
         ],
         'T.INV.2T' => [
             'category' => Category::CATEGORY_STATISTICAL,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [Statistical\Distributions\StudentT::class, 'inverse'],
             'argumentCount' => '2',
         ],
         'TODAY' => [
@@ -2495,12 +2497,12 @@ class FunctionArray extends CalculationBase
         ],
         'UNICHAR' => [
             'category' => Category::CATEGORY_TEXT_AND_DATA,
-            'functionCall' => [TextData\CharacterConvert::class, 'character'],
+            'functionCall' => [TextData\CharacterConvert::class, 'characterUnicode'],
             'argumentCount' => '1',
         ],
         'UNICODE' => [
             'category' => Category::CATEGORY_TEXT_AND_DATA,
-            'functionCall' => [TextData\CharacterConvert::class, 'code'],
+            'functionCall' => [TextData\CharacterConvert::class, 'codeUnicode'],
             'argumentCount' => '1',
         ],
         'UNIQUE' => [
@@ -2577,6 +2579,7 @@ class FunctionArray extends CalculationBase
             'category' => Category::CATEGORY_WEB,
             'functionCall' => [Web\Service::class, 'webService'],
             'argumentCount' => '1',
+            'passCellReference' => true,
         ],
         'WEEKDAY' => [
             'category' => Category::CATEGORY_DATE_AND_TIME,
@@ -2625,7 +2628,7 @@ class FunctionArray extends CalculationBase
         ],
         'XLOOKUP' => [
             'category' => Category::CATEGORY_LOOKUP_AND_REFERENCE,
-            'functionCall' => [Functions::class, 'DUMMY'],
+            'functionCall' => [LookupRef\XLookup::class, 'lookup'],
             'argumentCount' => '3-6',
         ],
         'XNPV' => [
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Functions.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Functions.php
index 16f6a3d..5004f52 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Functions.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Functions.php
@@ -3,6 +3,7 @@
 namespace PhpOffice\PhpSpreadsheet\Calculation;
 
 use PhpOffice\PhpSpreadsheet\Cell\Cell;
+use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
 use PhpOffice\PhpSpreadsheet\Shared\Date;
 use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
@@ -360,4 +361,47 @@ class Functions
 
         return $coordinate;
     }
+
+    /** @param mixed[] $array */
+    public static function convertArrayToCellRange(array $array): string
+    {
+        $retVal = '';
+        $lastRow = $lastColumn = $firstRow = $firstColumn = 0;
+        foreach ($array as $rowkey => $row) {
+            if (!is_array($row) || !is_int($rowkey) || $rowkey < 1) {
+                $firstRow = 0;
+
+                break;
+            }
+            if ($firstRow > $rowkey || $firstRow === 0) {
+                $firstRow = $rowkey;
+            }
+            if ($lastRow < $rowkey) {
+                $lastRow = $rowkey;
+            }
+            foreach ($row as $colkey => $cellValue) {
+                if (!preg_match('/^[A-Z]{1,3}$/', $colkey)) {
+                    $firstRow = 0;
+
+                    break 2;
+                }
+                $column = Coordinate::columnIndexFromString($colkey);
+                if ($firstColumn > $column || $firstColumn === 0) {
+                    $firstColumn = $column;
+                }
+                if ($lastColumn < $column) {
+                    $lastColumn = $column;
+                }
+            }
+        }
+        if ($firstRow > 0 && $firstColumn > 0 && ($firstRow !== $lastRow || $firstColumn !== $lastColumn)) {
+            $retVal = Coordinate::stringFromColumnIndex($firstColumn)
+                . $firstRow
+                . ':'
+                . Coordinate::stringFromColumnIndex($lastColumn)
+                . $lastRow;
+        }
+
+        return $retVal;
+    }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Info.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Info.php
new file mode 100644
index 0000000..61138fe
--- /dev/null
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Info.php
@@ -0,0 +1,44 @@
+ '/',
+            'numfile' => $cell?->getWorksheetOrNull()?->getParent()?->getSheetCount() ?? 1,
+            'origin' => '$A:$A$1',
+            'osversion' => 'PHP ' . PHP_VERSION,
+            'recalc' => 'Automatic',
+            'release' => PHP_VERSION,
+            'system' => 'PHP',
+            'memavail', 'memused', 'totmem' => ExcelError::NA(),
+            default => ExcelError::VALUE(),
+        };
+    }
+}
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Value.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Value.php
index 57a6704..5662085 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Value.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Information/Value.php
@@ -52,8 +52,10 @@ class Value
             if (!empty($worksheet) && $cell->getWorksheet()->getParentOrThrow()->getSheetByName($worksheet) === null) {
                 return false;
             }
-            [$column, $row] = Coordinate::indexesFromString($cellValue ?? '');
-            if ($column > 16384 || $row > 1048576) {
+
+            try {
+                [$column, $row] = Coordinate::indexesFromString($cellValue ?? ''); //* @phpstan-ignore nullCoalesce.variable (Not sure whether Phpstan is correct)
+            } catch (SpreadsheetException) {
                 return false;
             }
 
@@ -265,7 +267,7 @@ class Value
         if (is_bool($value)) {
             return (int) $value;
         }
-        if (is_string($value) && substr($value, 0, 1) === '#') {
+        if (is_string($value) && str_starts_with($value, '#')) {
             return $value;
         }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Logical/Conditional.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Logical/Conditional.php
index 9ac36c5..2dd3829 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Logical/Conditional.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Logical/Conditional.php
@@ -50,7 +50,7 @@ class Conditional
     {
         $condition = ($condition === null) ? true : Functions::flattenSingleValue($condition);
 
-        if (ErrorValue::isError($condition)) {
+        if (ErrorValue::isError($condition, true)) {
             return $condition;
         }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ChooseRowsEtc.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ChooseRowsEtc.php
index 944675e..b268de8 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ChooseRowsEtc.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ChooseRowsEtc.php
@@ -18,7 +18,7 @@ class ChooseRowsEtc
      */
     public static function transpose(array $array): array
     {
-        return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore-line
+        return empty($array) ? [] : (array_map((count($array) === 1) ? (fn ($x) => [$x]) : null, ...$array)); // @phpstan-ignore argument.type (phstan is wrong), argument.type (phpstan is wrong)
     }
 
     /** @return mixed[] */
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ExcelMatch.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ExcelMatch.php
index 3a1a6c4..4346037 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ExcelMatch.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/ExcelMatch.php
@@ -58,7 +58,7 @@ class ExcelMatch
             return $e->getMessage();
         }
 
-        // MATCH() is not case sensitive, so we convert lookup value to be lower cased if it's a string type.
+        // MATCH() is not case-sensitive, so we convert lookup value to be lower cased if it's a string type.
         if (is_string($lookupValue)) {
             $lookupValue = StringHelper::strToLower($lookupValue);
         }
@@ -70,7 +70,7 @@ class ExcelMatch
         };
 
         if ($valueKey !== null) {
-            return ++$valueKey; //* @phpstan-ignore-line
+            return ++$valueKey; //* @phpstan-ignore preInc.type ($valueKey can be mixed not numeric)
         }
 
         // Unsuccessful in finding a match, return #N/A error value
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php
index 486194e..3106d4e 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Filter.php
@@ -58,7 +58,7 @@ class Filter
      */
     private static function filterByRow(array $lookupArray, array $matchArray): array
     {
-        $matchArray = array_values(array_column($matchArray, 0)); // @phpstan-ignore-line
+        $matchArray = array_values(array_column($matchArray, 0));
 
         return array_filter(
             array_values($lookupArray),
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hstack.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hstack.php
index 9be77e5..5d4938b 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hstack.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hstack.php
@@ -37,7 +37,7 @@ class Hstack
             }
         }
 
-        $transpose = array_map(null, ...$inputData); //* @phpstan-ignore-line
+        $transpose = array_map(null, ...$inputData); //* @phpstan-ignore argument.type (inputData is mixed not array)
         $returnMatrix = [];
         foreach ($transpose as $array) {
             $returnMatrix[] = Functions::flattenArray($array);
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hyperlink.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hyperlink.php
index e7752aa..ab35ae3 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hyperlink.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Hyperlink.php
@@ -23,6 +23,13 @@ class Hyperlink
      */
     public static function set(mixed $linkURL = '', mixed $displayName = null, ?Cell $cell = null): string
     {
+        $worksheet = null;
+        $coordinate = '';
+        if ($cell !== null) {
+            $coordinate = $cell->getCoordinate();
+            $worksheet = $cell->getWorksheetOrNull();
+        }
+
         $linkURL = ($linkURL === null) ? '' : StringHelper::convertToString(Functions::flattenSingleValue($linkURL));
         $displayName = ($displayName === null) ? '' : Functions::flattenSingleValue($displayName);
 
@@ -30,17 +37,16 @@ class Hyperlink
             return ExcelError::REF();
         }
 
-        if (is_object($displayName)) {
-            $displayName = $linkURL;
-        }
-        $displayName = StringHelper::convertToString($displayName);
+        $displayName = StringHelper::convertToString($displayName, false);
         if (trim($displayName) === '') {
             $displayName = $linkURL;
         }
 
-        $cell->getHyperlink()
-            ->setUrl($linkURL);
-        $cell->getHyperlink()->setTooltip($displayName);
+        $worksheet?->getCell($coordinate)
+            ->getHyperlink()
+            ->setUrl($linkURL)
+            ->setTooltip($displayName)
+            ->setDisplay('');
 
         return $displayName;
     }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php
index 756478c..c0e4b28 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Indirect.php
@@ -106,14 +106,14 @@ class Indirect
      */
     private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
     {
-        return Calculation::getInstance($worksheet !== null ? $worksheet->getParent() : null)
+        return Calculation::getInstance($worksheet?->getParent())
             ->extractCellRange($cellAddress, $worksheet, false, createCell: true);
     }
 
     private static function handleRowColumnRanges(?Worksheet $worksheet, string $start, string $end): string
     {
         // Being lazy, we're only checking a single row/column to get the max
-        if (ctype_digit($start) && $start <= 1048576) {
+        if (ctype_digit($start) && $start <= AddressRange::MAX_ROW) {
             // Max 16,384 columns for Excel2007
             $endColRef = ($worksheet !== null) ? $worksheet->getHighestDataColumn((int) $start) : AddressRange::MAX_COLUMN;
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Lookup.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Lookup.php
index 9d3eea4..f3b6ad9 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Lookup.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Lookup.php
@@ -40,15 +40,15 @@ class Lookup
             $lookupColumns = self::columnCount($lookupVector);
         }
 
-        $resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore-line
+        $resultVector = self::verifyResultVector($resultVector ?? $lookupVector); //* @phpstan-ignore argument.type (resultVector can be mixed not array>)
 
         if ($lookupRows === 2 && !$hasResultVector) {
             $resultVector = array_pop($lookupVector);
             $lookupVector = array_shift($lookupVector);
         }
 
-        /** @var mixed[] $lookupVector */
-        /** @var mixed[] $resultVector */
+        /** @var array $lookupVector */
+        /** @var array $resultVector */
         if ($lookupColumns !== 2) {
             $lookupVector = self::verifyLookupValues($lookupVector, $resultVector);
         }
@@ -57,8 +57,8 @@ class Lookup
     }
 
     /**
-     * @param mixed[] $lookupVector
-     * @param mixed[] $resultVector
+     * @param array $lookupVector
+     * @param array $resultVector
      *
      * @return mixed[]
      */
@@ -80,6 +80,7 @@ class Lookup
             if (is_array($dataValue2)) {
                 $dataValue2 = array_shift($dataValue2);
             }
+            /** @var int $key2 */
             $value = [$key1 => $dataValue1, $key2 => $dataValue2];
         }
         unset($value);
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/LookupRefValidations.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/LookupRefValidations.php
index 620705e..9101559 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/LookupRefValidations.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/LookupRefValidations.php
@@ -11,7 +11,7 @@ class LookupRefValidations
     public static function validateInt(mixed $value): int
     {
         if (!is_numeric($value)) {
-            if (is_string($value) && ErrorValue::isError($value)) {
+            if (is_string($value) && ErrorValue::isError($value, true)) {
                 throw new Exception($value);
             }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Matrix.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Matrix.php
index 287e841..a916af5 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Matrix.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Matrix.php
@@ -125,7 +125,7 @@ class Matrix
         }
 
         $rowKeys = array_keys($matrix);
-        $columnKeys = @array_keys($matrix[$rowKeys[0]]); //* @phpstan-ignore-line
+        $columnKeys = @array_keys($matrix[$rowKeys[0]]); //* @phpstan-ignore argument.type (argument 1 of array_keys may be mixed not array)
 
         if ($columnNum > count($columnKeys)) {
             return ExcelError::REF();
@@ -135,14 +135,14 @@ class Matrix
             return self::extractRowValue($matrix, $rowKeys, $rowNum);
         }
 
-        $columnNum = $columnKeys[--$columnNum]; //* @phpstan-ignore-line
+        $columnNum = $columnKeys[--$columnNum]; //* @phpstan-ignore offsetAccess.notFound (perhaps index --$columnNum does not exist)
         if ($rowNum === 0) {
             return array_map(
                 fn ($value): array => [$value],
                 array_column($matrix, $columnNum)
             );
         }
-        $rowNum = $rowKeys[--$rowNum]; //* @phpstan-ignore-line
+        $rowNum = $rowKeys[--$rowNum]; //* @phpstan-ignore offsetAccess.notFound (perhaps index --$rowNum does not exist)
         /** @var mixed[][] $matrix */
 
         return $matrix[$rowNum][$columnNum];
@@ -150,7 +150,7 @@ class Matrix
 
     /**
      * @param mixed[] $matrix
-     * @param mixed[] $rowKeys
+     * @param array $rowKeys
      */
     private static function extractRowValue(array $matrix, array $rowKeys, int $rowNum): mixed
     {
@@ -159,7 +159,7 @@ class Matrix
         }
 
         $rowNum = $rowKeys[--$rowNum];
-        $row = $matrix[$rowNum]; //* @phpstan-ignore-line
+        $row = $matrix[$rowNum];
         if (is_array($row)) {
             return [$rowNum => $row];
         }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Offset.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Offset.php
index 6b07262..bc6537d 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Offset.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Offset.php
@@ -84,7 +84,7 @@ class Offset
         $endCellColumn = self::adjustEndCellColumnForWidth($endCellColumn, $width, $startCellColumn, $columns);
         $startCellColumn = Coordinate::stringFromColumnIndex($startCellColumn + 1);
 
-        $endCellRow = self::adustEndCellRowForHeight($height, $startCellRow, $rows, $endCellRow);
+        $endCellRow = self::adjustEndCellRowForHeight($height, $startCellRow, $rows, $endCellRow);
 
         if (($endCellRow <= 0) || ($endCellColumn < 0)) {
             return ExcelError::REF();
@@ -102,7 +102,7 @@ class Offset
     /** @return mixed[] */
     private static function extractRequiredCells(?Worksheet $worksheet, string $cellAddress): array
     {
-        return Calculation::getInstance($worksheet !== null ? $worksheet->getParent() : null)
+        return Calculation::getInstance($worksheet?->getParent())
             ->extractCellRange($cellAddress, $worksheet, false);
     }
 
@@ -152,7 +152,7 @@ class Offset
      * @param null|object|scalar $height
      * @param scalar $rows
      */
-    private static function adustEndCellRowForHeight($height, int $startCellRow, $rows, int $endCellRow): int
+    private static function adjustEndCellRowForHeight($height, int $startCellRow, $rows, int $endCellRow): int
     {
         if (($height !== null) && (!is_object($height))) {
             $endCellRow = $startCellRow + (int) $height - 1;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php
index caf0983..59d5875 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/RowColumnInformation.php
@@ -5,6 +5,7 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
 use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
+use PhpOffice\PhpSpreadsheet\Cell\AddressRange;
 use PhpOffice\PhpSpreadsheet\Cell\Cell;
 use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
 use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException;
@@ -106,7 +107,7 @@ class RowColumnInformation
         if (self::cellAddressNullOrWhitespace($cellAddress)) {
             return 1;
         }
-        if (is_string($cellAddress) && ErrorValue::isError($cellAddress)) {
+        if (is_string($cellAddress) && ErrorValue::isError($cellAddress, true)) {
             return $cellAddress;
         }
         if (!is_array($cellAddress)) {
@@ -131,7 +132,7 @@ class RowColumnInformation
 
     private static function convert0ToName(int|string $result): int|string
     {
-        if (is_int($result) && ($result <= 0 || $result > 1048576)) {
+        if (is_int($result) && ($result <= 0 || $result > AddressRange::MAX_ROW)) {
             return ExcelError::NAME();
         }
 
@@ -211,7 +212,7 @@ class RowColumnInformation
         if (self::cellAddressNullOrWhitespace($cellAddress)) {
             return 1;
         }
-        if (is_string($cellAddress) && ErrorValue::isError($cellAddress)) {
+        if (is_string($cellAddress) && ErrorValue::isError($cellAddress, true)) {
             return $cellAddress;
         }
         if (!is_array($cellAddress)) {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Selection.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Selection.php
index 53396c9..8d8daee 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Selection.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Selection.php
@@ -37,7 +37,7 @@ class Selection
         } else {
             return ExcelError::VALUE();
         }
-        $chosenEntry = floor($chosenEntry);
+        $chosenEntry = (int) floor($chosenEntry);
         if (($chosenEntry < 0) || ($chosenEntry > $entryCount)) {
             return ExcelError::VALUE();
         }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php
index 78a30a4..e013da7 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Sort.php
@@ -2,7 +2,6 @@
 
 namespace PhpOffice\PhpSpreadsheet\Calculation\LookupRef;
 
-use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
 use PhpOffice\PhpSpreadsheet\Calculation\Exception;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
@@ -20,6 +19,12 @@ class Sort extends LookupRefValidations
      * The returned array is the same shape as the provided array argument.
      * Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
      *
+     * NOTE: If $sortArray contains a mixture of data types
+     * (string/int/bool), the results may be unexpected.
+     * This is also true if the array consists of string
+     * representations of numbers, especially if there are
+     * both positive and negative numbers in the mix.
+     *
      * @param mixed $sortArray The range of cells being sorted
      * @param mixed $sortIndex The column or row number within the sortArray to sort on
      * @param mixed $sortOrder Flag indicating whether to sort ascending or descending
@@ -32,8 +37,7 @@ class Sort extends LookupRefValidations
     public static function sort(mixed $sortArray, mixed $sortIndex = 1, mixed $sortOrder = self::ORDER_ASCENDING, mixed $byColumn = false): mixed
     {
         if (!is_array($sortArray)) {
-            // Scalars are always returned "as is"
-            return $sortArray;
+            $sortArray = [[$sortArray]];
         }
 
         /** @var mixed[][] */
@@ -55,7 +59,7 @@ class Sort extends LookupRefValidations
             return $e->getMessage();
         }
 
-        // We want a simple, enumrated array of arrays where we can reference column by its index number.
+        // We want a simple, enumerated array of arrays where we can reference column by its index number.
         /** @var callable(mixed): mixed */
         $temp = 'array_values';
         /** @var array $sortOrder */
@@ -72,6 +76,18 @@ class Sort extends LookupRefValidations
      * The SORTBY function sorts the contents of a range or array based on the values in a corresponding range or array.
      * The returned array is the same shape as the provided array argument.
      * Both $sortIndex and $sortOrder can be arrays, to provide multi-level sorting.
+     * Microsoft doesn't even bother documenting that a column sort
+     * is possible. However, it is. According to:
+     * https://exceljet.net/functions/sortby-function
+     * When by_array is a horizontal range, SORTBY sorts horizontally by columns.
+     * My interpretation of this is that by_array must be an
+     * array which contains exactly one row.
+     *
+     * NOTE: If the "byArray" contains a mixture of data types
+     * (string/int/bool), the results may be unexpected.
+     * This is also true if the array consists of string
+     * representations of numbers, especially if there are
+     * both positive and negative numbers in the mix.
      *
      * @param mixed $sortArray The range of cells being sorted
      * @param mixed $args
@@ -87,8 +103,16 @@ class Sort extends LookupRefValidations
     public static function sortBy(mixed $sortArray, mixed ...$args): mixed
     {
         if (!is_array($sortArray)) {
-            // Scalars are always returned "as is"
-            return $sortArray;
+            $sortArray = [[$sortArray]];
+        }
+        $transpose = false;
+        $args0 = $args[0] ?? null;
+        if (is_array($args0) && count($args0) === 1) {
+            $args0 = reset($args0);
+            if (is_array($args0) && count($args0) > 1) {
+                $transpose = true;
+                $sortArray = Matrix::transpose($sortArray);
+            }
         }
 
         $sortArray = self::enumerateArrayKeys($sortArray);
@@ -99,14 +123,23 @@ class Sort extends LookupRefValidations
         try {
             $sortBy = $sortOrder = [];
             for ($i = 0; $i < $argumentCount; $i += 2) {
-                $sortBy[] = self::validateSortVector($args[$i], $lookupArraySize);
+                $argsI = $args[$i];
+                if (!is_array($argsI)) {
+                    $argsI = [[$argsI]];
+                }
+                $sortBy[] = self::validateSortVector($argsI, $lookupArraySize);
                 $sortOrder[] = self::validateSortOrder($args[$i + 1] ?? self::ORDER_ASCENDING);
             }
         } catch (Exception $e) {
             return $e->getMessage();
         }
 
-        return self::processSortBy($sortArray, $sortBy, $sortOrder);
+        $temp = self::processSortBy($sortArray, $sortBy, $sortOrder);
+        if ($transpose) {
+            $temp = Matrix::transpose($temp);
+        }
+
+        return $temp;
     }
 
     /**
@@ -130,10 +163,6 @@ class Sort extends LookupRefValidations
 
     private static function validateScalarArgumentsForSort(mixed &$sortIndex, mixed &$sortOrder, int $sortArraySize): void
     {
-        if (is_array($sortIndex) || is_array($sortOrder)) {
-            throw new Exception(ExcelError::VALUE());
-        }
-
         $sortIndex = self::validatePositiveInt($sortIndex, false);
 
         if ($sortIndex > $sortArraySize) {
@@ -143,13 +172,13 @@ class Sort extends LookupRefValidations
         $sortOrder = self::validateSortOrder($sortOrder);
     }
 
-    /** @return mixed[] */
-    private static function validateSortVector(mixed $sortVector, int $sortArraySize): array
+    /**
+     * @param mixed[] $sortVector
+     *
+     * @return mixed[]
+     */
+    private static function validateSortVector(array $sortVector, int $sortArraySize): array
     {
-        if (!is_array($sortVector)) {
-            throw new Exception(ExcelError::VALUE());
-        }
-
         // It doesn't matter if it's a row or a column vectors, it works either way
         $sortVector = Functions::flattenArray($sortVector);
         if (count($sortVector) !== $sortArraySize) {
@@ -203,12 +232,19 @@ class Sort extends LookupRefValidations
      */
     private static function prepareSortVectorValues(array $sortVector): array
     {
-        // Strings should be sorted case-insensitive; with booleans converted to locale-strings
+        // Strings should be sorted case-insensitive.
+        // Booleans are a complete mess. Excel always seems to sort
+        // booleans in a mixed vector at either the top or the bottom,
+        // so converting them to string or int doesn't really work.
+        // Best advice is to use them in a boolean-only vector.
+        // Code below chooses int conversion, which is sensible,
+        // and, as a bonus, compatible with LibreOffice.
         return array_map(
             function ($value) {
                 if (is_bool($value)) {
-                    return ($value) ? Calculation::getTRUE() : Calculation::getFALSE();
-                } elseif (is_string($value)) {
+                    return (int) $value;
+                }
+                if (is_string($value)) {
                     return StringHelper::strToLower($value);
                 }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/TorowTocol.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/TorowTocol.php
index 60b40d9..60945a2 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/TorowTocol.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/TorowTocol.php
@@ -80,7 +80,7 @@ class TorowTocol
                         continue;
                     }
                     $cell = 0;
-                } elseif (ErrorValue::isError($cell)) {
+                } elseif (ErrorValue::isError($cell, true)) {
                     if ($ignore === 2 || $ignore === 3) {
                         continue;
                     }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php
index 40a9df7..aec06a0 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Unique.php
@@ -40,7 +40,7 @@ class Unique
         //      so implode each row into a single string value
         array_walk(
             $lookupVector,
-            //* @phpstan-ignore-next-line
+            // @phpstan-ignore argument.type (not sure what is needed)
             function (array &$value): void {
                 $valuex = '';
                 $separator = '';
@@ -79,7 +79,7 @@ class Unique
                     if (str_ends_with($stringValue, "\x01")) {
                         // x01 should only end a string which is otherwise a float or int,
                         // so phpstan is technically correct but what it fears should not happen.
-                        $stringValue = 0 + substr($stringValue, 0, -1); //@phpstan-ignore-line
+                        $stringValue = 0 + substr($stringValue, 0, -1); //@phpstan-ignore binaryOp.invalid (stringValue might not be numeric?)
                     }
                 }
             }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Vstack.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Vstack.php
index 919c262..ce2fe63 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Vstack.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/Vstack.php
@@ -20,7 +20,7 @@ class Vstack
             if (!is_array($matrix)) {
                 $count = 1;
             } else {
-                $count = count(reset($matrix)); //* @phpstan-ignore-line
+                $count = count(reset($matrix)); //* @phpstan-ignore argument.type (reset can theoretically return mixed not array)
             }
             $columns = max($columns, $count);
         }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php
new file mode 100644
index 0000000..8e074a3
--- /dev/null
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/LookupRef/XLookup.php
@@ -0,0 +1,275 @@
+getMessage();
+        }
+
+        if (!in_array($matchMode, [0, -1, 1, 2], true)) {
+            return ExcelError::VALUE();
+        }
+
+        if (count($lookupArray) !== count($returnArray)) {
+            return ExcelError::VALUE();
+        }
+
+        try {
+            $index = match ($searchMode) {
+                1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, false),
+                -1 => self::searchLinear($lookupValue, $lookupArray, $matchMode, true),
+                2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, true),
+                -2 => self::searchBinary($lookupValue, $lookupArray, $matchMode, false),
+            };
+        } catch (UnhandledMatchError) {
+            return ExcelError::VALUE();
+        }
+
+        return ($index === null) ? $ifNotFound : $returnArray[$index];
+    }
+
+    // ---------------------------------------------------------------------------
+    // Search strategies
+    // ---------------------------------------------------------------------------
+
+    /**
+     * Linear search (searchMode 1 and -1).
+     *
+     * @param mixed[] $lookupArray
+     */
+    private static function searchLinear(
+        mixed $lookupValue,
+        array $lookupArray,
+        int $matchMode,
+        bool $reverse
+    ): ?int {
+
+        $keys = array_keys($lookupArray);
+        if ($reverse) {
+            $keys = array_reverse($keys);
+        }
+
+        $bestIdx = null;
+        $bestVal = null;
+
+        foreach ($keys as $i) {
+            /** @var scalar */
+            $candidate = $lookupArray[$i];
+
+            if ($matchMode === 2) {
+                // Wildcard: convert Excel wildcards to PHP regex
+                /** @var scalar $lookupValue */
+                if (self::wildcardMatch((string) $lookupValue, (string) $candidate)) {
+                    return $i;
+                }
+
+                continue;
+            }
+
+            $cmp = self::compareValues($candidate, $lookupValue);
+
+            if ($cmp === 0) {
+                return $i; // Exact match — return immediately
+            }
+
+            if ($matchMode === -1 && $cmp < 0) {
+                // Next smaller: track largest value still below lookupValue
+                if ($bestVal === null || self::compareValues($candidate, $bestVal) > 0) {
+                    $bestVal = $candidate;
+                    $bestIdx = $i;
+                }
+            }
+
+            if ($matchMode === 1 && $cmp > 0) {
+                // Next larger: track smallest value still above lookupValue
+                if ($bestVal === null || self::compareValues($candidate, $bestVal) < 0) {
+                    $bestVal = $candidate;
+                    $bestIdx = $i;
+                }
+            }
+        }
+        /** @var ?int $bestIdx */
+
+        return $bestIdx;
+    }
+
+    /**
+     * Binary search (searchMode 2 and -2)
+     * Assumes array is sorted ascending (searchMode 2) or descending (searchMode -2).
+     *
+     * @param mixed[] $lookupArray
+     */
+    private static function searchBinary(
+        mixed $lookupValue,
+        array $lookupArray,
+        int $matchMode,
+        bool $ascending
+    ): ?int {
+
+        $values = array_values($lookupArray);
+        $keys = array_keys($lookupArray);
+        $lo = 0;
+        $hi = count($values) - 1;
+        $bestIdx = null;
+
+        while ($lo <= $hi) {
+            $mid = intdiv($lo + $hi, 2);
+            $cmp = self::compareValues($values[$mid], $lookupValue);
+
+            if (!$ascending) {
+                $cmp = -$cmp; // Flip for descending
+            }
+
+            if ($cmp === 0) {
+                return $keys[$mid]; // Exact match
+            }
+
+            if ($cmp < 0) {
+                if ($matchMode === -1) {
+                    $bestIdx = $keys[$mid]; // Candidate for next smaller
+                }
+                $lo = $mid + 1;
+            } else {
+                if ($matchMode === 1) {
+                    $bestIdx = $keys[$mid]; // Candidate for next larger
+                }
+                $hi = $mid - 1;
+            }
+        }
+        /** @var int $bestIdx */
+
+        return ($matchMode !== 0) ? $bestIdx : null;
+    }
+
+    /**
+     * Compare two values with type coercion matching Excel's behaviour:
+     * numbers < strings < booleans
+     */
+    private static function compareValues(mixed $a, mixed $b): int
+    {
+        // Numeric comparison
+        if (is_numeric($a) && is_numeric($b)) {
+            return $a <=> $b;
+        }
+        // String comparison (case-insensitive, like Excel)
+        if (is_string($a) && is_string($b)) {
+            return strcasecmp($a, $b);
+        }
+        // Bool comparison
+        if (is_bool($a) && is_bool($b)) {
+            return $a <=> $b;
+        }
+        // Cross-type: number < string < bool
+        $typeOrder = fn ($v) => match (true) {
+            is_numeric($v) => 0,
+            is_string($v) => 1,
+            is_bool($v) => 2,
+            default => 3,
+        };
+
+        return $typeOrder($a) <=> $typeOrder($b);
+    }
+
+    /**
+     * Wildcard match (matchMode 2)
+     * Supports Excel wildcards: * (any sequence), ? (any single char), ~ (escape).
+     */
+    private static function wildcardMatch(string $pattern, string $subject): bool
+    {
+        // Handle ~* and ~? escapes first
+        $regex = '';
+        $len = strlen($pattern);
+        for ($i = 0; $i < $len; ++$i) {
+            $ch = $pattern[$i];
+            if ($ch === '~' && $i + 1 < $len) {
+                $next = $pattern[++$i];
+                $regex .= preg_quote($next, '/');
+            } elseif ($ch === '*') {
+                $regex .= '.*';
+            } elseif ($ch === '?') {
+                $regex .= '.';
+            } else {
+                $regex .= preg_quote($ch, '/');
+            }
+        }
+
+        return (bool) preg_match('/^' . $regex . '$/i', $subject);
+    }
+}
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Arabic.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Arabic.php
index 47c2c98..04549a1 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Arabic.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Arabic.php
@@ -57,7 +57,7 @@ class Arabic
      *
      * @param string|string[] $roman Should be a string, or can be an array of strings
      *
-     * @return array|int|string the arabic numberal contrived from the roman numeral
+     * @return array|int|string the arabic numeral contrived from the roman numeral
      *         If an array of numbers is passed as the argument, then the returned result will also be an array
      *            with the same dimensions
      */
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Round.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Round.php
index d8e338c..130636f 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Round.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Round.php
@@ -73,7 +73,7 @@ class Round
             return round(
                 (float) (string) $number,
                 $digits,
-                RoundingMode::AwayFromZero //* @phpstan-ignore-line
+                RoundingMode::AwayFromZero //* @phpstan-ignore argument.type (phpstan error till we no longer support Php < 8.4)
             );
         }
 
@@ -119,7 +119,7 @@ class Round
             return round(
                 (float) (string) $number,
                 $digits,
-                RoundingMode::TowardsZero //* @phpstan-ignore-line
+                RoundingMode::TowardsZero
             );
         }
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Subtotal.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Subtotal.php
index 09f9cfa..cec1ff1 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Subtotal.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Subtotal.php
@@ -32,6 +32,25 @@ class Subtotal
         );
     }
 
+    /**
+     * @param mixed[] $args
+     *
+     * @return mixed[]
+     */
+    protected static function filterFilteredArgs(Cell $cellReference, array $args): array
+    {
+        return array_filter(
+            $args,
+            function ($index) use ($cellReference) {
+                $explodeArray = explode('.', $index);
+                $row = $explodeArray[1] ?? '';
+
+                return is_numeric($row) ? ($cellReference->getWorksheet()->getRowDimension((int) $row)->getVisibleAfterFilter()) : true;
+            },
+            ARRAY_FILTER_USE_KEY
+        );
+    }
+
     /**
      * @param mixed[] $args
      *
@@ -125,13 +144,18 @@ class Subtotal
         if ($subtotal > 100) {
             $aArgs = self::filterHiddenArgs($cellReference, $aArgs);
             $subtotal -= 100;
+        } else {
+            $aArgs = self::filterFilteredArgs($cellReference, $aArgs);
         }
 
         $aArgs = self::filterFormulaArgs($cellReference, $aArgs);
         if (array_key_exists($subtotal, self::CALL_FUNCTIONS)) {
             $call = self::CALL_FUNCTIONS[$subtotal];
 
-            return call_user_func_array($call, $aArgs); //* @phpstan-ignore-line
+            /** @var float|int|string */
+            $temp = call_user_func_array($call, $aArgs);
+
+            return $temp;
         }
 
         return ExcelError::VALUE();
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Sum.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Sum.php
index b5d0339..0bbff73 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Sum.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/MathTrig/Sum.php
@@ -59,7 +59,7 @@ class Sum
                 $returnValue += $arg;
             } elseif (is_bool($arg)) {
                 $returnValue += (int) $arg;
-            } elseif (ErrorValue::isError($arg)) {
+            } elseif (ErrorValue::isError($arg, true)) {
                 /** @var string $arg */
                 return $arg;
             } elseif ($arg !== null && !Functions::isCellValue($k)) {
@@ -110,6 +110,7 @@ class Sum
             }
         }
 
+        /** @var array $wrkArray */
         return array_sum($wrkArray);
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Averages.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Averages.php
index 5880982..e9e7bd2 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Averages.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Averages.php
@@ -163,7 +163,8 @@ class Averages extends AggregateBase
             sort($aArgs, SORT_NUMERIC);
             $valueCount = $valueCount / 2;
             if ($valueCount == floor($valueCount)) {
-                $returnValue = ($aArgs[$valueCount--] + $aArgs[$valueCount]) / 2; //* @phpstan-ignore-line
+                $valueCount = (int) $valueCount;
+                $returnValue = ($aArgs[$valueCount--] + $aArgs[$valueCount]) / 2;
             } else {
                 $valueCount = (int) floor($valueCount);
                 $returnValue = $aArgs[$valueCount];
@@ -243,7 +244,7 @@ class Averages extends AggregateBase
                         $maxfreqkey = $key;
                         $maxfreqdatum = $datum;
                     } elseif ($freq == $maxfreq) {
-                        if ($frequencyArray[$key]['index'] < $frequencyArray[$maxfreqkey]['index']) { //* @phpstan-ignore-line
+                        if ($frequencyArray[$key]['index'] < $frequencyArray[$maxfreqkey]['index']) { //* @phpstan-ignore offsetAccess.notFound (not sure what phpstan wants)
                             $maxfreqkey = $key;
                             $maxfreqdatum = $datum;
                         }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Conditional.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Conditional.php
index 818d9db..7ac5ec1 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Conditional.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Conditional.php
@@ -10,6 +10,7 @@ use PhpOffice\PhpSpreadsheet\Calculation\Database\DSum;
 use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcException;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
+use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
 class Conditional
 {
@@ -26,11 +27,14 @@ class Conditional
      *        AVERAGEIF(range,condition[, average_range])
      *
      * @param mixed $range Data values, expect array
-     * @param null|mixed[]|string $condition the criteria that defines which cells will be checked
+     * @param mixed $condition the criteria that defines which cells will be checked, expect null|mixed[]|string
      * @param mixed $averageRange Data values
      */
-    public static function AVERAGEIF(mixed $range, null|array|string $condition, mixed $averageRange = []): null|int|float|string
+    public static function AVERAGEIF(mixed $range, mixed $condition, mixed $averageRange = []): null|int|float|string
     {
+        if ($condition !== null && !is_array($condition)) {
+            $condition = StringHelper::convertToString($condition);
+        }
         if (!is_array($range) || !is_array($averageRange) || array_key_exists(0, $range) || array_key_exists(0, $averageRange)) {
             $refError = ExcelError::REF();
             if (in_array($refError, [$range, $averageRange], true)) {
@@ -62,7 +66,7 @@ class Conditional
             return 0.0;
         }
         if (count($args) === 3) {
-            return self::AVERAGEIF($args[1], $args[2], $args[0]); //* @phpstan-ignore-line
+            return self::AVERAGEIF($args[1], $args[2], $args[0]);
         }
         foreach ($args as $arg) {
             if (is_array($arg) && array_key_exists(0, $arg)) {
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Beta.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Beta.php
index 378ce8a..dbe64d3 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Beta.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Beta.php
@@ -28,9 +28,9 @@ class Beta
      *                      Or can be an array of values
      * @param mixed $beta Parameter to the distribution as a float
      *                      Or can be an array of values
-     * @param mixed $rMin as an float
+     * @param mixed $rMin as a float
      *                      Or can be an array of values
-     * @param mixed $rMax as an float
+     * @param mixed $rMax as a float
      *                      Or can be an array of values
      *
      * @return array|float|string If an array of numbers is passed as an argument, then the returned result will also be an array
@@ -116,6 +116,11 @@ class Beta
         if (($alpha <= 0) || ($beta <= 0) || ($rMin == $rMax) || ($probability <= 0.0)) {
             return ExcelError::NAN();
         }
+        if (($alpha + $beta) > self::LOG_GAMMA_X_MAX_VALUE) {
+            // incompleteBeta declines to evaluate here and returns 0 for every x,
+            // so there is no quantile to search for.
+            return ExcelError::NAN();
+        }
 
         return self::calculateInverse($probability, $alpha, $beta, $rMin, $rMax);
     }
@@ -130,7 +135,7 @@ class Beta
         while ((($b - $a) > Functions::PRECISION) && (++$i <= self::MAX_ITERATIONS)) {
             $guess = ($a + $b) / 2;
             $result = self::distribution($guess, $alpha, $beta);
-            if (($result === $probability) || ($result === 0.0)) {
+            if ($result === $probability) {
                 $b = $a;
             } elseif ($result > $probability) {
                 $b = $guess;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Binomial.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Binomial.php
index 67cf402..948e640 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Binomial.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Binomial.php
@@ -65,7 +65,7 @@ class Binomial
     /**
      * BINOM.DIST.RANGE.
      *
-     * Returns returns the Binomial Distribution probability for the number of successes from a specified number
+     * Returns the Binomial Distribution probability for the number of successes from a specified number
      *     of trials falling into a specified range.
      *
      * @param mixed $trials Integer number of trials
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/ChiSquared.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/ChiSquared.php
index b132484..0ce1547 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/ChiSquared.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/ChiSquared.php
@@ -50,7 +50,7 @@ class ChiSquared
             return ExcelError::NAN();
         }
 
-        return 1 - (Gamma::incompleteGamma($degrees / 2, $value / 2) / Gamma::gammaValue($degrees / 2));
+        return Gamma::regularizedGammaQ($degrees / 2, $value / 2);
     }
 
     /**
@@ -99,8 +99,16 @@ class ChiSquared
             return 1 - (is_numeric($temp) ? $temp : 0);
         }
 
-        return ($value ** (($degrees / 2) - 1) * exp(-$value / 2))
-            / ((2 ** ($degrees / 2)) * Gamma::gammaValue($degrees / 2));
+        if ($value == 0.0) {
+            if ($degrees === 2) {
+                return 0.5;
+            }
+
+            return ($degrees === 1) ? INF : 0.0;
+        }
+
+        // Log domain, so large degrees of freedom cannot overflow Gamma(d/2).
+        return exp((($degrees / 2) - 1) * log($value) - $value / 2 - ($degrees / 2) * M_LN2 - Gamma::logGamma($degrees / 2));
     }
 
     /**
@@ -133,8 +141,7 @@ class ChiSquared
             return ExcelError::NAN();
         }
 
-        $callback = fn (float $value): float => 1 - (Gamma::incompleteGamma($degrees / 2, $value / 2)
-                    / Gamma::gammaValue($degrees / 2));
+        $callback = fn (float $value): float => Gamma::regularizedGammaQ($degrees / 2, $value / 2);
 
         $newtonRaphson = new NewtonRaphson($callback);
 
@@ -258,75 +265,6 @@ class ChiSquared
 
     private static function pchisq(float $chi2, int $degrees): float
     {
-        return self::gammp($degrees, 0.5 * $chi2);
-    }
-
-    private static function gammp(int $n, float $x): float
-    {
-        if ($x < 0.5 * $n + 1) {
-            return self::gser($n, $x);
-        }
-
-        return 1 - self::gcf($n, $x);
-    }
-
-    // Return the incomplete gamma function P(n/2,x) evaluated by
-    // series representation. Algorithm from numerical recipe.
-    // Assume that n is a positive integer and x>0, won't check arguments.
-    // Relative error controlled by the eps parameter
-    private static function gser(int $n, float $x): float
-    {
-        /** @var float $gln */
-        $gln = Gamma::ln($n / 2);
-        $a = 0.5 * $n;
-        $ap = $a;
-        $sum = 1.0 / $a;
-        $del = $sum;
-        for ($i = 1; $i < 101; ++$i) {
-            ++$ap;
-            $del = $del * $x / $ap;
-            $sum += $del;
-            if ($del < $sum * self::EPS) {
-                break;
-            }
-        }
-
-        return $sum * exp(-$x + $a * log($x) - $gln);
-    }
-
-    // Return the incomplete gamma function Q(n/2,x) evaluated by
-    // its continued fraction representation. Algorithm from numerical recipe.
-    // Assume that n is a postive integer and x>0, won't check arguments.
-    // Relative error controlled by the eps parameter
-    private static function gcf(int $n, float $x): float
-    {
-        /** @var float $gln */
-        $gln = Gamma::ln($n / 2);
-        $a = 0.5 * $n;
-        $b = $x + 1 - $a;
-        $fpmin = 1.e-300;
-        $c = 1 / $fpmin;
-        $d = 1 / $b;
-        $h = $d;
-        for ($i = 1; $i < 101; ++$i) {
-            $an = -$i * ($i - $a);
-            $b += 2;
-            $d = $an * $d + $b;
-            if (abs($d) < $fpmin) {
-                $d = $fpmin;
-            }
-            $c = $b + $an / $c;
-            if (abs($c) < $fpmin) {
-                $c = $fpmin;
-            }
-            $d = 1 / $d;
-            $del = $d * $c;
-            $h = $h * $del;
-            if (abs($del - 1) < self::EPS) {
-                break;
-            }
-        }
-
-        return $h * exp(-$x + $a * log($x) - $gln);
+        return Gamma::regularizedGammaP($degrees / 2, 0.5 * $chi2);
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/F.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/F.php
index 0127565..e615355 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/F.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/F.php
@@ -55,9 +55,20 @@ class F
             return Beta::incompleteBeta($adjustedValue, $u / 2, $v / 2);
         }
 
-        return (Gamma::gammaValue(($v + $u) / 2)
-                / (Gamma::gammaValue($u / 2) * Gamma::gammaValue($v / 2)))
-            * (($u / $v) ** ($u / 2))
-            * (($value ** (($u - 2) / 2)) / ((1 + ($u / $v) * $value) ** (($u + $v) / 2)));
+        if ($value == 0.0) {
+            if ($u === 2) {
+                return 1.0;
+            }
+
+            return ($u === 1) ? INF : 0.0;
+        }
+
+        // Log domain, so large degrees of freedom cannot overflow the Gamma ratio.
+        return exp(
+            Gamma::logGamma(($v + $u) / 2) - Gamma::logGamma($u / 2) - Gamma::logGamma($v / 2)
+            + ($u / 2) * log($u / $v)
+            + (($u - 2) / 2) * log($value)
+            - (($u + $v) / 2) * log(1 + ($u / $v) * $value)
+        );
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Gamma.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Gamma.php
index 60f5309..284c89d 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Gamma.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Gamma.php
@@ -85,6 +85,10 @@ class Gamma extends GammaBase
      *
      * Returns the inverse of the Gamma distribution.
      *
+     * For a probability so far into the upper tail that the forward-CDF series
+     * approximation plateaus below it, the root cannot be bracketed; the result
+     * is capped at the alpha*beta*5 search ceiling rather than diverging.
+     *
      * @param mixed $probability Float probability at which you want to evaluate the distribution
      *                      Or can be an array of values
      * @param mixed $alpha Parameter to the distribution as a float
@@ -143,6 +147,9 @@ class Gamma extends GammaBase
             return ExcelError::NAN();
         }
 
-        return log(self::gammaValue($value));
+        $result = self::logGamma($value);
+
+        // logGamma returns its MAX_VALUE sentinel when the result would overflow.
+        return ($result >= self::MAX_VALUE) ? ExcelError::NAN() : $result;
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/GammaBase.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/GammaBase.php
index 4ae6075..e1d5539 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/GammaBase.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/GammaBase.php
@@ -11,7 +11,7 @@ abstract class GammaBase
 
     private const EPS = 2.22e-16;
 
-    private const MAX_VALUE = 1.2e308;
+    protected const MAX_VALUE = 1.2e308;
 
     private const SQRT2PI = 2.5066282746310005024157652848110452530069867406099;
 
@@ -20,10 +20,19 @@ abstract class GammaBase
     protected static function calculateDistribution(float $value, float $a, float $b, bool $cumulative): float
     {
         if ($cumulative) {
-            return self::incompleteGamma($a, $value / $b) / self::gammaValue($a);
+            return self::regularizedGammaP($a, $value / $b);
         }
 
-        return (1 / ($b ** $a * self::gammaValue($a))) * $value ** ($a - 1) * exp(0 - ($value / $b));
+        if ($value == 0.0) {
+            if ($a == 1.0) {
+                return 1.0 / $b;
+            }
+
+            return ($a < 1.0) ? INF : 0.0;
+        }
+
+        // Log domain, so large shape parameters cannot overflow Gamma(a).
+        return exp(($a - 1.0) * log($value) - $value / $b - $a * log($b) - self::logGamma($a));
     }
 
     /** @return float|string */
@@ -32,6 +41,24 @@ abstract class GammaBase
         $xLo = 0;
         $xHi = $alpha * $beta * 5;
 
+        // Extend the upper bound while it does not yet bracket the root, so a
+        // tail quantile beyond alpha*beta*5 is no longer clamped to it. Stop if
+        // the CDF stops increasing (series approximation past its usable range)
+        // and keep the original bound rather than expanding into that region.
+        $xHiBase = $xHi;
+        $cdfHi = self::calculateDistribution($xHi, $alpha, $beta, true);
+        while ($cdfHi < $probability) {
+            $xHiNext = $xHi * 2;
+            $cdfNext = self::calculateDistribution($xHiNext, $alpha, $beta, true);
+            if ($cdfNext <= $cdfHi) {
+                $xHi = $xHiBase;
+
+                break;
+            }
+            $xHi = $xHiNext;
+            $cdfHi = $cdfNext;
+        }
+
         $dx = 1024;
         $x = $xNew = 1;
         $i = 0;
@@ -78,17 +105,98 @@ abstract class GammaBase
     //
     public static function incompleteGamma(float $a, float $x): float
     {
-        static $max = 32;
-        $summer = 0;
-        for ($n = 0; $n <= $max; ++$n) {
-            $divisor = $a;
-            for ($i = 1; $i <= $n; ++$i) {
-                $divisor *= ($a + $i);
-            }
-            $summer += ($x ** $n / $divisor);
+        // Unregularized lower incomplete gamma; kept for backward compatibility.
+        return self::regularizedGammaP($a, $x) * self::gammaValue($a);
+    }
+
+    /**
+     * Regularized lower incomplete gamma P(a,x) = gamma(a,x) / Gamma(a).
+     * Series for x < a+1, else the complement of the continued fraction.
+     */
+    public static function regularizedGammaP(float $a, float $x): float
+    {
+        if ($x <= 0.0 || $a <= 0.0) {
+            return 0.0;
+        }
+        if ($x < $a + 1.0) {
+            return self::gammaSeries($a, $x);
         }
 
-        return $x ** $a * exp(0 - $x) * $summer;
+        return 1.0 - self::gammaContinuedFraction($a, $x);
+    }
+
+    /**
+     * Regularized upper incomplete gamma Q(a,x) = 1 - P(a,x).
+     * Continued fraction for x >= a+1 keeps the right tail free of cancellation.
+     */
+    public static function regularizedGammaQ(float $a, float $x): float
+    {
+        if ($x <= 0.0 || $a <= 0.0) {
+            return 1.0;
+        }
+        if ($x < $a + 1.0) {
+            return 1.0 - self::gammaSeries($a, $x);
+        }
+
+        return self::gammaContinuedFraction($a, $x);
+    }
+
+    // Near x ~ a both expansions need O(sqrt(a)) terms to reach EPS.
+    private static function incompleteGammaIterations(float $a): int
+    {
+        return max(self::MAX_ITERATIONS, (int) ceil(10.0 * sqrt($a)));
+    }
+
+    // P(a,x) by its series representation (Numerical Recipes gser).
+    private static function gammaSeries(float $a, float $x): float
+    {
+        $maxIterations = self::incompleteGammaIterations($a);
+        $gln = self::logGamma($a);
+        $ap = $a;
+        $sum = 1.0 / $a;
+        $del = $sum;
+        for ($i = 1; $i <= $maxIterations; ++$i) {
+            ++$ap;
+            $del *= $x / $ap;
+            $sum += $del;
+            if (abs($del) < abs($sum) * self::EPS) {
+                break;
+            }
+        }
+
+        return $sum * exp(-$x + $a * log($x) - $gln);
+    }
+
+    // Q(a,x) by its continued fraction representation (Numerical Recipes gcf).
+    private static function gammaContinuedFraction(float $a, float $x): float
+    {
+        $maxIterations = self::incompleteGammaIterations($a);
+        $fpMin = 1.0e-300;
+        $gln = self::logGamma($a);
+        $b = $x + 1.0 - $a;
+        $c = 1.0 / $fpMin;
+        $d = 1.0 / $b;
+        $h = $d;
+        for ($i = 1; $i <= $maxIterations; ++$i) {
+            $an = -$i * ($i - $a);
+            $b += 2.0;
+            $d = $an * $d + $b;
+            if (abs($d) < $fpMin) {
+                $d = $fpMin;
+            }
+            $c = $b + $an / $c;
+            if (abs($c) < $fpMin) {
+                $c = $fpMin;
+            }
+            $d = 1.0 / $d;
+            $del = $d * $c;
+            $h *= $del;
+            if (abs($del - 1.0) < self::EPS) {
+                break;
+            }
+        }
+
+        return $h * exp(-$x + $a * log($x) - $gln);
     }
 
     private const GAMMA_VALUE_P0 = 1.000000000190015;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Normal.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Normal.php
index 7e2ee92..5a0a7eb 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Normal.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/Normal.php
@@ -116,7 +116,7 @@ class Normal
         //    whatever purpose you want, but please show common courtesy and give credit
         //    where credit is due.
 
-        //    Input paramater is $p - probability - where 0 < p < 1.
+        //    Input parameter is $p - probability - where 0 < p < 1.
 
         //    Coefficients in rational approximations
         /** @var array */
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/StudentT.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/StudentT.php
index cbe7d84..db1af8f 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/StudentT.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Distributions/StudentT.php
@@ -4,9 +4,15 @@ namespace PhpOffice\PhpSpreadsheet\Calculation\Statistical\Distributions;
 
 use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
 use PhpOffice\PhpSpreadsheet\Calculation\Exception;
-use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
 
+/**
+ * Some of this code is drived from Perl CPAN Statistical::Distributions.
+ * Its copyright statement is:
+ * Copyright 2003 Michael Kospach. All rights reserved.
+ *
+ * This library is free software; you can redistribute it and/or modify it under the same terms as Perl itself.
+ */
 class StudentT
 {
     use ArrayEnabled;
@@ -28,9 +34,39 @@ class StudentT
      *            with the same dimensions
      */
     public static function distribution(mixed $value, mixed $degrees, mixed $tails)
+    {
+        return self::calcDistribution($value, $degrees, $tails, self::distribution(...));
+    }
+
+    /**
+     * T.DIST.2T.
+     * Returns the two-tailed Student's t distribution.
+     *
+     * @return array|float|string The result, or a string containing an error
+     */
+    public static function tDotDistDot2T(mixed $value, mixed $degrees)
+    {
+        return self::calcDistribution($value, $degrees, 2, self::distribution(...));
+    }
+
+    /**
+     * T.DIST.RT.
+     * Returns the right-tailed Student's t distribution.
+     *
+     * @return array|float|string The result, or a string containing an error
+     */
+    public static function tDotDistDotRT(mixed $value, mixed $degrees)
+    {
+        return self::calcDistribution($value, $degrees, 1, self::distribution(...));
+    }
+
+    /**
+     * @return array|float|string The result, or a string containing an error
+     */
+    private static function calcDistribution(mixed $value, mixed $degrees, mixed $tails, callable $callback)
     {
         if (is_array($value) || is_array($degrees) || is_array($tails)) {
-            return self::evaluateArrayArguments([self::class, __FUNCTION__], $value, $degrees, $tails);
+            return self::evaluateArrayArguments($callback, $value, $degrees, $tails);
         }
 
         try {
@@ -45,13 +81,105 @@ class StudentT
             return ExcelError::NAN();
         }
 
-        return self::calculateDistribution($value, $degrees, $tails);
+        return self::subTProb($value, $degrees, $tails);
     }
 
     /**
-     * TINV.
+     * Based on code from Perl CPAN Statistical::Distributions.
+     */
+    private static function subTProb(float $x, int $n, int $tails): float
+    {
+        $w = atan2($x / sqrt($n), 1);
+        $z = cos($w) ** 2;
+        $y = 1;
+
+        for ($i = $n - 2; $i >= 2; $i -= 2) {
+            $y = 1 + ($i - 1) / $i * $z * $y;
+        }
+
+        if ($n % 2 == 0) {
+            $a = sin($w) / 2;
+            $b = 0.5;
+        } else {
+            $a = ($n == 1) ? 0 : (sin($w) * cos($w) / M_PI);
+            $b = 0.5 + $w / M_PI;
+        }
+
+        return $tails * max(0, 1 - $b - $a * $y);
+    }
+
+    /**
+     * T.DIST.
+     * Returns the Student's left-tailed t distribution,
+     * either as a cumulative distribution function (cdf) (TRUE)
+     * or as a probability density function (pdf) (FALSE),
+     * where TRUE/FALSE are the value of $cumulative parameter.
      *
-     * Returns the one-tailed probability of the chi-squared distribution.
+     * "True" algoritm adapted from java.
+     * org.apache.commons.math3.distribution.TDistribution.
+     * "False" algorithm comes from:
+     * https://statproofbook.github.io/P/t-pdf.html
+     *
+     * @param mixed $cumulative Expecting bool. See above for explanation.
+     *
+     * @return array|float|string The result, or a string containing an error
+     */
+    public static function tDotDist(mixed $value, mixed $degrees, mixed $cumulative)
+    {
+        if (is_array($value) || is_array($degrees) || is_array($cumulative)) {
+            return self::evaluateArrayArguments(self::tDotDist(...), $value, $degrees, $cumulative);
+        }
+
+        try {
+            $value = DistributionValidations::validateFloat($value);
+            $degrees = DistributionValidations::validateInt($degrees);
+            $cumulative = DistributionValidations::validateBool($cumulative);
+        } catch (Exception $e) {
+            return $e->getMessage();
+        }
+
+        /** @var int $degrees */
+        if (($degrees < 1)) {
+            return ExcelError::NAN();
+        }
+        /** @var float $value */
+        if (!$cumulative) {
+            return self::tDotDistFalse($value, $degrees);
+        }
+
+        $f16 = $degrees / ($degrees + $value * $value);
+        $g16 = 0.5 * $degrees;
+        $h16 = 0.5;
+        $result = Beta::distribution($f16, $g16, $h16);
+        if (is_numeric($result)) {
+            $result = ($value < 0) ? (0.5 * $result) : (1 - 0.5 * $result);
+        }
+
+        return $result;
+    }
+
+    private static function tDotDistFalse(float $value, int $degrees): float|string
+    {
+        $result = $k15 = Gamma::gamma(($degrees + 1) / 2);
+        if (is_numeric($k15)) {
+            $result = $k16 = Gamma::gamma($degrees / 2);
+            if (is_numeric($k16)) {
+                $k17 = sqrt(M_PI * $degrees);
+                $k18 = $k15 / ($k16 * $k17);
+                $k19 = $value * $value / $degrees + 1;
+                $k20 = -($degrees + 1) / 2;
+                $k21 = $k19 ** $k20;
+                $result = $k18 * $k21;
+            }
+        }
+
+        /** @var float|string $result */
+        return $result;
+    }
+
+    /**
+     * TINV and T.INV.2T.
+     * Returns the two-tailed inverse of the Student t distribution.
      *
      * @param mixed $probability Float probability for the function
      *                      Or can be an array of values
@@ -63,9 +191,17 @@ class StudentT
      *            with the same dimensions
      */
     public static function inverse(mixed $probability, mixed $degrees)
+    {
+        return self::calcInverse($probability, $degrees, 2, self::inverse(...));
+    }
+
+    /**
+     * @return array|float|string The result, or a string containing an error
+     */
+    private static function calcInverse(mixed $probability, mixed $degrees, int $tails, callable $callback2)
     {
         if (is_array($probability) || is_array($degrees)) {
-            return self::evaluateArrayArguments([self::class, __FUNCTION__], $probability, $degrees);
+            return self::evaluateArrayArguments($callback2, $probability, $degrees, $tails);
         }
 
         try {
@@ -79,54 +215,102 @@ class StudentT
             return ExcelError::NAN();
         }
 
-        $callback = fn ($value) => self::distribution($value, $degrees, 2);
+        $callback = fn ($value) => self::distribution($value, $degrees, $tails);
 
         $newtonRaphson = new NewtonRaphson($callback);
 
-        return $newtonRaphson->execute($probability);
+        $result = $newtonRaphson->execute($probability);
+        if (is_numeric($result) && $tails === 1) {
+            $result = -$result; // @codeCoverageIgnore
+        }
+
+        return $result;
     }
 
-    private static function calculateDistribution(float $value, int $degrees, int $tails): float
+    /**
+     * T.INV.
+     * Returns the left-tailed inverse of the Student's t distribution.
+     *
+     * Based on code from Perl CPAN Statistical::Distributions.
+     *
+     * @return array|float|string The result, or a string containing an error
+     */
+    public static function tDotInv(mixed $probability, mixed $degrees)
     {
-        //    tdist, which finds the probability that corresponds to a given value
-        //    of t with k degrees of freedom. This algorithm is translated from a
-        //    pascal function on p81 of "Statistical Computing in Pascal" by D
-        //    Cooke, A H Craven & G M Clark (1985: Edward Arnold (Pubs.) Ltd:
-        //    London). The above Pascal algorithm is itself a translation of the
-        //    fortran algoritm "AS 3" by B E Cooper of the Atlas Computer
-        //    Laboratory as reported in (among other places) "Applied Statistics
-        //    Algorithms", editied by P Griffiths and I D Hill (1985; Ellis
-        //    Horwood Ltd.; W. Sussex, England).
-        $tterm = $degrees;
-        $ttheta = atan2($value, sqrt($tterm));
-        $tc = cos($ttheta);
-        $ts = sin($ttheta);
-
-        if (($degrees % 2) === 1) {
-            $ti = 3;
-            $tterm = $tc;
-        } else {
-            $ti = 2;
-            $tterm = 1;
+        if (is_array($probability) || is_array($degrees)) {
+            return self::evaluateArrayArguments(self::tDotInv(...), $probability, $degrees);
         }
 
-        $tsum = $tterm;
-        while ($ti < $degrees) {
-            $tterm *= $tc * $tc * ($ti - 1) / $ti;
-            $tsum += $tterm;
-            $ti += 2;
+        try {
+            $probability = DistributionValidations::validateProbability($probability);
+            $degrees = DistributionValidations::validateInt($degrees);
+        } catch (Exception $e) {
+            return $e->getMessage();
         }
 
-        $tsum *= $ts;
-        if (($degrees % 2) == 1) {
-            $tsum = Functions::M_2DIVPI * ($tsum + $ttheta);
+        if ($degrees < 1) {
+            return ExcelError::NAN();
+        }
+        if ($probability == 0.5) {
+            return 0.0;
+        }
+        if ($probability < 0.5) {
+            $result = self::tDotInv(1.0 - $probability, $degrees);
+
+            return is_numeric($result) ? -$result : $result;
+        }
+        $p = $probability;
+        $n = $degrees;
+        $u = self::subU($p);
+        $u2 = $u ** 2;
+
+        $a = ($u2 + 1) / 4;
+        $b = ((5 * $u2 + 16) * $u2 + 3) / 96;
+        $c = (((3 * $u2 + 19) * $u2 + 17) * $u2 - 15) / 384;
+        $d = ((((79 * $u2 + 776) * $u2 + 1482) * $u2 - 1920) * $u2 - 945) / 92160;
+        $e = (((((27 * $u2 + 339) * $u2 + 930) * $u2 - 1782) * $u2 - 765) * $u2 + 17955) / 368640;
+
+        $x = $u * (1 + ($a + ($b + ($c + ($d + $e / $n) / $n) / $n) / $n) / $n);
+
+        if ($n <= log10($p) ** 2 + 3) {
+            do {
+                $p1 = self::subTProb($x, $n, 1);
+                $n1 = $n + 1;
+                $delta = ($p1 - $p)
+                    / exp(($n1 * log($n1 / ($n + $x * $x))
+                    + log($n / $n1 / 2 / M_PI) - 1
+                        + (1 / $n1 - 1 / $n) / 6) / 2);
+                $x += $delta;
+                $round = sprintf('%.' . abs((int) (log10(abs($x)) - 4)) . 'F', $delta);
+            } while (($x) && ($round != 0));
         }
 
-        $tValue = 0.5 * (1 + $tsum);
-        if ($tails == 1) {
-            return 1 - abs($tValue);
+        return -$x;
+    }
+
+    /**
+     * Based on code from Perl CPAN Statistical::Distributions.
+     */
+    private static function subU(float $p): float
+    {
+        $y = -log(4 * $p * (1 - $p));
+        $x = sqrt(
+            $y * (1.570796288
+              + $y * (.03706987906
+                + $y * (-.8364353589E-3
+                  + $y * (-.2250947176E-3
+                    + $y * (.6841218299E-5
+                      + $y * (0.5824238515E-5
+                        + $y * (-.104527497E-5
+                          + $y * (.8360937017E-7
+                            + $y * (-.3231081277E-8
+                              + $y * (.3657763036E-10
+                                + $y * .6936233982E-12))))))))))
+        );
+        if ($p > 0.5) {
+            $x = -$x;
         }
 
-        return 1 - abs((1 - $tValue) - $tValue);
+        return $x;
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Maximum.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Maximum.php
index 88531a5..3a4f9eb 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Maximum.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Maximum.php
@@ -25,7 +25,7 @@ class Maximum extends MaxMinBase
         // Loop through arguments
         $aArgs = Functions::flattenArray($args);
         foreach ($aArgs as $arg) {
-            if (ErrorValue::isError($arg)) {
+            if (ErrorValue::isError($arg, true)) {
                 $returnValue = $arg;
 
                 break;
@@ -63,7 +63,7 @@ class Maximum extends MaxMinBase
         // Loop through arguments
         $aArgs = Functions::flattenArray($args);
         foreach ($aArgs as $arg) {
-            if (ErrorValue::isError($arg)) {
+            if (ErrorValue::isError($arg, true)) {
                 $returnValue = $arg;
 
                 break;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Minimum.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Minimum.php
index 874edc3..0a1a0c0 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Minimum.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Minimum.php
@@ -25,7 +25,7 @@ class Minimum extends MaxMinBase
         // Loop through arguments
         $aArgs = Functions::flattenArray($args);
         foreach ($aArgs as $arg) {
-            if (ErrorValue::isError($arg)) {
+            if (ErrorValue::isError($arg, true)) {
                 $returnValue = $arg;
 
                 break;
@@ -63,7 +63,7 @@ class Minimum extends MaxMinBase
         // Loop through arguments
         $aArgs = Functions::flattenArray($args);
         foreach ($aArgs as $arg) {
-            if (ErrorValue::isError($arg)) {
+            if (ErrorValue::isError($arg, true)) {
                 $returnValue = $arg;
 
                 break;
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Percentiles.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Percentiles.php
index f2ee15f..0fb0749 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Percentiles.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Statistical/Percentiles.php
@@ -89,6 +89,7 @@ class Percentiles
             return $e->getMessage();
         }
 
+        /** @var array */
         $valueSet = self::rankFilterValues($valueSet);
         $valueCount = count($valueSet);
         if ($valueCount == 0) {
@@ -172,6 +173,7 @@ class Percentiles
             return $e->getMessage();
         }
 
+        /** @var array */
         $valueSet = self::rankFilterValues($valueSet);
         if ($order === self::RANK_SORT_DESCENDING) {
             rsort($valueSet, SORT_NUMERIC);
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php
index 2f15bfc..305f1b7 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/CharacterConvert.php
@@ -6,11 +6,14 @@ use PhpOffice\PhpSpreadsheet\Calculation\ArrayEnabled;
 use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalcExp;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
+use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
 class CharacterConvert
 {
     use ArrayEnabled;
 
+    private static string $oneByteCharacterSet = 'Windows-1252';
+
     /**
      * CHAR.
      *
@@ -27,19 +30,45 @@ class CharacterConvert
             return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $character);
         }
 
+        return self::characterBoth($character, true);
+    }
+
+    /** @return array|string */
+    public static function characterUnicode(mixed $character): array|string
+    {
+        if (is_array($character)) {
+            return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $character);
+        }
+
+        return self::characterBoth($character, false);
+    }
+
+    private static function characterBoth(mixed $character, bool $ansi = true): string
+    {
         try {
             $character = Helpers::validateInt($character, true);
         } catch (CalcExp $e) {
             return $e->getMessage();
         }
 
+        if ($ansi && $character === 219 && self::$oneByteCharacterSet[0] === 'M') {
+            return '€';
+        }
+
         $min = Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE ? 0 : 1;
-        if ($character < $min || $character > 255) {
+        if ($character < $min || ($ansi && $character > 255) || $character > 0x10FFFF) {
             return ExcelError::VALUE();
         }
-        $result = iconv('UCS-4LE', 'UTF-8', pack('V', $character));
+        if ($character > 0x10FFFD) { // last assigned
+            return ExcelError::NA();
+        }
+        if ($ansi) {
+            $result = chr($character);
 
-        return ($result === false) ? '' : $result;
+            return (string) iconv(self::$oneByteCharacterSet, 'UTF-8//IGNORE', $result);
+        }
+
+        return mb_chr($character, 'UTF-8');
     }
 
     /**
@@ -57,7 +86,28 @@ class CharacterConvert
         if (is_array($characters)) {
             return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $characters);
         }
+        if (is_bool($characters) && Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
+            $characters = $characters ? '1' : '0';
+        }
 
+        return self::codeBoth(StringHelper::convertToString($characters, convertBool: true), true);
+    }
+
+    /** @return array|int|string */
+    public static function codeUnicode(mixed $characters): array|string|int
+    {
+        if (is_array($characters)) {
+            return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $characters);
+        }
+        if (is_bool($characters) && Functions::getCompatibilityMode() === Functions::COMPATIBILITY_OPENOFFICE) {
+            $characters = $characters ? '1' : '0';
+        }
+
+        return self::codeBoth(StringHelper::convertToString($characters, convertBool: true), false);
+    }
+
+    private static function codeBoth(string $characters, bool $ansi = true): int|string
+    {
         try {
             $characters = Helpers::extractString($characters, true);
         } catch (CalcExp $e) {
@@ -72,22 +122,27 @@ class CharacterConvert
         if (mb_strlen($characters, 'UTF-8') > 1) {
             $character = mb_substr($characters, 0, 1, 'UTF-8');
         }
-
-        return self::unicodeToOrd($character);
-    }
-
-    private static function unicodeToOrd(string $character): int
-    {
-        $retVal = 0;
-        $iconv = iconv('UTF-8', 'UCS-4LE', $character);
-        if ($iconv !== false) {
-            /** @var false|int[] */
-            $result = unpack('V', $iconv);
-            if (is_array($result) && isset($result[1])) {
-                $retVal = $result[1];
-            }
+        if ($ansi && $character === '€' && self::$oneByteCharacterSet[0] === 'M') {
+            return 219;
         }
 
-        return $retVal;
+        $result = mb_ord($character, 'UTF-8');
+        if ($ansi) {
+            $result = iconv('UTF-8', self::$oneByteCharacterSet . '//IGNORE', $character);
+
+            return ($result !== '') ? ord("$result") : 63; // question mark
+        }
+
+        return $result;
+    }
+
+    public static function setWindowsCharacterSet(): void
+    {
+        self::$oneByteCharacterSet = 'Windows-1252';
+    }
+
+    public static function setMacCharacterSet(): void
+    {
+        self::$oneByteCharacterSet = 'MAC';
     }
 }
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php
index d51d44b..f377821 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Concatenate.php
@@ -7,6 +7,8 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation;
 use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ErrorValue;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
+use PhpOffice\PhpSpreadsheet\Calculation\Internal\ExcelArrayPseudoFunctions;
+use PhpOffice\PhpSpreadsheet\Cell\Cell;
 use PhpOffice\PhpSpreadsheet\Cell\DataType;
 use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
@@ -17,7 +19,7 @@ class Concatenate
     /**
      * This implements the CONCAT function, *not* CONCATENATE.
      *
-     * @param mixed[] $args
+     * @param mixed $args data to be concatenated
      */
     public static function CONCATENATE(...$args): string
     {
@@ -47,17 +49,33 @@ class Concatenate
     /**
      * This implements the CONCATENATE function.
      *
-     * @param mixed[] $args data to be concatenated
+     * @param mixed $args data to be concatenated
      *
      * @return array|string
      */
     public static function actualCONCATENATE(...$args): array|string
     {
+        $useSingle = false;
+        $cell = null;
+        $count = count($args);
+        if ($args[$count - 1] instanceof Cell) {
+            /** @var Cell */
+            $cell = array_pop($args);
+            $type = $cell->getWorksheet()->getParent()?->getCalculationEngine()->getInstanceArrayReturnType() ?? Calculation::getArrayReturnType();
+            $useSingle = $type === Calculation::RETURN_ARRAY_AS_VALUE;
+        }
         if (Functions::getCompatibilityMode() === Functions::COMPATIBILITY_GNUMERIC) {
             return self::CONCATENATE(...$args);
         }
         $result = '';
         foreach ($args as $operand2) {
+            if ($useSingle && $cell instanceof Cell && is_array($operand2)) {
+                $temp = Functions::convertArrayToCellRange($operand2);
+                if ($temp !== '') {
+                    $operand2 = ExcelArrayPseudoFunctions::single($temp, $cell);
+                }
+            }
+            /** @var null|array|bool|float|int|string $operand2 */
             $result = self::concatenate2Args($result, $operand2);
             if (ErrorValue::isError($result, true) === true) {
                 break;
@@ -142,6 +160,7 @@ class Concatenate
         $aArgs = Functions::flattenArray($args);
         $returnValue = self::evaluateTextJoinArray($ignoreEmpty, $aArgs);
 
+        /** @var string[] $aArgs */
         $returnValue ??= implode($delimiter, $aArgs);
         if (StringHelper::countCharacters($returnValue) > DataType::MAX_STRING_LENGTH) {
             $returnValue = ExcelError::CALC();
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Format.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Format.php
index 23bf74f..c6e37f4 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Format.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Format.php
@@ -96,16 +96,13 @@ class Format
         if ($decimals < 0) {
             $decimals = 0;
         }
-        if ($noCommas === false) {
-            $valueResult = number_format(
-                $valueResult,
-                $decimals,
-                StringHelper::getDecimalSeparator(),
-                StringHelper::getThousandsSeparator()
-            );
-        }
 
-        return (string) $valueResult;
+        return number_format(
+            $valueResult,
+            $decimals,
+            StringHelper::getDecimalSeparator(),
+            $noCommas ? '' : StringHelper::getThousandsSeparator()
+        );
     }
 
     /**
@@ -209,18 +206,20 @@ class Format
             Functions::setReturnDateType(Functions::RETURNDATE_EXCEL);
 
             if (str_contains($value, ':')) {
+                /** @var DateTimeInterface|float|int|string */
                 $timeValue = Functions::scalar(DateTimeExcel\TimeValue::fromString($value));
                 if ($timeValue !== ExcelError::VALUE()) {
                     Functions::setReturnDateType($dateSetting);
 
-                    return $timeValue; //* @phpstan-ignore-line
+                    return $timeValue;
                 }
             }
+            /** @var DateTimeInterface|float|int|string */
             $dateValue = Functions::scalar(DateTimeExcel\DateValue::fromString($value));
             if ($dateValue !== ExcelError::VALUE()) {
                 Functions::setReturnDateType($dateSetting);
 
-                return $dateValue; //* @phpstan-ignore-line
+                return $dateValue;
             }
             Functions::setReturnDateType($dateSetting);
 
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Search.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Search.php
index 0c4b977..0aad5ed 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Search.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Search.php
@@ -12,7 +12,7 @@ class Search
     use ArrayEnabled;
 
     /**
-     * FIND (case sensitive search).
+     * FIND (case-sensitive search).
      *
      * @param mixed $needle The string to look for
      *                         Or can be an array of values
@@ -54,7 +54,7 @@ class Search
     }
 
     /**
-     * SEARCH (case insensitive search).
+     * SEARCH (case-insensitive search).
      *
      * @param mixed $needle The string to look for
      *                         Or can be an array of values
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Text.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Text.php
index a4cc14f..40e3a17 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Text.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Text.php
@@ -171,12 +171,12 @@ class Text
     {
         $columnCount = array_reduce(
             $rows,
-            fn (int $counter, array $row): int => max($counter, count($row)), //* @phpstan-ignore-line
+            fn (int $counter, array $row): int => max($counter, count($row)), //* @phpstan-ignore argument.type (I don't know)
             0
         );
 
         return array_map(
-            fn (array $row): array => (count($row) < $columnCount) //* @phpstan-ignore-line
+            fn (array $row): array => (count($row) < $columnCount) //* @phpstan-ignore argument.type (I don't know)
                     ? array_merge($row, array_fill(0, $columnCount - count($row), $padding))
                     : $row,
             $rows
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Thai.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Thai.php
new file mode 100644
index 0000000..0d08db0
--- /dev/null
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/TextData/Thai.php
@@ -0,0 +1,139 @@
+ 'ศูนย์',
+        1 => 'หนึ่ง',
+        2 => 'สอง',
+        3 => 'สาม',
+        4 => 'สี่',
+        5 => 'ห้า',
+        6 => 'หก',
+        7 => 'เจ็ด',
+        8 => 'แปด',
+        9 => 'เก้า',
+    ];
+
+    private const THAI_UNITS = [
+        1 => 'สิบ',
+        2 => 'ร้อย',
+        3 => 'พัน',
+        4 => 'หมื่น',
+        5 => 'แสน',
+        6 => 'ล้าน',
+    ];
+
+    private const THAI_COMPOUND_ONE = 'เอ็ด';
+    private const THAI_COMPOUND_TWO = 'ยี่';
+    private const THAI_INTEGER = 'ถ้วน';
+    private const THAI_MINUS = 'ลบ';
+    private const THAI_BAHT = 'บาท';
+    private const THAI_SATANG = 'สตางค์';
+
+    /**
+     * BAHTTEXT.
+     *
+     * @param mixed $number The number or array of numbers to convert
+     *
+     * @return array|string If an array of values is passed as the argument, then the returned result will also be an array with the same dimensions
+     */
+    public static function getBahtText(mixed $number): array|string
+    {
+        if (is_array($number)) {
+            return self::evaluateSingleArgumentArray([self::class, __FUNCTION__], $number);
+        }
+
+        if (is_string($number) && preg_match('/^-?\d+$/', $number)) {
+            $isNegative = str_starts_with($number, '-');
+            $baht = ltrim($number, '-0') ?: '0';
+            $satang = '00';
+        } elseif (is_bool($number) || is_numeric($number)) {
+            $number += 0;
+            $isNegative = $number < 0;
+            [$baht, $satang] = explode('.', number_format(abs($number), 2, '.', ''));
+        } else {
+            return ExcelError::VALUE();
+        }
+
+        $hasWhole = $baht !== '0';
+        $hasFraction = $satang !== '00';
+
+        if (!$hasWhole && !$hasFraction) {
+            return self::THAI_DIGITS[0] . self::THAI_BAHT . self::THAI_INTEGER;
+        }
+
+        $text = $isNegative
+            ? self::THAI_MINUS
+            : '';
+
+        if ($hasWhole) {
+            $text .= self::convertLarge($baht) . self::THAI_BAHT;
+        }
+
+        $text .= $hasFraction
+            ? self::convertBlock($satang) . self::THAI_SATANG
+            : self::THAI_INTEGER;
+
+        return $text;
+    }
+
+    private static function convertLarge(string $digits): string
+    {
+        $length = strlen($digits) % 6 ?: 6;
+
+        $chunks = [
+            substr($digits, 0, $length),
+            ...str_split(substr($digits, $length), 6),
+        ];
+
+        $chunks = array_filter($chunks, fn (string $chunk): bool => $chunk !== '');
+
+        return implode(
+            self::THAI_UNITS[6],
+            array_map(self::convertBlock(...), $chunks)
+        );
+    }
+
+    private static function convertBlock(string $block): string
+    {
+        $out = '';
+        $length = strlen($block);
+        $i = 0;
+
+        // Hundreds and higher powers
+        for ($power = $length - 1; $power >= 2; --$power) {
+            $digit = $block[$i++];
+            if ($digit !== '0') {
+                $out .= self::THAI_DIGITS[$digit] . self::THAI_UNITS[$power];
+            }
+        }
+
+        // Tens
+        $ten = $length > 1 ? $block[$i++] : '0';
+        if ($ten !== '0') {
+            $out .= match ($ten) {
+                '1' => '',
+                '2' => self::THAI_COMPOUND_TWO,
+                default => self::THAI_DIGITS[$ten],
+            } . self::THAI_UNITS[1];
+        }
+
+        // Ones
+        $one = $block[$i] ?? '0';
+        if ($one !== '0') {
+            $out .= $ten !== '0' && $one === '1'
+                ? self::THAI_COMPOUND_ONE
+                : self::THAI_DIGITS[$one];
+        }
+
+        return $out;
+    }
+}
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Web/Service.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Web/Service.php
index ad06d39..0268400 100644
--- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Web/Service.php
+++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/Web/Service.php
@@ -2,9 +2,10 @@
 
 namespace PhpOffice\PhpSpreadsheet\Calculation\Web;
 
+use PhpOffice\PhpSpreadsheet\Calculation\Functions;
 use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError;
-use PhpOffice\PhpSpreadsheet\Settings;
-use Psr\Http\Client\ClientExceptionInterface;
+use PhpOffice\PhpSpreadsheet\Cell\Cell;
+use PhpOffice\PhpSpreadsheet\Shared\StringHelper;
 
 class Service
 {
@@ -18,38 +19,43 @@ class Service
      *
      * @return string the output resulting from a call to the webservice
      */
-    public static function webService(string $url): string
+    public static function webService(mixed $url, ?Cell $cell = null): ?string
     {
-        $url = trim($url);
-        if (strlen($url) > 2048) {
+        if (is_array($url)) {
+            $url = Functions::flattenSingleValue($url);
+        }
+        $url = trim(StringHelper::convertToString($url, false));
+        if (mb_strlen($url) > 2048) {
             return ExcelError::VALUE(); // Invalid URL length
         }
-
-        if (!preg_match('/^http[s]?:\/\//', $url)) {
+        $parsed = parse_url($url);
+        $scheme = $parsed['scheme'] ?? '';
+        if ($scheme !== 'http' && $scheme !== 'https') {
             return ExcelError::VALUE(); // Invalid protocol
         }
+        $domainWhiteList = $cell?->getWorksheet()->getParent()?->getDomainWhiteList() ?? [];
+        $host = $parsed['host'] ?? '';
+        if (!in_array($host, $domainWhiteList, true)) {
+            return ($cell === null) ? null : Functions::NOT_YET_IMPLEMENTED; // will be converted to oldCalculatedValue or null
+        }
 
         // Get results from the webservice
-        $client = Settings::getHttpClient();
-        $requestFactory = Settings::getRequestFactory();
-        $request = $requestFactory->createRequest('GET', $url);
-
-        try {
-            $response = $client->sendRequest($request);
-        } catch (ClientExceptionInterface) {
-            return ExcelError::VALUE(); // cURL error
+        $ctxArray = [
+            'http' => [
+                'follow_location' => 0,
+                'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
+            ],
+        ];
+        if ($scheme === 'https') {
+            $ctxArray['ssl'] = ['crypto_method' => STREAM_CRYPTO_METHOD_TLSv1_3_CLIENT];
         }
-
-        if ($response->getStatusCode() != 200) {
-            return ExcelError::VALUE(); // cURL error
-        }
-
-        $output = $response->getBody()->getContents();
-        if (strlen($output) > 32767) {
+        $ctx = stream_context_create($ctxArray);
+        $output = @file_get_contents($url, false, $ctx);
+        if ($output === false || mb_strlen($output) > 32767) {
             return ExcelError::VALUE(); // Output not a string or too long
         }
 
-        return $output;
+        return ($output === '') ? Functions::NOT_YET_IMPLEMENTED : $output;
     }
 
     /**
diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/Translations.xlsx b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/Translations.xlsx
index 080d5e7a8eecf953e573fdde76b191e76fcd40ec..78a67d0b760639b424555597361817a2fc2e95a1 100644
GIT binary patch
literal 99195
zcmbTb1CT9C7cSVgPTRI^+dOUCIPKH6ZQHhO+qP}@^u7O!nfGSm{WCEYkri2A?bs{x
ztL2oJ0tP__fPjDiP;M~S1o)pW%%8cd6`iiVv6Tazi>&w+HK}M
zk!ea2nJ{Y?PQi5(_nHz)@FmmSgG~zj>qP*yTp%Ce&9>`XJN|6dM4Vj+1RReBj0+0{
zR4=sA2C*beG07NXBp8HvU=d^?hCFEAjYc2s#hwiz9)vH6h#Sic?`X^4FiKOs8`PZW
z<&f#7MGdF(uhNHjDas_%Pu*W2@>1XsbyJ5{r$2PC0s{cZ|8EI{{|{mRGgXmF<2LK`
z2p!K<5L?zfA0^gma9XQ^Kn3sv=JrJeiGyi`pmAZBsGrYOYbXniM$1pI1pROq=J3v{S0s)JUWGV!gDWMT=<*IAC%o6I(W@jMvL>lz^0D(zoFC3p>
zsUjlM5R&#L1S#+Pyoc}*#tcZ~JNoW71G{Ek!|Rno9m;9ms&kCB;|+*}c`9tl7UBzc
z7q+q5>m_1IX@(ZV_pV@D2>)B!jr5G|i;S3EFT@(y)`-OOS{IM5+*vC;b0Ttn|3*9=%!X
z*)8RNa_eQtxxP~f|3alOFE}r3^@IEdWO62UHyI)9kF|%!yf<7F|BceX$byWg9ED&>
z>=fEP^Z^?l5yC<G_?-l}?)m~h^J>38Y_Y{vwCCzrjVBVi@^_z1hBH4K-X$i^|<6G3DQAH`}Dp#Y?
zaa0*zq@_E5XXO=j!Q(#qiFH5}IVC^JMQ<$>Ee-Lz^W|{f74O9ej_9L22{y5oOiFi7++YG(eiuPYm-
zcUZL$RF$^P1l+uO$b7RsdV>CkGIXIF{No=&>;V6p1tR>1GDBNyYhxQn2Zn!0id5{i
zS)fPgyhkA-`s2smE+{`oTn<$dumBwLzIu4hCCiFD5GaoESLY+^
zXi}<^gA*DYW;Vulpo|gD8_xX98+bA^2NiRv?mlJ?|#?
zB=|Ml%(77qIIsnKjq7{I{K1Z;v5~uf+?OtEb0a0iiw&X1b|5*?=XH&2GbC;i3HKFo
zzTcGg77aV$a57Pl(NRV^!>3#?VN$I+qqZ1B%amVsAi-1hPci2fWqSxZ9QoRHvygKD
z=9J-w>#c{fj_e`UWq%xsJUm`Bj)wfw9Qv-XyEsTHj5&Sm__km&{Y;3oH{@70K;!O1
ziauh*kj9EZ)5S>-@;nnxE*q+Ep*zJ%RL6iji1+ur5{=RpQMUu;
z-No%GvskNRt>?eq_Nzpcyz1vWpZ=K0|K8hT{pVX7+3UNQ+n74gIa^x^|7XrX`!kPJ
z8vV!fc0N;q99eM0r4Q;+gA0V=2h;MOHx2s~iJ}^Y5ydo+9G`O)7%z+Z68fRuP4@k@
z6|H~onjJ;0P%?u`ie^gkiB+Slr(BsHRN^!az3)p9%=h)X?5TZ!4OWN9A7<~OSsB8-
zGJ=h?GtVy&2~LzFVf9!yV}05E{-`NS2-tN9IobXN*cq_@OFOHRs6a@w?$v5sn?~cN
z9Tia-^`db4#O*fTst+;r;9voZA?tumoi)rh3E!oXjQXXaZyD55)XjE*EULdP_>*I6
z4>L~n_t0x*QAXsQ-l{Qu5cTL-n4<4(Euj|LL~uSaCh%M^6tsys3L&lB8E;$&$lh6C
zSVh7TBFmbe_HRc4TsL$Ja>FCfzHp|#k-()D3L*P5KV<^Kq_sSytRVmJLZQxEyJd)W
zMbbt2zgfq4)x)aGm3%q4npv|=N`=+_e?6qyF&D~6uTK|UF+FgKGEuBCr+0!#&u!A(
zPCO|iDrWCQ6AMY@IU0X!yy8?t0y>;%hH4{%v7R(I+8csf0ACiO+IKFqg$P_o-Vp2g
zV}UDDwetS*Gb^=+$c&*CNW^}c6*`}q3;OPzv#y0k$P$WWcS4tJG?#_NoH$#!n`WPh
zR+XHMd(jwBdHzrcE`1^H5bvVV4ecRR`9SeZsmSu~rTI|l@+v55MZfhodBXl15;k?8
zu3*oDL89Jqzph~KAfhXHcY1Mmf(pcnn=Rh>8b1S&yiUl=1($9$C5-AUQ>BH=SqlP
zp|zq8>zf0H9nK5F4r9uI??@Py(-58)JblQ
zVHw|GE838&$HR@p{8*&iG`ec5R8p=`ODs1Z3|FCSotYO@IW0oG>dNR)mZe4Q(0d;j
z$bW}}T|&8d38A^`7BZ$GvnB7gXl
z_&(=kRE`8orIfzF-`b==E$fY(G9x>0&%iKWLn@1v;x}F{ymlW0awps+*LW
zve;}E?=q=kjixlxt`|&4HL-!F$iKClyUPF73FvtSirKm&h^#om>cB_6(;ssxG#^w~
z{a1B2n91Kb4Vs|21I#6CLK{7@t2FYrmXKY+tCmFV(uHASvy_e;sOCumaqjhQ&MbIo
zlFV8EWm2+RA|bXr!A2#zZk1!0;Dv&c%IIm+WV-oxL_^TXTT80N(y6%)8r5nM3Y`?7
zVAV?obk+78inyBRNwcC24pPmKoP%=3dZ)R2{V^e(j>{Lz6u`M;QLk^J7L^KwPDd6y
z(8S)M4vN2Sgetu&Nab_obo=0AJG!G;X^HA{Dp9jxwSfd-yi0zj?PGg97Os2=LmS;mI;a>VZ+
z)$Q%s_=0etUAWL6q|Hp*Au@d{5DLnRGrUCW+Yh7pm)8QerJuSbg#+-NFO&`BH+e2*
zwRnG51i4@uAQ8^+QU-e{SuYfca6lVs1D(aqBo1bGFk4384d6jhHw9(~?Ru{-L`^0l
zucl~gFQOu~k%21Nc4TH~*Za7zJ4~ECByC~pk9MwZ2(3dVke{L`UtA
zi`!Iq3i)-;0CCgzK(+#;3fyICz$2v6YvJ{@D6|srRg&iVlOzFJUWMnC$Igkq^IvqAUy^NF5$!M
z;_797xvu8pb4*P6{MWryL*DD4{00Ny7(ya0mKE1lQ@NK`s*}FeW
z+1*GcniOgYd=L5iNGcXdA5p#Ro8SL|^JP^lN=c5Z+KIAoZT_+gPcF*w11hI|M6A}+
zmu?gmN1_^fE2=1Iuv9MvVRbIEPoVrTnS_cm$wDLYO%15*;uPziU=%$OlF$K3EMmwb
zA0AR%1}$d>L4+Q^EeG~1PQ%x_F7?`LSekG2v3ty9|MG+lmdXBn6D#*{pGI*V^Z?~?
z5oqWV0S90+)H-&KzQA?}-4G^AoFgFr`drEViE
zj2Aldi=g)aZx!b@tZJ}4-|vmU2@v%Ya{r@!_0L-gj#g{`Rz)IUk+1>6519LfMgCa9jo_k55JNNp*PdneJ*k@tv@T
zF^^j*%8Iv@YFkD~SLxtJ6s1_-PY!CnaiD@7;%1hGgm+T7P5G7PX-&Y-ojyH0XO@WR
z^Gft`3GDjWCT6w2XxA^>2n`Rz!FDmtoLu+m)P2GJ6P`tT2FAaC!ZQu*f3u&Tm;ban
z931~z{im|JRMVDQr$_eOuCDpuy%V6IClQFCnAag?4r4Z7^G|0F4Kx&Mf~M*Aa?U7{
zfR1pEhfsVocz?@skB;Ws<7`n&Lf$YWfHWm|h|+?fmvWUPL9s$%uzON_|a|5cBCTa=P^pa$;?+D}z>oTY$b4X>Ph7XlVZkGn25c8)glqqlt
z0rc+I22q*6>k!Xw0yO^JUx@vcO7Ecw`OwOBlsqHa5C)SHVn^diFId``7`GKxoy^Un
zm9YktJ4J-?MyweebPpS@1U0>RHu=R5xDMKh4kXF-zt6cW!UBN_eSU*&fN5`5%BO?<
z4;D@O7Tw*h$3T_b-vz)W(3vh%B4sXeZ1pU@2{{=|!nWlICLxkAn)7I1JxfF6IqXWD
zHTZw^B?qa7K@`H0+v
zzAF(*e~e=T59ByTmWUXQ!UYOUJ_A=B5uMGz1t~LGTcMOx(;!>&tV0OcoOG35v!PL1
zoC8Q8b79wxrxq{E#gQ{jwWc_HaaAyZmC@G~O|Oj91#-%;hQ9
zH0o9N*$s)O>@L}8`o+^lo%Z^^p-epsrv@Wc%hP<^GNq$sx>c^cejaPkZT#fy$1$0l
zd%rdBu4rA^!@UrA)BZQ15NGlTS`bCQ>F@%m%2hqDvD;-17@H3opnGGR#%v8(CL4q%
zpodz$F=ZatXG5hvptd;a;yVgk_q
zo5c|Q$725Jbvc+B8#_Auk3QkQ4~+k3m#{Ks5~`0LA>f)3++eYpjWPrj9+&%S;;OkHc~LpqNDvRq66Amx*iUrTgg(<}U%y>TNSQW_CyKEs
zaR#0?#?jEvhgY;&pSlFM5oUptdDOgenXzcrWkSL!4u>(znCgfRLw+j7Xc^+BofO=@
zh2ta8dfi!GP19+~Y%dv2TK%&|&_HrqADRDD^MLu*$1}S38^dtU@bZv^g%f(Nqd_iv
z{xsEGp+MoBpqa%>RET#4Zg`Am559MYiX*
zM!PDP5^WGy3}asM1RPDgjX{7PFMpjL^>?%em<3MSLBoph;bOS<9%p@AoNv4_L6B%_l)I#X6G(}$$%XojrlKJU&uDV_#
z&834~48QSu#v4I89bfB8H9t}JkKA@{w#}L?TF#}vHtq9VEmaqTdq18xk~eX$+k47g
zE$JHn{&v6S2;a2>f)rMa~MyE?1!hNNOHh%cLqsr7m~D`_sgUR3Z@qgIhmgI(Zv!_#=xLKcn&
zU0Hb`uO;P`sf)@b22N#XArtiHNcM1KYWIZlygP>(oaE;RN|^U@oPJpqFIQ->@a&4c
zN@21T)yFIHN8uV=LZKIX6;gK*PSy2g!KH3MW9$A9Bln~A;X&)6sNyDcg&0Tdty9ca
zbn#bUoBW2&)g|+N@Uyd(*elU&1?t$Vji;FhMpT4AU%^Nuk#1h&bfejBH#RwJwiXp7
zrZk-wJdXQ?tD<&ffAPm&;y2^nrjRi#O367JZULj_9|A{QG|A!GjNx)p!}DR`HhWa5
zcH*NC3g^Ux(eu0D>ne?!!won4ePM8tY54H-Y01OQwq&&hW$QNqLPKJ0wo~&?R;d*t
zlAQI@Wv}-yd&YV#W3v23jd!mk-Y8Y1@Tf}FZ;D1KFE)uPysmGi)`W(2NzdKUwQ7kR
zk7iA@%c!yO<&Sjz6jFupw85!*#FzqWFQ&;uHP7fcGNtE&B?)Y_gx(8tE1K6V^WWiL
z>FsZsCRKmZRJ60;6v5s<<
zS=#%w0+YfHgi=p(I+o0d*}TdTXlV}t&rEwclBVaB;R~QDb(Ik7Qp}Q&SJoAL(F?yx
z2~ITx8c-kWi^U)LmZ_o70}a>b@(q+yrOuNTl|G$19&C}jN%b>9%$6c%EgUBX1o5cq
z;=?ZxVn3tNOpo@YmxN|dn)q~#*w9vu3Eg20p*t@Q3Eia&X3^2{=<2e=FYaenMd`p>
zM*z`Q(eUWz5JZ9?2pww@Z7?qdP147hxin+c3krMh6iLQZI5mii1?uNFBtIF#9+=I%
zY)Xqo{4kk?+BHjsL}T-YxH#6XtOd+s3&1AlbPR898AYg)Q$1cP42Z*Om1=sGLPRaG
zcxc9A^$x{BuUQ#Iksrlt+zRF#u|Oq|K_zDV8Zui$$)VzMKu1?(@lpOkjI4~ckVLEz
zDp+Bf6r+U|FhQtb{X4QJt+LWPi!^H+n~~n=8tK#Xi@r_n>O*fOeVaMhPU2?2=^^D3
zXWU9Tvmrj);>vKN78Z{?mt^lb6qv|FIrS4lJu?xE_tIq>#s_?59mfV-GU>+$a%COI
z2V!L(#s+F-AI1klWnsn!Qq*7~+$0fd&M*XeDvm(_+jS5?VB94TYA#R&dK!@Ye5{K>
z0QYr}Kq}mY@?b06r4VYaa0GhVoz$$Pql;)3@kdt{V_RT70i
zLM0gy+q`x*J^g%Kx=+QiI^6jYZmwK@p5P&Yws;63T%S|$cQ$?m*bamEZg7x5s69vE
z>uoyVoccJ?K>#gv68|7!KM3THG$lVDr)tO_kl+VQ`T@CqfNQ4#ke@XS^!N#&`-1w@
zTT`QB5G|#`(V<`RF+Q4|djecXK>%%SlKsfS66k3M2LZhHT8^c4eU8E3*(CDwafpTl
z>f#}QIG9TL`OAM~O#cYxA^VZx_X9}boccOd{~($F?DL-mrv0E;KR>hmXDLKKK-Lcc
z?gvv-bRyjy$P#$qqoO3v1y)Uw$)}(cgrm*Z`^fYyKFQ?tc_R~o|5M{*aBM_iTZ=}q
z5$#WotePAcFxj_aw5~-+vl7iuj!c>ycro48U|_&e(WCfTu)hsEONP#V9t@B}UgxfK
zHwLg8>XkFPmX!3wX%K8y*7~j@JO@2wvX~@;-uL
zXNPKtpKyyF?80Llz2+XruxIKy0+5T3z9@KhGKW|6neVv*c~kVfzMm_$#2oz3O`<@{
z8fRWilUNVFb-Ithnk=Ay!z2)x`8z1gToVj>!6hcW_&R%uo*zRXJr72}c!4Q0P;-V7
zgkxI{Ld#*CAiF>@LW{BDpzJ*53=2x|xIc0qXdk73iOi}U!kExOe*tZ3T$sMxBtiBiMe%i7Vgy?3?4JpvQB2Y#
zOkXKo;0;ZRU<-`O;GRoEA*~6Vzgr3iOkZrOC=TrloVjpirA~;VM9&MbcA}fJOnM;PQ$|e0{q*XWkga
zP-iyj#5_gv&i)o4wCAirXLCK^o=U4rm(bFcMTllk;ps@Z$9w>9V
zWf(?=LyUm#g8b7wn%mjmt}d@v^K8KpR;L{wSi@gjYo2dwuc}0v?^cNm^SU(LVf+~V-k%cygbvTkBjpSasm+k1(j5vrOgUMC+E-4PczGP
zD0PFP&Wzk1JeqM|^szN$E0A3(SEqALw`nt6oz&V)f|u564^kaBW4ooNYQ#o+4XTY6
zK6?}-;!9N2?k~qC;IUIhE6=n|68#qdSk_ag69+qAdLZ-J6w1esn?5gJX?UNVJ=Uk9
zewPENzgO^@(3m8vW-kX`UbfuLrga_#A6V|9iT&l|uB+cZuKsfH>HG%#CvUdUs}tLW
z1OU+E|8IrF|Lw@`-_qgI)6xN1tZC=@69t8R56mj$nESGpXI7lVY#v_51v-6oWu*t-
zY7=mj0Mc+N)bP+s=D*@_wYEVxr=#Oc8og
zbcY`QG5_vhH2(P0_5C?poBi68laYagllN&Vs~;Th@>X{dwtz>h)xR~BR%b5;CYyId-eWyVS&sRhnxNH(S9s+
zF&TV>HzOnI76%*epWTOq7pJ{^|M+a_hzwq?*V~big}{68Xl%}hbAL41=%~wFvW~~q
z)lg}B&ieEQZH!ImgLv)dUK|dZZ{XgnH1UzC~
zlrS?h6uVuKh>o{bmAE>F^P^FwyXuc)T;CQ(O!tKL&XI)nsz<%=d3ii_^sZ*I{Pzlb
z|J;grWLqUp)lQ-e=E;i|qvZzYJ2WJZr{hXYdl$P&zYkFy5YyWVw8c?HjJl
zj;KfXzGQ#j4ebhb_jk)i>Sn~49!6&0e@stBWIW4e^YM9^&%Jwmdu0HPQAN+%!RzTWxKdtIM^Osl(6&MM$S@P&z-#9Z;27r976ABhgC;pA?f1XPDN(n
z-45jMVDBt4TsN?>+M5pq?fIx;a_}%5X`^SO{~h-?%s@VVnSJqv&Nn3;A{(BT!9mc&
z=5{&#I9bVt`@;Rg<$0g7^>#R!B>wX*FN1M(@0byrwGm*rQc5&7wU_js!?7XJDBe6V)*
z7SfhZ_eCn176(F?m!6f;$xe5T>Fe;EX{(dz3Xk_J8=W%g^G>1riO#m;mFwj`3T*>E
z3#{4C8wB33y;tb=Sm8#vgB6W>WO{_pwgb=`yxG5U=UA`Qfy2zX3ocEdoN3>w$e-l)
z&cd{f37GG1uSU&Q>sBjf!%d{IYblNu7}ef2%k5yQ9yIO_DBU2R=H6iTt;3%6t%{V#
zGuG`cjfd9ydvy0DNyyXZaGPrc*%rb!d{#e_9~z9G>yYSi&gFlMOyZxxwU;
z?}&Si4V79iN}9pt;}mQw(yh@7^o2f^8X7{V*v*gzP}$^E;*=A-cP`udjbe5%yhc9T
z@{X9C$6nw|y{|5~IP2h)ZI5dDUb*HC-3L@o!=O!7Z<9XWd5zfH6{gn-BX4>PkgfoB
zuObYF2W*m-fD!FuXmE&U2guE!i`SKw0J)tURgKcxTG7j%@kfN~UUMcLr|ymt=+Iyu
z!PBqtX~5%5OI+ObI#+$9>5A1?$&Ynjeq=HTYFH=Z+;Uq`Mg)I=%DzcVA761-!0OKn
zKt_;=Nnjm%(<`kW9$3wzOr+k>d8F^E-lrl^+-?=Jh+?32p@Nfg;KsG{{&Fe%&!o_h
zTn(p@Y8F|Mm|F5CPCO5?FHb){k?O6s;j(r70-
z`mh-)&G4uH3hdb@lkP(yU+MAegqTg<1Ia6~kT3$y-+)=AoI4=pPEkv8O<}K^N+$Jc
z0)Rmy_Plu{Wg^s5O&+8=@6qM-M%^AX4nbG(Q4f==06-gM6Kz4xqG!dN}gnByAesJDw*bYO~_ihr`9-coj
z_z~U^U%0^A*qGqwC9qW5-=@zJwN@8evr~=tGTFg|=Ha+PT)(8hgKFfvNCR|Gqw9dTMNf2aP6Xp|(XVWULrhg-t_%q#LPD;<>T5x^1lbE3>?vjnRLs+Y
z;us4)YcfHIaS@kbSNx$*i2aCIMn$wAfAZZqPJDVK8z@?jl)^>b
z=#QhuueS6z%l_t;b(!S8MVSf<4Tw^bo-GspRQ0fvz??kSOuCyptC-Ib7Zo1^OdS|0
zG;23Nl5EGaIyymMfGV}zvvhTg+VCoiJ$dN9lE%75<+0Im}IAtxh6MQOwyD)qos*|Q7>kv=t7`kG}
zIxEcA@i-qX>hA398e~e6@q-wGn@?9pVX@@G9MWF~ZUu4O0cL`F@r$Q2PgZ7}PEyY#
zW-GS2N@1=vCpu|D;UB(KEq=peX;Us36P6J^7OfiQ*lhB4x@Kt65P-dMz0b9xGpw@+
zZ_>9LONqbQCQRY4&52F(WLp2m{fdRH4oKc#)K`l7#y2Az)jJ(k_U}imxjBWHH(be+
zYv2iBeIR~WkJ5RA4Vw*U*vmr}A>x|yUm%dMqx9PUw73MZ%Ne1V%#Hg{%UGf@5_V7z
zSw7W@sm&-ac{0|MgfV;c1h$m3ALB&nds~ZfG^5OG6g~Pm_*Z>?FHZynZV>sCv*QGU
zWGHqDt;|028Nfpr-Jn37zTl;qK@5W9wHBnoZzAgq0?1xufWJFhh;D}epu%GI+xied
z_AW~Azz&@H;*g*&LCt;pS#F?=vIKpg4co1}!iut5m@A;dKs|Y9)mz*yln>tEH4uvv
zI4_Avyoy7yF?u~0KI$lNYJfLM+fqM5>k`sSUpdHsHz{Gq{
z&_TNFpgmb*Hn$)*bbFCZ_8rYT_Vq|qI30I6LQt&>yPZp4PtCS)g7E)ytoXvn{aA^0}JAvMh~4~ux`S+Wf*XCFiYSCHQHGzqnxbtWe4lg(&904Gh)1Ej
zO=&#{|Lh{E+Z;d1+xG=b94sk}$Uef56gLEhClUdBb=Tfl*}VgN@TKb!4>QW@#aWx+
zC`d&Nu${d!?TQKVN4-0fhXTC*Vz?cM8nXgN7eC;?g77l~m6qQ*Cby!QDf=|Zk@W*R
zl?)er40X^s?w?1rf3zjP9{?%o*1)AyZ)uB8n*tJryM~To+=Ci`GVUOtQIZ5^*H!rJ
zIpC$4l@Rq4*RirYfL>Is`(sLu2oh%`H1g#%7nqnoWVh4KTeL5H)owpmoeEy-!};Ek
zb34r8#P!rLj!gKV)BGntXG-^;Tql^Iw)&>tgPKwjEFiKr^rz6d;JSa0#&!Zh10}fM
zY|BhEs3k`M`2O6Z-0P^EM-dP(3hsCyWK?@-I1o^^^_6rQhmT>^L(Nn~5p-Nn)^AJ$I=@O<5g6!qum53hiFv!gU*^Q|mf
zSA00t^0ywwDwOrJ0>iX)L{i8QC2z%)CF8VC5vGLvAe%7^4fIUE*uI{ZbSba;P0(4P
zw5GqElF6(-8K;FRC&fAgX(*l&ex_nSq|ZzPPEy!h^?fV8!NSq+CrALJbuv=^)X&IL
zfv{Rh0P1xvL{O^YU}79IiVosZOJ}(5D$?!27^&RuZk2{IeO!GSkdqGtu#a*o>{Sut
zeynfCi3Ctq?g1upBdgPqJA?2{ktIWk8GftOCLd0bc_MAYDGp`Y9Eo4
zrF~%K+7T{lGF$jeHkW~OzOFf$Ck$Qw?ZyNOWcoq6!1PW@j#a&MrO;EvL{lq)-6^T1
z<1g$-0F<%CH6QPaqG1<;bcBMvd1>y=J77JJL`u3wLHkNx9GICz{zTn?;njxm_13)2
z!dmuZNEcN~m5uapBvh_Wj?)i3Tukx|&az_vKctjJ%rtr``S^Vmoc10W+-Zi8Iw0~A
z#*Zu-2?<+WB9Jgt57yE>1u!qh1x)?{u4JKDqj^|nd&0h5$bJ@&?4b8J}4FB&;?Nwv(=Ah53pdh3FJDNtU8h
z1qq~A^_Ib8%_3H*V+VyWW>P^gr-GSO1hhel*xI73`0;I3ySq*toQXpT9YX3)+O%+q
zGMNB=iQiGVYw4@pnQ9l4ItTJ5LggsMmlJTNE~Mp}cxFm%omIcgMym|Cgc?gcayk#@
ztoNMz?Xm89RYs3ayMSRw0Ot`rNKg9mf^{Hkiq7EYBNnm)dr6a9h8l~E18b8Q{g(P*
zydoSX*M1|FlO>B2skyau&(fh5`|Gb}SM1lwGlr8npC{rUn)XR6DP0H}wXmQCf|yHJ
znXER9(OXH8O~97@VwfX^L~lghFH>WSToAd4Wv2R?N@#DEF!v*t-!-Y!8%HONlaC(^
zQ!gE5L|9ZT#ea~74H;gc214|`3%C_0EAki`Ux5NzbM-h4v7T`X$hV2#9)v
z#F)4tUJq{1HT`XK@{Ut6c=^mwm&zWkWc#!MJEyhoLKyeVtwqHgwkUa^!ix})w7}LY
zVYW~l$SC|*do>O0;3K@}3||ztvV;~(C&r|cF{FokQbu*BFAUN;?Zq|L}*N+c}jiXiBC8ZPr5if
z)lD5KgLQBocrAkbJ)^F<)XW)Fob*^e*xxcv`%s3;0E4_8;}lDiozo`#hJt`sbi2}|
z3&#B8HsF0ttvY-sw2woLK>
z^!AnrN3n;=Fj>Q)v24%=u0SJA+nSCvl<1KUmFIj
zN%mZVC0gmADqSGWa+N6SJ_Ir1u~}7;Sh>!@cm^jDC{=yq?@UszhHB~o4LXhb%4l*G
zwp`B#mIrvU%>AyGaZ6CT&g&~CcVJF6)hzzuXS-KMmIzyzgQ^S|*uSsidbu(BnqkR3
zK1wy`@Yk37vh0MkV1{CvZ+@DGFEYfrGY1Cr1*NZ(b)AC=hQq0i9x;aJSH;muw|L$f
z#Isk~NitYN=lQS5fK8Xt2olr;TG9GW*n~a!XztYSZrrRbzMRYu-vT1(p5AhfKF4*t
zQzot)fjs*bkwDlW$$kq^iN2Zw$C1MktqUH;6b23#-OzE-}Zpim^aG5-Q;y-P_9)bn<`;Yzis*3u`A5ChGKYc4o#;gBv)T-<1LJ>d<{I^O
z4xAUSy2N9ZlU3U7^if5{Xw<_tG=1t1F_ZDH-6|apga*I~`6rbn9%!2@dU(kQ;*kc&
z0C>o*^2ldXOrHU-OOa^GZ61A9cuxkES-*${QUJpDi`PgChBbd&PQX@#g2;_W{^Y;<
z?j0+zlGP5wt{}JGq0fF(3LYVU4(`ejqHsZ<>o&$Ryig85PBA#?Wjmr|^pH@Fol~q3
zT4#<5r`+-zpgRj!lw}X`;8^~`)0qD5&HdQNg+1gm(&EV9moxy~&?l`-%v7&MlPqE#
z0=9h5sHl@^UG1q`e{aYXtMsKcuX=6XJRETOJua82|CLTu%{1ZiM<-R65g3RsckM=d
zX&oGq%gT^M>b_D%ry$ItH#P~mn!5avTd@pwYlbYMR2I0=;*x)VA*O1G$G6oGJZIDA
zWfzq`c7l-zqWy@VS$B70i6#QCp#@ew1+(TXm+%e|q-rrZM-8I_b0m;`oGFa|8K|`_
zvt4pEeuWJSrH}>%G)7-mIa-HLgYx%8lZcCgIk0&;#o{dG0AXn4FOpspjgW;VWx+~e
zB104+m3efpBTS&Nr~?Xg@;Vd@@y>d+XPU+{n=CJ+RyG2Cn=)&Jjq}WNcFAEDJP{&f
zdD58BFmN|pkZ3rt`n9RDsZNGJra#gf`5IH>yUa)XnGsV`;{MW#u5}iW?=_p>P*T4B
zQ8ylRD|M?_!2?tHg9k5fHEoQUR}W0)$Ea4_Upp;&m1oeD3;51fZ@+p^L#z<@hl_Gi
zvj00r-)h(9Ccs_Ux_VQ{D@@R^hm@+*p**i}tcGFuwo0{0eMAkMZ?u$)g?#(P`ivONI9WLT+*}n6OSBs9LGR&#Re&7o|3Rg0Eh40iZz6
z1f>*vlV8q*-tCwXhIIZMbUb`tzRjYnych#5f>||Z*8>sc)XAb2$|B-~v0bK+b!6*f
z(bC@vP0Fhh?mX!iQk=B4(Z&4j?5}I1O8Ey;vB51`S+{ti_^s2>Xxcu9L0dcS8{T7J
zG)bp;S^>{jslDF_$I9aQwk5$#;10JsV&K-E=8pV&%o0bxMM;m+gbr**5;xA6Xp>b#
zbqL7}j#WmCt(pqdig|gIbN35P5QR8{9aGJ!lWoUQYiWqn7d&gQ7(M=
zK^}3dXe-?JSi`ICs?iVLx6o@VB^mSW?=O~-9NR#Xe51Z>wS}Y~r;K9`W7r{pv(r<&
zF4k$NHZIhnh(?#O(84Cm$e@2(1M$tIt*3>}^L%6O!B<#RLstnaMOT;&l^JRTL^Tz`
z<^1)UBUO&Ed4?gnx$AIzhlx%uTSV$NY3|^X50AC;aaduJGWi%)B!+5MYVebsX225+
zidnZwJ)L_pML5pr5tLiXmvH>?HL4x(rjZ_sS!Ty9P~IOenX
zZUzUu;i3)fu~;es<(xRLxs@{#BbH`ruEWhjOh#OQur>0Ria(sCn-4RjohF)$jP0_1
z7+SPKU$&X`L=tmU)~|RjU*sb7j<|`SmCzlUOe#U8R&;&Tm=_--!D1DEc^46JW@SSIZ~>Z^hNy!UPTO+jFI4np
z5ie&d$Wp>By=A3{sANi$WFZ?)8f7HeD9^_T&;T*2xavOhAPSAm$V4oZX4)RXNq8TA
z!5A@Oa(%bP)06$W?PH4>Mfk+!%k~{2s1*1>u8UTFZ2n=uf_u=}FJMdtVNdlu_}}~F
znz2xI#_Bg4tl{*(K)G>p#=)13l@bt2NS7l6=?VV0r`GOV4o2JLK~t_QBtjRgBss|*
z5rI-wBz<_o!RMLGfN|@Yh1WBzIhqNId>MJbz$Bane{Y{-`1
ztG!7IL#~BT>a5HA%THNYz|(>t*H9`|U~{UjPt7tBNZLl>{@qNrLz`5{YwugFf7idP
zt=HMzTX+eaSE_kv4L3C136_Xg^EX{9JkqzO3KQ1AH(IOhNK>{{d!dJMHG@HsQ^5ib
zw967DKJ=8;(y+P*e13|RKe-Z&URdYewBvob0wBHF7PQ@YW^Q5Rh?Ipa<*mLa*}lM9
zz!5AGBJeJLiMO{aeWha>u@>m-`J#mbFlC?R1;#Gnk
zK5+1euUL!lZBEMxft9f?iK3i;V)Ej2x<$1P+tJ??Jp*pA-o>NRntdg_X2a0iT+3nd6+$c)-(r@F^)ZqaLtFzhImk~_l)8R#&Q5#r^P15
zv$v2|)2xWi-zgkKuhIAHW>!3()=qq=YSl9-q{2qJzJs27QoQWIjouJ{>N+_dn-CVQ
z71L5`Ik9RuLW;|z(ZVyKm>SF~nsxIxNZzD!P{ursHtZZn31=9FrT6q`wnXi%j{Zm&
zW1-iwnf>^AG922QeWe)|j?7NY{&dAyqbXRS8uR1^e!R4lvJf*CU4c5M7i^`}h2-xH
zQO$F5-?J3u18jwb&o&Li(6;?N?j5#|G#*5wtm9-8tBi7vOrbt9v|lb|dCn6m_=Q>l
z7-de|0DTPHja&%RB$cCQ2X?nqSA*mIB<^(+N*NK1+19uk7bW@sI#w7WHyr3)E&uKD*(P%1gq8B@4b@+J#RrAhgmF_Nkm!mFiGw+mIhGL
zy!yPMW7?M9+~$K(#TqneWLdt;vN^3eoA>KF2ajYD6?)TPj$VHvmg3t^*G
z`wht?6Z-JsiE+p1w`L>dIt1vMsgapVnq>#)>H4FqKd+tQ)lkV?HkHeUXutj5M;S61
zj^#y7r`LNjgEp*n3?b4B!OUk`f9DzL>KQG{8+S-IXUtPwFc*o_dAXWbJEMJbm6>D+
z$>Z^^m9P?G(orxFmt`4X9>OzHa|Cy9ggtN71BaQ4LP$-JB8)7DQY*%*acq&Ple
zbJ$ZUQV{F2!j7guR|_DV?pwr$()*tTukcE`4D=Z$&toioO{{4e&t*mtYeTs8KpQS&L_
zen_$L(ije)UF6zVU?Mcp^`WGH#)He?sjiN%icyFKsvEO*V()1WuPjB4}gN
zsH0QSod
zL9&)F_72lBWkOkL)nqai0FioKuUv(u7|fQkZ=gMEhc?J$ODGP-2bWW6mD2cD(*i0v
zjuk3l=>6#)?5^P~h+#vb-&u52(B828ji#vTcIgZht=X%_)%KIuSnp|5Ni^O<4OdLE
z(~N(>**VraXzWn(_;N}J5kTk~t}!ncK2Xx&%$J#4@B-OEfUB~-PQ*RvhB9e`
zNi0WtCn6dk*rKi=M^UR6DnKt);4pCSJi$~~y4g;ZQIl$FNM%&Y>0+r0bxB@CAROMn
z-d~|-+KTm;M{}cZKWy}QX#*D+N4w?>J2rRH8?2X^hB7S&2bG8HJ%qd|+LNbFBQjN}
z6slYrGi6_wRmoPUl&9^Q4JjhN`*dAlIhE-#3Fp$6BY1lyqcx`e
zo0t0fIXF7{m@q3zjRdxf*%;wcbV~(Rp|@hn*zh&a$&-O>KHHMTAEY-M2mn#GQC6p+
zaeOF!AL&g5-Zt<$Z}Yr;Dx&5Uti8QR`Nq=&LvXQ~>HjcRPf)Yf0#&1NKl4Nt5tcvr
z;b2*xk{|-5bpyDw$Hq|Px~T#!B0Hf>xJ5_GrruwdGnt`aRp1**~|iD+WWQXNEJ)5sIK
z!I(6+cCGMwWri5CeSh;GI=>VAOk)e)Q3O6k}yo8i-
zG)tPqSZc|L4k=Ih221j38n8Hq2pc-X2AO#D8NpmvI6f41V0q?Tjtpl$ao{}d
zZjG`P`R{O$ZJd#h7@K@;2Amw;p^WZHP}&9El%#D!;MB7!!#*atyem9&Zj>+T_c$s$
zo2Q!bq?yGGE6|zrhW*;rrAyN-@wJL_A1EG5f`~*scn#TJ^G9yq(`$X#Aic=~ys7M$<%wh-~-dt$~qwwW!6R@Sx>=AubQtP0U3g#(9&+kostBnfy2H=e}8jpQj^wQXw
z?^zBJY$)4Ra+zQ<5>AW}0N0|!zsjHAJi`&V)J_;Lc0#C33^r{_x(4s)
zG)*zZFgQRK&3al>MjWo5x^l(AsYJS9u<{LW2p{3J#S$T2
zB^oQf+zNJ|!jHso@>a}hbbn}2d9FAUFCo@&5;buI@X{io3__S>-m*m1`3$sj?+B4H
zG^Q^c(nQU6F6i)gx1?MDtMuTMV^pQ5AJTY?l1Xehr8uiK
zDb=rg7RqB$EJ8&1K9Kk&5Zj}Cl0Ogi?nL_fj+b8=BrT_P9%gZ<*|jzJXXRnKo2bMSs~Y51|dLd*FzZ35N%+QK)YF{6m+;@SI&fpWHey1
z0%QS)49KTvvKB6)#w+W!xTCgYn_v>oT{#4D3Xe-K@!&p?XECWJyN9AYgD>@P2xays4=O
z%vpCTmVJc=x$kZ!CwYvrBry`|9m++k)t5n)K>n2igX^J#MuZ0r@+mRHlklKXrD`io
zmzS11cZHDLe~_9Tu2oC^hL0|>GCe#R!f(s|%NqHKo%l=9IzGtrwTX#iqoHk^3}rZi
zy!MC;1<%q0V+)IplFa~yyFHw4!55BbZygNK&eH(58!>o&0M}4yrhf(Zw(}e*7D`L@
z$|ibdYXB&P)S&YP$?b~nI^}c?l^+iEZK8i60BqY;9I-g<{J3;|q?o_id5=hKc=zdj
zU0z&x_vw~NmZPau4!{Hdm{J;jL&S9SS*ymb;~VwUKdGr-l?n1UOzVar56^&
zJ;)@til+Y0De?L_13V!tPC^+VDu#|_-U6Pn#5wOPAv?X%Z|l(gE-g5BUkNR9q=BPC
zA6VBn*&^_loM!yOP&%%vSE&y-hbuv#(U?A|TSDP+LzkKNfOy$0q}An3gmf>=WI164
zNNMlvE%me+&8=)Q>bl%AjRHP~v~9|_{B>Lyw1zGB>gigfoIX{R?JuW-_+t;J(ZnS8#7@
zvj<=FRsLIcKtxp{GoR8++Z+t^G{cn3Y!rak{a!WvGuXvtmF-Q*B>RY
z;_Dk;B89B6cLV&LQPZS)@X%Yqz9B+9g~WYhYF~NT6RzxS*}`1%ndZ(pfWKz?wNjSS
zB(@Ywcu<1O^d$YJIzws*>6EKx9vqf6o)m7c1Soo%&{XdgnH0M)m+AN^d_n_q%98N`TDAfW8c5eUSJ$=XH9zcDA77i
z;yF@96@jomA92~ALM4NJlxBKERq)*E$t=4dyb(=wsTXq&hUBOlmgioA4OxqErPj)c
z^G10b;O~|tEmKFeNDg>1FsS`+&+6Qd_UjL7_!il{NU`-?%Pg5Sz}mxp5jDO7`L}qF
zr4iJ1=#>r6Ajkq)hd!E}YJ#xqp3hEkz-O@O7pltgZO7PFBR!e0YPD9PTEVqrw9i-_>Yw7S?kP}samSk?B1?^M60?wcTD^oe(oZ=D
zV4xGU+FMI&16&8ejo(~~P=PwsQ{h!ZfAohIXIGm{EBs3bHc+hCKnr0^RB0`bAsfLl
zC{q+N%)><>*7w3c7!M8Me~Vr4xY7*I>dh#arMv1!0eERjg3-9cN|uCH2oQPF`^ATmbZM
zGA>#Q8lB&uP6r5TS*F6~@pP@qB#}%z*j8gB4YpsZ6mHmXI&J$=P-6os
z0-P7zJz^`i$Qv9%Og?KS4xJN^ZjSJzNre4mTxnVu6?m+>KYiTe&vjRjhLWdyIaB^Q
zeH8Kwk^}3Mg(F_rW7)qKznjeUOOkSGD9%K=9@G_S6bNhrL^5(TE)BSwjv0_h|5^oO
zW#WEKhuwapa!O7y`7qH8BmU=Zi2U%p!TfOq8~sm)8}d&;jqQx&9qsI$=#A_gP5yI6
z2PZ20hvgXAd8VYvf&5pL&h|%}va*AhMzM&gdOg!jua6pYg01-VsWH26zHEKF?#8#b
z^F6oRR__ggH?ZDOJ~PPJBS7FzP<+Wc;pWl%jZtu!kxZSPVwF^^ZjAPa{iSo?-?t5i
z9hImb4@s%z*e#)X;v8OoMEfvls+<4!mLV;N)0YK)If}m?!an|lz#cIM`WO?TPnmw`
zFbegr;Q5?0G9gC@f~WknVFL1p+C|^P(^L>hKd)G9
zd40pg!bdBy_gb$ZZd2M-ngV(UgF89=BJOs$GJ(w`(O6tFqu(U4Htc7}<;K@E~
zqL+?nX+1d`uV!v>ge_v&z$rM_Psxqy~Nbmk7OJS&f&t6;dv%*-zdXH
zQjJd6V|sl+-5x9lPS3X17C0to;3Q-_5WaF96NWQtze(R9|C7L)`DXIaj|7tcrv&~p
zxcq+!7}(qYr-1CZ{vU83T*!@AguZ8Vb`TBqu)`WAH(HCZsw-?xVH1A1jdgL69e=Bd
z&CG(t8yTMjzuwG29Bv<^jf{+AMm$(lLpEDy#$PtOvjkG$;5$mVzW;wh}x|AX56|G*i!I62$d{1?0CBq=K#MpV%sb5P(DJIPpr7*mVz
z2NYPwudodfcF{sHp56G6_{-&_ujlbIlD63?R68&swcP3UJnrt#_3OyOD
zmpc!l(WXEUvXM+mo9TIqy^JTx^`ktla~+E5&ni6RgMS7OL$-
zqg*-dQ?2Ct7GKIhr|6c-JjCk9@*>PwDeo}KrrFPMVSWJl2n)+Rn0HBJE?X(x$y90Y
zfLySZl9B1@Mg+YYbqwchvo=V-F|Z>kbsvaPN8W^&N9$Ki#I?4x!k$Xz6hSUQPRoso
z&GW|3xf
zt(7E8rlO-N!Ki~6;AqQm{4X*D$b!I3ivr$o52E_VF+*)+
zzQ`1Mrp{jfZ{`HsHt6d9+4)QG|K$t&Z}+u??QDO@m(F_19`+_qI{)qdu@2GkZYzNlsrE9@AY8N<%Wen7dIMz8&_Y{3qA(G1m^Y+GvXJ^$N(2Z6H~J;KtPz^
zC?=9K7Z~U1{kOC_N?(v}81AdFGqh4qnUHI9!I@_2u7|6_O{C&M7PlH@KPYWu+jz@9
zk8&Gm8_RuY=XPjgK%50)%J|;h4b1eU4-^F+NhDwcnI54)j5c9H?}iBa+~{Ce~fqZ
zeb?yGWm~e>f&E3@t*i!cn>wvVFf5EKz~pgKEex|F{4T!YiM-|X2aK<5%jCOUe9^0v
z&HILQv}N7UzyG}Z$SOnTt`J#@-!swezvIu62`EJ!)adAV*>rTWZ>)U*PQEx^zMQNUt|GZpO*c}%KiWIa
zy>EK?T3cFs+r2FS=hIIQGr3;g9nYJ3U&G%|f!*DotQH;J&nGiFYdgM=a=E@fH~YzF
z%~Rd_Crf87AE~KsuC+GT6TR2EK0Y@~TW80`-P8+bU(J;>y|6HnNR+m@7}KoC##iKGhY|)b(`;Vxv!h=x6hNWeMqmy-WUJx+1%^y4iAMMdfqD;
zH@!^0XN-j_0-oM)41V|R_>bDr8j^;{%&^OGx(ge`cpXE6t)7Tc-5f?Hw&01&!qgIL
zG<7?*Rl_Md#s=?y+XTc7YHO&vTfS$ZQG>M&dso*CO#eE%X+{5$bDMDNc)`bCG55%{
z>UB(;-VIKKP3rD!tZ$k$tZr@YbglmlFo*1{4TspwsP1O+Hu@*JnT^lCo@>)sAC$Ra
z&)IlZe0Hrew{P7&e!;zx#&mXNYs;=@J+0pRIaRqZ0XMz(@YZul5%#o!*!!9N0ifUU
z0bKR^yl>d~hGQ3Jrt0k&`(0S;u5E5;N4C(9p|`OcMWR5ni!`OQ$R71h)vwO)r={N>
zv|H_-7AMVTPtHy^`*U&%2fz7!?iYST${`&0X8W>!uQdPu_Wq)m`!fB~3R$nbHWIs0
zyt6oqCAdRZ{VI74#cL5NcBbC~FSu%>U`NUfcIs`*ElP#$
zLpjc2E*&Qp4)!D&_6S
zT&?nWCL6iQU~6FO*HdSAhzalg+}()n@s{(!2XC7yYFA)sy?0b(Z9N4ek2iz>ARZG;
z^B>bR56q38V~&df>|#-&oXn%fDQ4^g=c731Maj!)3m=Q*`DA__DPu_XdI(xXns0?@
z=&IKB6JnghAQpR6LXn7f8Au}wZGXm7iYjBl@LS7ZH5kB-N7(UWly^N}%@DSa8ZT{z
z-A%_~j&t~&wN6sU^a}BghoA^z^?(Z_Y7<(e2QADRh53*5M6-+J?R~X#ol*%T|-SqtY
zr7-l^3a)>W#>kcRPv<@`0I?@-C+ziCseg26XmPP3m;Ol^#t_I)hl{-KeK=KM$yk0T
zWETb{JYvaMo2r-kCLK{0vm~1TvB(SzA09GIGEfW*#VJNU0oD%We|yU3z8~Xz#XlZ`
zpAJQ>%}BS_U&o%Y^)d6Y`?4CwK5@21#vkncl+%cj6&wq;K;Dy{e?!QjvDk;r#HK25G!eCNGm;vR|T%#fg&-Mzv=1V;GixGM%
zmk7ltJJy35#UGOu??xEORZg)Lm~Iym%YH5_ig9urw)|i9w(OJ2L2pa6Dslbk$3wzVd`F^r1*I30hK9KmK{lyHTo;>H~
z`UESYgzD$BCRBT=4&WeS1oI|7rf`-ao!j
zTQMC$td-U4h~yY+4)Z$v!o*1_Kdw78Qp|&(0
zWzACVIz9C-s8R?QS2S_WA;?%a+MDZt+h5=Ak5)41TlGQ>^wZ~X?z7Wj;#Qt@pGHHG
z>yFkhy8~8^EqJzF+>I+W-K=>~Nw}_7L7MH0MCfdaET+ko-9CN~3^56_+FrvKi#XX?
zPZW+sfx4aKPVHoVOTy(MW=He(@_#FTT~B`yS=ySIvsHq;TE-n4c>k4OC|_$q&fN+)
zij-r@5u;}4t|4AA?B=Ag`IoxfqGv#^kmY8C@(^RE%BCS_#eu_*HNyrUa;~UJip<=<
zPgbKcQGgCQ^^y#7TXm|oryJ=(rl;1|@#{1l`&cqct)v#X6=4;1sT30r{dE?736s@n
zNW@qeWnINUYrSV`d=*aR#l}|7IsuoL&odA7zVP*(k9e0D^tu&b`AL3mdGOMQyK^Oq
z^Ln>c*ct#1c7c|UPjfsotz*VuN3v4K&Ae`TF_7<1^Y#QOj!c!YZh+LfV65Z_3G@n
zr$&4Onp<=zv3_n*OxgeoOQb(3|yG4X|qYWbg1e1wVP
zx(aPJ41QylXT|EaO@PDsM%mamLk3+K`1G+W#^~Rwi&ia=
z-XA)Bc}pLd+_qqBvpgg>0Jh~X;8JThHkH6dcA6ZtjURRo&J|_f6bDs0tf&87B4)r2
zI!l;aeFRN}91%eiQ=c0cAlVf2{O
zDsmY?sA$>HT79y5CKzSfpo!L9siDM|zq$#g5H^m#(8Jl-B}xGp9_rg19n9Zx+5FN9
z&0>hvGK;35hRYqwJkgv7iXdl_s~z&huJJu1sq0jG>m~e-rkdju_D;Tl(9GMX@q`zR
z0Bruv&7QUcpY|pX3$x;{fCJ%ZJY+0N?oBTCI$c`NPcLzJNm+8STS_OfA4E{MH+#2GLZ>#z1(rS$I%Z
z%?6)#t0mjk&f5Cs_Kr3qjpI`#_r}IlDg(NKaa2rsWA}}X%ubpvuaBM0W7=4;MyI$Y
zmXe6vG+h;Oz%OhCREUhZYz7_edohm*ws8fE%vPtesS|>*@TW0HtAGQ9n41n#
z1Fi=HhmYs8Ii2G-s0E55W_WH3<*|6|8y!3>DY~qvRau4MmMD_0g|UERqf8U)SDD}D
z2o+#lL4z>>8~GTwL*uHNB&rxVTJiKr8ik5N1knnE#-Kd1x7hl3-;>;EaGF7Z#|**q
zqAP#myv3ptpS{)hA~`EXNlx*Pp*LU2r#eln^XBnq1^la=b!07DTIo~38rsM2xr$EZ
z3P7|-%Cm8qABk4g^7-mfFmIrgoPcp=Afpy57Z&?K=m{oo!pZ)+eT4}{$EcT}H&Rrj
zHD|%bH4NyviRURP#+4uo`)RGZVOez6iN#_y>#cD+rF2~^qam!~3EeIdnutJe8SXha
zDsqwqyQJDuSY#{DA*~DcT%@^NVYGQ{pwcYTWe!93=nn)Am)sFeUOgSlZ)Zmld5w4R
zc|tboFds}OJl^_gE)J+Xu6H-toh960mCcKFj9{WJP
z8*e^%EvC2);kg>xs`=MwT*P3dJ!TP{Td$xlxrasPInHlVWq@`lKn6?#A?!FofV2e7`fq+v5T6w#xO8pE!5!mHkrk4
zBmVt|pxTGy!?5MjMoe--zm$fT?b?C@F$->`ACus~2gii~UT#WOjy_T(zS(rCqz;@vXUSQOoc
z`L^zSHI7iVq&r3DZD(_`kG69A#%eMk+}zIH+3>XPY-8u-^u5-|kN=*8`25b#_j>oi
z4yZjk!BLO!@UT%WxqDl;zPg_-W;flqOnFF0jc=Z6E_7pmUS$Suu;d=-oUqbTb8BfS
z@lwmoy}4i(td*7Cg`6aJJR+UOdv1%Vy^3rdp`N}9Mg0wDc&$#
z)KEjXG*2(LqCNQ!1KGbR74_Jaa#^t!`&MQ1(VlVEt;lA*St}BsOB1KUu(mu%9Td$p
z;6)Hx9#5WpbtfOp6V6_0_4ht6rkk=51I0B;e2#x=!CEL`UOWHOom}ABp24ReS_FgZ`@2{2As{PKsKTw`}
z{0C7=T;d9b5LA+u3(p5%#oP%sQ03Q&W6=PONiB-Aba&!syrh)%dB5nxSH!SwchMep
zi-uHP#?2trCT3oXJG_9<)n7?_F0&$a#9#gM2>HEB6^iw@$03>P+QVv*pO>{4
zg+lbW#s_>UO`>^r#T(}~e3b3N*|I*~4HAxhf@th%%@L~^TSNi(#NQ6Cn^WVOf43^XI;o&t%xt`5!
zHF#jmQ$_QOmN&iL?ui(j&G`%6v5^~Tk6Sm#W7Pkc(<&Q}q?MQFhmxM^HBwh5~%*sv!%^B70c$$Ty`h0V`ZbU8Il#>C!%DtmF(y
z@-wmK2m4f}gL^8)j}}BYPKCMcA&KoiQ7SUfycj7W|Jwz+_Rc5N9!$4-Cq%7jT3jMd
zWZCSqmYj~d&Vq=!qWCUgP%A4rN1#tz%R;P1Ya0Rff!Kz5{EP
zIhMM6vn#bSulgRnsq9}25I?+@CgLev$y1a3Q;omAafANWY~l+DGYq~FMJ?3cvCR3O
z+m|K%=j9K7Av_EQloQly_tW_S!K8YH$Z3Ua>b$j0eYa!0>R++A!EAT~=K~Vk(Ndbr
z4u;0e`am8#y&K*?MSwrs*?n;Bg>Hlf-~_H|>gIbUWPBoNP@Yd)T=i#vh-h5NjA+ce
zXU*{}DHZa@HIIQq^Qw}FD>sDY6BoO|3dqYh`W4a_ul&NDI!wlkiAy&OP=C8{Cs8?e
z;Hl~jyl&#xvE*+Us3uNt38^K{ULjSDo;+EPyl$$S3_=lpUdE83WC5`k)GR|GfKrIGr9nI^5bsG@!K_9`i|7;yq}8W4YNnu5-&R>W}@r
ziszrjduE079wzc_-Xx20N+Z0F!VU$+I)t7t%XYS~N=|MEn&Ez;MFbAyc@R1Jc4~stRWyKE
z8wVVh3qga$hAt>Mt<(bsD_)CweiE$;eOrsZ#SiIZqWz7#ot*!PvU`y>U!XVqnH>1q
zq5ic1($DW9OO;gr(OA%*Np~nb=6U33q1_%9!zD0MCmu9lB-Pza)l1z-Ti3f}oF2UP
zGD{uF+pvmp!7xV+2m1nJ0B{8?X!|3+y~PO(d4!z$U-_%o_v*f&rJdAsb9Nl$ehnH>
zVtLO}W^luqXBRXm?2{-YrEUp8b`a91zn;Fi&cHz6`l~o?v=3my6@Q02+kv9t4~K;6
z*KMSlZ7iH!TfaA?d}&m)$+XhTwboi+PwOMMo-IFF+gUluX!y|B^z<-o^{{Moa&0K4
z+I2F2n|{so2yNKqe5`hlvVTvlCk{5UZk={{@3pKw-L2Hx&6IR{X)&v^(5&-)eL8o(
zifXdxy_M@3!(&`#Sgxta(CfyBVAQ_AcvGucWVR0{hqLw0S6w^%+ALLAe}5gs9%fFZ
zUKhr-S^$o&T>&#%jb`q1GgE+uTi2FWev}*wjVh`MS6c}*vNf(28rtZio)cyye2wg3BBV4uQ^sA*CaS+g(?zJfvm
zWL=;t<{9+47xDJ<)4s6186PtJ&O?vJx*ih)KV?=-!(4-^@?z7x1L3udHEM;X47Ly2
zI|=)RIQiZ09A&_Em`4v&{iP)iPFHZFAfAmw@LeqQ_{pgQ
zYXIlxwaSnjC?=|tk)Omb1(8}p4s?0gvWfzre=@;oJw}wE=C<;zBo`uXkyMnB3W&N~
zttJU>Hm`<|$6e6bRw&{qlKfOgT)2?=OmfoJ|A_wXIzbnU$%2m8ww`V%4a^%5W!h(p
zh`Dvd!?Ohp+4hZ$;wHXU1$ip8tu$~X;bhf$r2R?6I8#c;FE&9%Q^D^W`M0U*DidHr
zeTu2^S~Q+6HP%cK>TzZ{Ilt8kqba+jKrdbZXE(Sss^*7(kUsdU(e@7~K!n^s#F}Lk1_L(tL2h6%tJ~
z6ttMDD{nRwqfK3J)?(=RX^5Pw
zP8oYaSDr$K^YfoA14)nP77rfzZB%z&@QMN|-bjh~)LkHyr{m2VYIo?D#ZzYh(eZ#p
z!F7=lh(ZdDM`<;*R8KZdC7rP#o2#hc@`ux)Mi*?Eu>Da#VRjMl5u
zQ^rkof;Vmw#6L%Wg+}(T%@%2ZkSmYRsFuz0Wmf&|`-RN~i(In%gT{vQG6RG7*gJ6}
zY_BW~Y&N{}g?E}#vVWx6v2Py8CQuKh(lyhwH-N$?yZ7mk;jm{A?E1?jLjp%+rMSRo
z3KO-7PXL()*YO23-8A7x+kWl^;Xj0<$W76(XdBLQmbRI=Q
z9m`Qdf*S#f3!I25O@2f9Frnh-?yh0VvlYTATf(|VoG+!*?y6ws
zUpX3P)*-`5n`pEBHB3j*|7wRi*qH+!xfa=+z5~VucuZe*mvZh&x&k$Y?W52)MQ`n`N;-sj-X>2~@}FFZ6IE7AO-;TU_d8N5B)5E!|(
zL6UB`-P^J}6F4H+xCc(A}bkWvr*~`^lriQx8jh*
zhI9Ti?in=Il|y}C7;3)f(;vD5opOM>6wufidn^DP29|HG53%H(xh#{aeWb~<D+W78NJIe
zD602#Wi|e2jPi}^__SXtpVxEugO70%h--LXh7)op!}l1Lb1T<3LUZC&b>S};IT-nc
z(vgEGsL*n@pA>6FFv23s-Ae*`yOqC6evHZ%k5=vbL;PzBe!{=V8cO_X0H5hCTCx%@
z->=AN6NE2MpUgL7?Xj$#YOo1_
zk08j{5--^(%FlsDtCp*A=JiIY8BLA>6^vZ({Ot3zrz)(sv++js-%>ODd3nc0F=X!4
z1x04dIaEWI!^tBph-AFfPuiLE=z2x~^Ny3DG)43tV>&u|)>*t`0gz5!aA<2n=SqLD
z85G2$Dh~yrg-cZO2NGIo3O62U3WvVbMNpA^Jz7%R{DLiMb6Bx9Doy7^73<$k7p1$!
zTYdW(_XHWt5gHi(YR(?`x$eO0RspE?f7^NaH*78s?&Y<2$4sRj=iDy}jSxFipJ8ig
zR`Hyw%Q6Of@%O`hg^a)zKe_k+tOKZT4U(P)Mx5{nT9nW`p*jd@wR0C#F=qgDXHVNN
z(5KHxc#gBAIH+OW<2$xD76TV0w}#VppHfZB;{(3+CGBPM?SQQmX*btGA@40HL|gD;
z?=}DgKI(}EQ~ke%qOM|Sk}|A23FymA?*sW5LxX++gR}mQo;T~+GU`aV{y$}|~ND>kG+-rz#8J$!zmDMe515IbSqTCX9x$VHIPO+o%RV<}Xv8|Gm
z^*0*bz&m8j&h0QMB&}!r5tv5ZkNQKXd8UA70C0->H>Cpp@aP;0&Nm@)3InNp<88{8
z;!ZVHr%2O6Fi&(rl>w4Orc@>(oK6UVnl>U@S$Xos)fB8Z>Z>orvcS~Re-HuSaya2K
z1_#G2%P+kbH%U}o`0=!C#7e4cy0qaALo=f|%~@B~twsr9n;lLXJ*3R>DO6v0w*g|}2O%lxNdjpV2p=_W<-mQ0L_vT)6
zTfA>uvJ>zV8Tn;0H5qzngK^QiK&eT__O}>J#WV{D5!L+io0sMRQ1!p-ZM&n&t
zdqU7ck@Sxz4#hM{_KtUIDir-&?<&g@>TvL}(x)ETV0-ZevH{N3r|N@VQ|_!Pzi@eL
z69`EZP$lfui(0qv&>63OMYf3#ET|WW{F0pwvm&MGFZ*%G#?J;Q%(2qq6XIE=)v7D&
z1P7rqW~|hv)UZnB
zVbzDEq}JH(g-NMBn{H%oV3=j9xhzumN_{un7;tTD1<~StZd-dkR=gR!wUkFa9x8Z}
zW9H8EX81Du@_ZkU^?y7AeE%-9pM6F>yZQ`m(>(|BdAfDJCLc4tDbbbh6h6!Bw6(PR
zno#-j91K2OolloIyF^Y=b2olocv167_P%HX+&XWApA7&ejS-x
zM_Oh@R}xM~Vy56aG!s?7L%?B#j<8RJhZhpEQBKx6I3cZfFsR_e?S=QUlux|@n^|}M
z8XWf~8Xqi+LW{2m6$76Ig>O=kksljf-l6F^F^Ox83vEiNoDm%aLUERfgT@Fz*fJ^QpuTtGJqN`#)?
zlUzbFSiHz%@4R?`m^v)ln-OXZy+Mrf`v*?)4`ZwpYWt)Usq^&NZ
zFmnmnXb=aJNfH?{?EG-eGhY=G^^nK1-L(lG`Eo(|%|!aoD50HPk8JOqGy6qnBOd4G
z+D35}H-^d1NrrpSq}NrT3X#$Z5yq0_vsiu1K5t@sX@ms+5Cj{A<-ubHY}U{Mzr>E9
zOp|5kWk3f7XejV&S*6O8W6t*U5qq
zG0z%6d!!}8i_bC)_@z@~G#k
z!J5(Ms5l0@9f8f&81Z_t;(dRX?&AUiK+Fi=;83XUFA+pIxw5{HRW+mAS
zP&TG?|LJ8$y0NgkGVtcoQVH)z`!b~qn6jrPNgdfsYAcr`#^v%f13nt(QuUK{Dq{0I
zdLlke!NQA~dVhGE{ST^JlVfHoyj;TzD!K9F%3YYU`3l?dt+mKHx
zz0_nyd(BSuNJP;_fxgW
z{sYZ4SuH-!yXqCeTa|L2XlH2&#+BcLFbM$$msoM)xCv$v_VRbwstB^y^6PP6W(@Z-
zC*Uy}xKouy#7_y@Ap6_4UVLJwep%A01JdnyM%uHnzQifCurWG;!
z^g5E+8jJs2fu?!`T5GYrP(xtk26cevt;yXB%Wb`q-q4Q3$Gi@uS`SZ47L}(4ICVhB
z=cS9mXI3E=+wOF{{S^2z#y_36Q4-_`8FV<9KEuHfvVLR8?P4mPkXNmf3sE@QgsYJD
zCW`w4I788f58WqL!}U62L11r}g>5;X4MH2_kd{F0O=oT(na#rDv4Z{*tR6FvA0OV>
zL81Oi%^-cRQ88H^8D=WYdZp5iUM-k@V;SR{V%{T_6(UG4s;L|g%vn>^@
znA;lWxBVsRrOpvJZg9FJPl*fgQi2%iS06}%iW5#nJvhY?s5B5S75A(;psZ+!&X*>j
zV-lI&j-B6xMml3-@c#gnKx)4#f@x{ZJ$RZD+YAZv!7qk++v{0a?auaW6~Q2ubgOX1*-8!)
zaOeJ&oC80XE&DD+n~XZH#v2(Pw~$98h5u*S1beQm<9IZp#e!U_tDl6z>hy~mu}GWG
zhfHiA?pChv3J<>>iGrIw#>Ww?Y9R`J)ErXN6uv7a`!h2bJ9Rk7L)l4TR?-GrusX3L
z6PN<7!H8{_>z(EX-6n%x4dKY9%7#E6nH}|piGjtrdSWej&6PzN
ztE12leJV1v!2~-$^HozG%^$jeF>177T*j>InC0a7z&T$bmnzeNInI*}=4cu90>?`&
zP6Jy+{Nz092165B_f{o5X|3y^8+2RuGJ{W*+c?-T?tlwxsE;s@WIrhA6PuN}VEhdg
z=BRtKC>2|40Li`3Z9cPb~!8JY**h#I0|pF(Rgm;qF-zPKEVa6U=Q9(
z&TeppeDQ4;#APO=NORROL36jr+1a@W#-UA0p1Huow{+Qsjz*{oAC*cVSX*G1CF69|
zf!!8n6c+Fe@2FrCt0Gu~h>;yVZr*-LjFh9t=}QVt`MzxREV!CB?-nK)HTSc;l>b$b
z#qb7P4D)KEvN?2_EsBVL1?8@j;)Qchr$IoLRR1(LumWms1YB8}qC!B$zV@RZ{#@L$O4GAHNJJ5gCR2SJ(FNAGCd
z=2C4O2s#C`6uL9B0h>sc!MloK*c%oRtZ6+gZ1Z+5^eN4L
zuEWsucs{d@$DuuX0CkU?sy_7llvgKW{oMEe|Ka==YVLC>eEs|&hq;h-67_8myW6X-
z9E%J#gW^+Xg>{$VTwBU(vFBaYj6jT0+piN-1nl0*P)yxQjsi9-gDgtjy_FKjj(o<&
zaB+3Tf0>>55@$ml-wbSX0JoXKP9{E$JIw=XZ=2pmU|nP+T8FnQMx!Y1xi{e;2AxEV
zBbzX7m(<-tFA!!Fo07+yoLIPvE3h#5xw28K2!=C2RuYU}bEhaP8<<{9@A59?LcYm3
z?g*=Amy4>#F9fA~64!0-?m9h()5^D4M%TrICwLvr&|*Z4~X@
z^c6M=IUp;gp%fdXvRvbPQAiXGzH^IU0n4Q*jG`!AagPXBmxx-qN7RBNrAQ>|O`_Co
z5{Yt?Pz!^8lPFi+Bvug$ze%ht6xto4Jr+O14zYq*Xt#)G5eVfLv6?{W_J~rwM+{fX
zVuckdb#DKm#_c~;xcwih-2Qpcofxq;iVc%N$LUbz_a6rO{MUdxK87o#!Qx7P3wJ+W
zf9UETs$Bh=tLcbPm&g#>QDIA)yrj|H+C&CKsz_icQl7rRT<`)&cIRLOJ#nfG2OI&V
z@lQqYbBF6FJ9gAnsTirI8ZkN$tu(!Z%t^)o)kWEA$?KfAD(xhGM%@&}N-s4ghrEMTUUWr2;Bu(0
zdq5U-Z8U#$hq;e`4@hNd%zZBL)3v{V@vbUU3{rT
z@rJs`Y)V%zwT7;nuF0w>G-2TOxlRNN4VA`Jb-<4n3nNaIN+t*5yn?O|F_A`mL(7Nm
zji5es-KR1NQ&kH;G8+%Ra@AgqqfWLw5ml_aPbZ@uHg87R$ilHN-g-*x@dKNUO%)YU
zx|9y=TKSde)b|6mf-Ra`8(F>)C~w_O+$}oA6sx=nid=UqF0e1sqSo-XND);*JLsUj
zoDTJ$Nup9vpp9#k35;kAVLVVInb&@UwH@7If#vJAwbtl+KMDJO0;3yc#dP@M86);>
zUF|l0(MSdmm|oc(z*{(eT^Sq3upkRue(&;SUAk++9DZYXGV(5&FBJI8@M~dyz&#mo
zG{Pe5Q^1FLM610x?`?Bi10s`UHSD`(IMbP(C`l}Pe<-gQ|H^W7!NSWJ1NXS}F*D12
zQG@sWg@L=?p1X_9`oP8;0es=B6ceKKrKfpp*|V`Z#QRQStHRYL3o5^-yf_L>(8*x|
ziMA|un=LS;#>Kf>fdP}nc-{8Lf&AFay~4%}`CPOAimEeJ9LS6mXr=dj95Ihl2)f1e
z&EX590&yIxa2Q`Dc}xRx?}}Ru;5`4!a}-mb#dx5Ig0%2f#zI7Rf~v43T)!6CG%VsU
ztq6@QK+V&^mPl1l^JFl=wa*8`xK_Av9I{u-0aGjR6|t?z`|^BsDN7M9rg{e8Z4#JT
ze^Um8KAN%`%z3Vt13FIHTEHCBd;Tg-nUH++Y2;T@dCY3ZQM2K_Lj85aoxVz
z^r^R7z4}AVe6fT~aWnIRwD7ND*CoGFoGG4EKVP$B@dgvMj*CcHZtH@kjt6sx3!y9N
zmNK+ofS|ZQuSs5@_arZj;ML#@s*spb+^1|2dw>2j)F896j|J*0k{1>aaBR@%`Y1CR
z#AZUSLg~n4T9jtIDqIF(nhju`ybJWorSjeY8)cr#PR{m&RqR7-7v>vTeth@nEhYop
z7}VH-3e#^TqmSo#>9YED!(bfdffo-;oD$}ti8I1lc;dXJ7NR^S?DQC;%?k5J8Jrc?
z;#HRywg7Gj50i_#E10t_fO$&TDT>P|9l~&;hp{Rn%wJLwImweqkrvk8qS0i9wfNTN
zg?XscyfBY+oEFw1ohOF1Q0IwZ=WlKC)G!Zp9zkCqy^Wohq0xceCy8m`?%1o+*nusY
zLpGJC^tLWRY>9kDikJs6j;DL-Ha|=Qcujg(wEgnV?zPSR+0M3>eyw+X`=a}0549BP
z=8NVx)4jDz)3sUKjr_%DJKFE=y?lLp%l$IlW$u94)yuQB+3~}x)7P#}cV|qN?##b=
zayb9}lONB&oy7(ebd**D}-@U
zwMNCj+AK)SI!wNjp}YPqyfizA9B-O;hcM;d58VcCCVV`-96ln^vr)p49A!N=2uE0?
zid*EJqsK{~qS)0;{JbrZ&q4z(O|Ptu@7NGlwu|eLf$OuFstdZ|1`VtHVA77e&0z!1
z_p+0i3?BY(xXmmWnF-7&Yi_OGkok+HF+24VZ;9JY=D9(t?(Tqq)uLKuc|32iqhXb5(t6
zY{Cn~EryQ{nPX=GV|SMgBiZVpjP*5N$rjG=6+Dk;g?-r^aE0BPmkLxvCOgBD;(r7S
zjW;)jFk?ahEqh@KyY=s`r_}oVP)_RyE=&IxD}1j)dlhnD`pcTjQ90;3RLXJ&o75QP|#sWjGHg)$z@
z{EZ9rRks$-t^wBAb{+R`Q+H`Qb9aP}9xE&qS3vhu|4{iOSQ*b@Jd50og&3x3d7$%|
zERl};7l_Bads#NV2zh3sm;6YWqIFnrQ2}2lv8j7}<@N~r@0PfCcmAj@?_1~jrp$D2
zAjek2{H#uCiH_z**4$yYfv?mT
z0iC5tgsTcd1)@$ZbZ_NZQpa5F*KGZGlAgyuAFcyGMuz4Hn}_h?`i&^Xo|YTUyPWv2
za23}Ptq8NiC3Ao2meK46TE9%Kam>`2&NahX-a2P!cE9t?xV{y>8B--Dl*L^Ds87
z0I+{(rYD1^37{e|9XqM%ah9^r`Pm~_wnij{b~}hhri$~4#?xNF*J7h`WQ&bxeM0Yw
zS_~#(GB^DdM*j`n2|;^4;P}@c3RDLI&2d0=5>OljG{*ql8Gs)E7RJ9Ka;h=?HD*7j
zg(X}V(wMuD8gV+8k@pv|ae8;rm`#a#i?FANyNOow5mCR7#_KbPnuBaSiQo)1z8vVu
zfqtBFU}?Gf1U`+~%aZ|ooPjq2>b^<|9z(Kod3JI0s)lyuOLAA6e{vnLX6|Eq
zTbpajn{nyp_Wsd7Z0*S~Pp-#N{q+0!Kh3{-^7i~UPY&k4oqzRgx8klkX1xJ##Cgtc
zHD79O!?g^WY8m94UA!`NtGLr~Mz}f(?)*y_|kKl=)U0=!z=(U0=L$HJwVDw;E3O^C?n1F3ghk!X_g=*x%n!c3nj
zhn$^&3meujeXH!kD08_HRnYk`G)421)K>5WHEIqoz}s`8c4B+{pnX4%q3NL*nxFXW
zqL1f)5A#2U#l*VPQvWgPWr-ix~ujtfY4T_G9hMoGosgr|ilNb97EXmQi#pc*v?
z3CwTk4rIGasQ;v#VE${n#Y`>-$@P(3O<
zizST#Y#8`{+k&@{wb%hUDxjjoM&*c(5xXg|m+}JKVK2Zf_5$Byo7)qcjO-Y1w2!=6
zBwavBHrfWakJbw7v_1)|0TC|^^4Xm#{Igo$;X8t=Dc3*S<`=9gQ&%g3vcy{$<
z8Fl~B>w9lp-nG!auo-M_UH{(NE3)vj+5U9Zdbkd*7q3irUF3Ya@Bh8|^5y1BE=#ct
zdawC%Z?-0XtX-MCwzFc&Rgaicz-(^sO|@A9VOUt`3UXz_3){R63(xb8Jt@-yH|HOC
zYyJ^D&e3Zx?P#I@BPwjSOCQ?I%bY&T(QV#^)$%eKLquQqGlhjN{N@0Y=4sKqi8BIS
z>M6}N(XC$wLN_Ec3IJp-@je9P+X2~%k=RvwWS?X
zTV=FV#^-_ZlJv5!qvAsIauynT6=!|P#z1kqc~LBp-*8^kZq!sc@>iTyMI28?MH$t%
z)i4_6RYXCK8AS-16{pbCOHoA92X&b_r`qL9YnSRK@jp{
zrezcr)i{NZJchPw@Y7{Ul_%7IcUuyKO3Mt9Ov=TU>m$r{T1_H>9?SxlAgef_KlQc>
zJyLE&!Zc=Oid}jjrahZk^A^3#G_espll3C#>MjHVaG|I+EMbDQ3y&62pN^ARhk`p;
zL@k2xQ&%gTrjQHv6d};oQyF{5oU$Y$w5h{J?13hH1;o!)&K{}dp?
z;HSV4n%#m!IQgPt8mm8H=8?~ge4p(wCbL0zV#+#@jTqi?D#D7s#Lh(4g$F9@q9T^M
zl8TD$F0!%=Eh1y?%{&%Sb-i|=^e*~!M+DTMDoj=mY1-U^n%Rh^w5rqqsQ?}
zVRJic?j1j9?y;m3udQ4>?-8w%xENT?RQG~>LcM3F^Ja%oPp|4ZhqV|T`9$cO18l3w
zk+=w?p~D0`#hlI;Y9KMdX(Vv|nXKy_xD$<(&M2neoHvSEd7;Ct<)O9rR|u^|vi=+J
zj-l!xpD03WrQRvUxINUnrC7&3ZD?&oZ_7GDbzqGm>Vg8(Py0CGl{M}Kd)fuXKJb4O
z(Cy2}V0qsOtT_!oN-aC4WH9yw3zAKEnHeK77zIL{E
zt~FoEZ?slw>iXE+_hoP0<-BDlQ~713Qn+n*TkfLX*}l}5_3q6dKl#c0H}kLk|DP|B
z>u&G;H#}VP_XOpe?RCJj!`r&P_Y5}zuPq_tE-Ic!46G2mA%Kt-on4-~
zi-CWzuBz*SC-dC;-qWblUJ~p;o3*YI_{K<@hxPG6`+6ZeQMnr@Q7PsNy!1xz9?~2<
zQ*EcFqTA+n
zSx#)(ybJN!4p~DB2a;M@ZTi58=|s$;HbryLp-;8DHzqrZA(Sy>L;ii*)*+wA`&;+x(b&$ho|
z9+s8afKLR9c7pH8*#bT=W$43q{i4n?{4RReM$V87QUfjV%2v6fh7c-j7}yJ4Tb8_)
zOdvEtJsi3gR(>M-Zb@ZECNtxDWQxQrTOU1UhKSEn`9yH>zP}9dx54}#w%sQtkBeX<
z=r^~DqdUtlkGm6?&XMH33iBuL*g|aHY-0Vgc@9C3L0hsi56W7Q7v%;1=5XBHE~2t(
z4ujYhMKDgpq+iU_R4!4GiJzRHdU+OdytNH9h;y~@1V6T`L!EKCj-kzhPCk-9H?aLW
za(E5;>oy5pb*E)r&^mRgyJsM*lE-0TyAcg!JCSS~P@C+mSZZ3Hil)6_Ruamv9?Sp3
zEa5+pNLSe4i~0RkWHQX-;{z^?nguu`kbRpO7V`T;SrnGiTM*wZj+H55cvxJ@VLMHY
zBo6be!tTDOg|Yn{JkOpNNa5>7Z0iC(ko><8OBn>+pW7--xVlG6j??41dD?;JomfXI
zhZ9qdVRf~W!XeHZa1WLZX$NGw>$DC?ORl1J?h#3ls%W
zNvjZ@A}JV|BrjmCuyzqu(|2g15TP0lMnn{m78>Nh!39OoqFXYDaM$e`ko6_ZVee8W
zssFf!6i(g`mGv9SHlM-oEtYPoIE!%k!sS-p(C{T>x553w3oc<3Eyo#ph
zPP>YB|G(#4)(8FA5av^?H;1rrQ}g!GV~9051{};kRr!6wJdh#7qQS03UJsI}3Nj<$
z44WIwQxx^#reK|BWG*&WZY;DWpd1&vC`q^n*1)@)!{(rX`;!}j#w$}riJKmdQrHVj
z#Wwe#Hi%*Q14rnNgcU5c7)O;I9kWBKOp3lO)HARqP0v(vK!iqH&%~lDpk2|tZHi%(
zMerKa=VD>q#kEPwD9K=!h78(Gu{qGpu{nf}AzM^A+>moNSa&)$QKH1@bjKRmsOgFB{gu!Xgd24X4+puMNEN82GTE?^4n1xy
zWZ$7{u>f0;Lw~sAs#`otUAe&whtS8_F18JH@M}5CMIp)Cm?OE9GaY5bIkY{Q
zfi~it*0Sk$J@s%H*6M_KRL!p*97a7?Z)}CIv(0Sy9~5|&p0t7IRx~wH&ok1g4?Zs?R3ChH
zQmP3)cb+x)yb#V+LeH&u^2qZ-F8g&dQZ2<<*<2w4eIl065P|lRsS7~2BB_Z$w?es6
z1bQi#t3{v}viTnugLV?Ch(q_g?4Xc_Eb36gD-U_cvJ7XRQuhHO&CL2nuu%>J=b>j-
zyuj>^d-XTyHE$(}EoC)F5iNn!@58GBK{tT)k7|KH7K{g#E5jeH!x{65*uinD`
z`f#<&9xKH)C@a%t+e!o<1|Eog4v(#sNlP<^O8n>Z59c4_OAaTlqpmqVpb1+^a}XT8
z(;U!e3`{ne;0L9gVy8(8ah{o|Olnwjz`~`GCA57QhH00P%}oSY<=1!gE4uZmh2cJ-
zbL#s+VV}OEUdg5J8m{2ccg?Uv-w{8vKVQS?)xG(?wZfTyN{`7`bLIP%dQZM@_H2&)
zq|4~ilvxOApY@l=Fnz&W2(0#vSO{t6O@s|@ORxanXD2(JF*B5%<)OlF};oJ3UsOY=|_z}
z5oN9pE8Nf86>ghA8~kh&CgbgpAUls9Pwe<5nKpcM;hul{mpRfGhzatL)sgk%rlx@q
zYZeHyE^ZE+LorCP>u<6A6fW%)gS0u2Wo0FyJc#w{56gkLjZ6bXOdtA)9tG8B^5Zt}
z+bUp1N#31|VLkA?K6)qeH+|r(`*c|3VNLfzXJSIvmg_d+8m64&#YCg=+y`>{D1{$Q
zmyafACUM~&?8B7QWBSOyILBfb&ifVCYz*rr&RJ!Q)YUg^Zot4qk=O7Dk@^!QB=x2|
zOub(CrZqn4&*r!0AFCooVeU=o90p9&))9?p{ak6eUZ2~-b<|TQMC|g-!YDBH@(=)T
z(KWX_opRznWMf>Y68ZKb$O2X)eexPc-Ds+j(5MS1Z$m
zzP;-9koJk%JANl&uakMy%CKOZFHgpbS)4FQ%TidQUG{}RbC8X3eEjqi+8JaPdfn1G
zgRy-f4B^4ZW&?OU=o~$EsBzmMen-qbiSdC$jmq{|w_%o-Rh+|1`+gEoxWxP%&=iR@
zaEydG;p4cB;<&l3IFNR&`#@!)0vhIO+ZH3nIu;6O=sot3F{ODorMZxG2)Esed#yff
zvrl!qZMJIiX%ck@F#EK|>8EwJN&;$x+3xZ`E9zWLRT-$JN}%!&qY~$#<{3_WK9jO4
zfVDWhF=Qhd(*5({96os!!4F44zp$`o*)ZRL5ujmSHGiFTVK!$y91fb>>+VHn$kwGkUpoZ&xaF{p%F<-;mejyhBWJrP
z<_Gs#(;dM5!GYt>I1J$Y+JiE-n|P<1lRC8Y+H`7|Ox>13zcc@tj~Oz@@CvRM4VM{5
zD*9gU!sUX^YU*xLd0Jkq^D3fIaUt$HVwmeo-)`?Tw-fwU$QSdQUdP?X2@c&!o7&Lk
z-dLJ+?}m>#)73C0FHP!Px&_RkiAT*X*FDp_vPcs?7I)2&zFyMO!Ys6w7;d0KJf^kG
z+Iy^bd?hn>leHZR)9RhPZ?lvZfnH2fL2T%`!cog!q!ZHZQ{
z+#_BZ*{bT279Bp3FttRP=)yhEeO7d2D?`BM|1(kPFsY#R+$Z(
zH{rQj$j$KsbMzouIpF@DOKn(Q2;~f%fZ}9n3LtD5zx+)_^zdEv`tbsB>G|
zBC=%(?~awZ0L#AE+*71I_%_Xr0y-CLE6XJsC#Il#v9>f@#6O|#{OK-J^8t(_lu;k%Cqx;o4Pd+U<@%$?VmjhL
z+RwyJya}(bPBIf~UghoOMP(r?MtL!Dp#eBa$6{E7v~vE|IA%^N*DphcO^$D_(=s2H
zj&_)rV#Gn=f<9pyF~E-`;x`iU!+iL;KmIzQ;%P$uGGX~5yxv%th^56j!WW^<4r@Ic
z=V!eQ(|}$0#nv9T#{}C
zl|X+?8C{r=E%Sy`noz9>8eulBgBc~wX{Sr1;Z)x+&DE!)xJzlEwxYlTRJZ1;j?8`
zyZJBX9|rR~{$FoF#K{Q9L3eU==lE_n$~Wj+bx~rAzU^eN^4XGaIM3iPi8O|NT?Twz
zisnW+Q5p8j(QBwxAfZcz&!zY@0WaF{bEx(J%FjLgkjE$|oS*!JsW`?_Qs_@@T^N`&
zptKGo0i2lC1I0ZBBu7rHtxT|PkAG)j{ZDBS3`ST>$+B$N3?2+*EJXmXZM0lmhhF}L
zrvi2@I392h2w(U-4GerTu?4h-(NPbxLQu_VWp)7{vjaAhE_YY@8PM$tZ*YWfn4-?&
z<`ygHvcx#oC@sn{n>@^eZl0~z8N7vYf_M3;V^|4u3HyNF3}~V6@BAz9Ka84>6he^N
zOeqI`HRKC0JyIHJZaq1a7c1OoQttMF>T=T(?q@DJakwvTIbpmnYRr_eNt%NqYu;6{
z8Q$&kOPMKK81==fbwQWXg*~KhgT);yjj8LeiZ8@ObL&c)s4(p1M9JKe`4pUdyja7l
z`d*kYbv!a*VRVw@{02H1x~q|$c*74#u~(Sj=$&e0i|+B?(eF@cd3rDAMt?l9DzRxLhT(IuVzS4nP74d{(H0k8NXP66Tqgn-UI;0*
zV?6I%1mMC8780LGfV&arVl^sBxbQ-d28vl&1&eAwXP2b|Tb!aa5(RIIyn?S~ws2Jp
zZQ|Ctb9^)5%bof{`aOtOcMg#8M45)Sg^IZsUKpytN>^b4R@%7-tI>jWCABSJ9V)?!
zS=55ntOixBVpWXGBQ>>s6;L&rwMFVMsWtd;P*t$@f=aC_WI#dfaH`g+0?(yhRlqbI
z2O5y76sks_wi>i@DNrlz|9oT)rD}EhqF1OKq)wY^7+Y{`7?>GS!OL$Ivw);K7FDkL
zkj~(cc9nN?m|2n+IWv^2qRv=0oTr30j?5^|i;4j^E1|U~2kuXCwP2soViZqf)TNEH
zyr#Fh-29js+}tZBFj*ruRcz=pN>!0(FV>8
z3`HWC!Rd%aD4=z^zqvo#+n-(BysD*N>s{ZzxIf$3-a{>gy7{8{&2(>Vx+k49zw*7X
zw#%>XZEdc-e0_V%eRaBvnjSsgTQuHUn;k#AI(_Zxbaw`K+9yAmKbD`bo*ei;-=6>G
z$-(@$^RMQ=S$lH$F#Z#7@~`Crtv
z+uNFI*>=+O;+3ge=(g=Po##bI-LCW{)k;^htDAfKJ6EsH_NM#GLGQEA?kwQAwp{VQ
zx<1w5>kYxk=4|cd=IhIsUu(YHeto({=lh0ycXsk!;hCPvFMDZH4fu3r_g=v(YeqJ-
z%>5JEaulS^jS!anNTuh*j9BNQCQAbgoj>wU=ugGuWw)I3usaBj?-n-9VCwF$IR5+8
zH5(`JrMwin7}Kb#gex6pr8t(0_J;4H*fNfWc@)RiK;zkpVN(TZK8720b5JFLO{(Tb
zL2o__3fwcm5MzCtI@-uoaHaa|Hp}v~%!IF{fDJx&DzGmMtjp;kPs`JW&@ymlVnUb6
zBfd>;{sJ{vfF0H+bYMTa3yY3NA)R6i^$q%nhQqm~y2WGN6eFmK9Kc}~q=UGD!4ykz
zA+Yv*gT-CXf?5M-2vUz{M!STm9;fr0Dx?PVRSw5S`ew-ze^d@y0K)9vB?1|F3oEQe
zgdro7VlbIIEm42WV1enm&N&86kuvLN)`UEhR1SSK7U{CEPkjdZM|T{p3zDcwmPsXox$5L%n$K+?pT6Z}I6^<6~1FAUc-Un1+
zseL4nHE^%<==70Dk87mpUZIkX9{ceql+xn0rj^uExB)LpbQTXQX;qV-zE(H+MsU6T
zxb)g-`A(GPGb^bm?OYT}s)d$TOP%07C`<<3VD5$aViah>-)`RigKZB6)}uZnH76|@
z-_OWTU<#8!pWt_hQLrf7u9hcXd>ESiCt~GUwVbL_v;s{v;sg!^Gcglk5ca&R
zDZjUnx1cgmeVjlg51x|0Kq-JNOBr^amcu+Pnm3`hZz&uKqk(Tdtte>hh7%c3TMi!h
z*m5|5J)L>mUuHXb^Vr5tgyrj;PI
zMdKC_8#JvfXqdU$9-_5;miX7$J!b_#Vf`#7rQJgCx}PFlXI;5*;q${txpORWuG=^i
zLplq0{kGvjztX1RkgwP|TJY0-;0*rtyTC#OR@nuXuEZ60gH{;$R&Z7#blX77%T;y(
z*HWYZD+|vaj?ikhfVP2d6TqW66${v4ArPPX7$$ov+ASUKzm)+@J}heMQuSBb1l(6(
zME?wWhedO%!2vOn-e@zcyXOoD
zD&m)o3!HNTk=0e1^{=d|to&HWGA`ymDawkOK!WIr*v=K*2adPPhQ4avWZ6C0`QqYv
ze(}7Le;IGtCuEJeyB}SBl|&QFGni$mT@>Zz}|KBEDr`z=55(DZqwE=
z@t3{R+>gBu6A#U!8^4@&A0G7S{Jie&E_u%*&@5c)EvPEnGg+YPY~@30?@rjmR?bsU
z!xeW#G^2kB&QQ&M2g_Rg_P1BRlmFIaiA?VvF>U0`Mtl1it3QrbBa8_@VzK{7DZoP5vt_g5R
z^U^PpRA=2NAE$lbE*2ryejd893@_3jgdT?9jUB*qc28cf@pxKX?ONC^y2n@>H#Fe0
zpjjzra&P-V>g7kxOTT`5br&kkAj%@fezK!ydGSk_RK4lmhp~Q)_SIhK@Wn=P4I>$f8J)+t1`LC>L=Ng#xp)v&{VWLOYooG8>`jjhN|
zloxHF7|y+~{6R}s(=9WKS^I$rw0lf9`_;O;$99Zjmh1xbeTdlZo|rUXvXetK6jCqN
z+%M%&#ii5;sbMN2)M02$RQk{eKTH;+?pd`&#+cQ-B_Jl^-x3U?>YInc9J+fenNA!B
zGs=>Ur3XEYfDypM??$*cR3bgv>Gb#WPo4LBRnFx-V4N@0ghsp^Q4S+sMw}ZXUZ#Q_
z2f9SS_dza3z{3ES6Uf^nYm99(bo3k@tP&7dnr0?WrFT^gtq@~JIyrP*4y@4G92+#jh_Yxnw8PTUWPl}n
zq&&jXK)>ho-w#WcxA6s7>N^=!TJ08k=$$~Bn#zvMuxx6l
zP=koTkE
z-re1kT5D%<=k9+J#77U+3I5$OIL1U)Z;k%LJ>N$EZfOL)u60CZ;YFo6m)pb8;^?%s
z+9zn2GPntWXa=4}k);M9nB&kE#;^x=HiQ5qsn>CV1m7xG(;~nCC4J{&jaY2~+JXshMa4{?pTaPa-6eF*v3r3UjXy;QFj%m~{ETG!E
zx-48C8s@q1;v%ZuAvR7Obj3PdNp*Kmx?a{ZUzNUqA2?l78^5bBo^NUq!^LwLuKGPY
zfd-E}xcZeWuFqpgR176CFRR*%D9W=KP-0&qb@#r=qD|`^wa&fR+=XElssG$})|uDB
zFkzfa^K#2vTZf$stH$T;ltm}1aV|$i-!oDKc$E2`1y~>aVGspOOY)s%D3Vx);S2w5
z(qgo)sne#o$@1;XGU16OuF|?Lh(jHj!2HJ
zm{3L>$0n|lpEL>m2<6x^K>OW`P>v$GTS0%-6Dap$VO^f?7Y1=ID*t-xi#gj=l)8J;
z{jK`h@Xy=mvmN!ttV4z~WPTf8l=?6TR^MZOxOh}I{OG0OJ`tISdez-)7h#$C02P(!
zDV--M$HuvJ3ky4s5PS+b=z6uf&u4(6D>;1FcL>#
zbOpd;=|9dr0`$WMmzN`_le_~=vfo&9!uM^Ao&tR%rq_E~tH*%7hY7AO_D&4WE`0;b
z`>6%4mNf4t@H0QB_FR}Uy<71*HCgLXz=o{vCvAALGCNGPm4QBIw0U7d2>j|bJmlp1
znIMEY=pUbMMAMk3+`=$b8ssxvSd1)f$79{^;;&AB;#|%d!08}V*7;H55(zq&vclQB0!*tm|Q5Fph>Kj2f
zbYoiriHPhh^KBIjJ9Q7*8ne{ahD}1lm%n5m_T|RI;_*YZDs9r;mwgvk&@OqUOd+N<
zv}2*FbGf5Gg{iZS#i`=7NK0zQ(C64?2A+}+4@nmi2S-D6>c)tq8gYF
zw5qzyb=9~c$hQ7auI9{d`2GeE$``PpNB3mkJ=wr;lV|;#2(?GaNSG5wUaIYik6P{}-J_+?;eH!KfOy&=Rlu8o
z<(;{D$2P2aRff8D^wM5*4`I^Th!B_tMv&*3{@D2pUXfR>vg&Yc(tTRsm3h+Y9Jf+(
zuK9)9sQEv_NfVEnUx!o*161)Vy;bqYHb%v-!!jyNAm6U;mfORgrk0zWx03r7_7ANMG3iDY9Bqcl}Ru
zzXXW!M2`H{$|{b~YZSaAP3Ojjo1*x=+mf{MF9+9viMipelR^&+7CI4=UF|9i?vA+k
zg|WiF>2q~M5#`@76c^?7w!9|&SR|=sZ+ZDgKEgD1
z5h?-lF{4#}l)WaWr6GzfF?=rhz|QmCgmBekQ8;bdG=g$5Ftg(23Zs^^!J&n9!Q&mR9AEu5{yV47V7nm(0dJbo&%N%fg4HUqvo=~m
z`=QpNawHccop$Kiw|w?3Y~h4#DBq~2{3OHWb;9WT3@KnLYVk(2F;BH|t!@(|H<@Sp
zQ4mlj!Xm(l%^APMF=NJ%-`WE?`F&$#}h^|#6e;S$@Pi%ea-?+3f%*bYT
z@=|`4aibRO+||y8yhP+%>7=iu^zukv^-k5bRs1rrF*xz(mqn=G?koMdBEk**Ry`Xj
zbrIheX7+q!;59e>`|8Hg)PcgDsNO*CMrrn@!^1&@gJrTrY?dZ9p}=X~8^C!MPijYi
z*X>8d3GQN!GG);`0KCbTF!XhW23T76T!PWVHPx*RWp@ST$)Jb%vt
zYJS~)=Lsr??_%}m=8sz#?%qW5R@ejC+ux8bB*{50j<7#zw%>$P<^`^CJG)gbIAezs
z>iy(f40^fLz?ueh8=|+u&0NdTktYYdjgK-~5LyjJeYt2T-k}<^OV2Vd1-P!mdD&YZ
z@j=9NzM`YE&N|o~o^u|+>?OsXQ#jp`c!h51S08@uGe9eqW!sO^Q^NwBBXJAoyy(jt
zV3Ftb+0q}3{leutohlKnOOWrY!{PX+$=;u>{3qfAsACK(RhK#a34sq^P_
z*6?1P>3~Ng>7XNRqAK8Wx)4iXA(>TOD
z?YP&>_lg4@b~_q`6(D*g585UXv-3LWP!fg)WZVpz&-q1O)SBnid$*-!04)ncXEN$S
zw6`OyYSOYkfLm^N<7ybVNg{aCq6_yL(sHzX_NNj2D9MLua(lZH9ef7!18Ox#>Ov*5
z@fCh1bE|9Q%1uX&%_oe$XeHC^ao*5zy0}~IWIxgnZa+9qE2@v|`jydcz31lxeHEos
zM5X6LMO0+Y`CBQo(XiO=PBOc8Qa!bJF*jj9WKwoEOGurr%sk3!b)x5M8RHcir^xKq<&(D;#!~}DD-X`WV
z0bRQj3?BQOe^M?WO_7i&x1Ct7&9w1n>6&n&ZoF$-_w=RDY;jOuJB`Q|xL5yb_x__T
zaAHJeBNUyDUKq(X+LlD8?nil7W+oqBEW>N5Ms7RQsLGJ}j_PeG2fGDqlLG1#KNnGA
ztp$jgveFFxt_tr6l}F~u
zn$7KU4RpyBN7}ium5TJYUHzKm^VmyiK!`Y%X|;sS*RX$Oh#m_c6kiaw)NO@ll0)WT
zr$|`d4dM!l`b5*nv4N#PBJBNH$6zp@r3Md8SP_UHwg~+dbhz;Rg_|;_4I(E8x^R_Z
zLqOr5J0!YU%@B(Ik@Vvi10%)9q(dW($1k!5`i@_QcPc6>L!^1ySy6eIubiI?hmo!G
zJhxq#unsRG7$`cyI5*L4C;j?N_C4NrZMiynAd?!_{lm&Tx_g9^p2_KxQL0ZXbgl2@
zSO1s{zSdbu2&(*c@y*r7+}LccxC6g(MH-PmoE_XeMZXMcikO-x-uZL2q>>vTqI~*+lqb^6
zM||6HdG-y_AL{pM3S4XEyIhuhtC0h<`#jn0E4B`HvFVD;meyZo-y|I-npxINsQ{Zk{ZU0T6L5qJ{v{T^UKl1xxsT5tTWWJHAzmAjsuus$v;92
zT~bps5ABRO)EpI8rxV(S>(QLdFKnm0#jCo(c3*kk9vspdRzbz(((%;A5)6~-5{cyI{X9g}w8aQfyAZ#|-;6G-K0S5lkfXBNgi_S{PMoP9qt%%y
zt?cb$t7%qacvQ3!^s(a8GKagrGS)mYwzo+|V-sYp`wE~((vUD%Vl!C;-k4c~tF_+|
zjG)R@xIMR=F+$+UX$$1BVTb?Zr|nX#P#_^5QRwsX2JRk7zO%Wb2&bQqzPiC3GEA4w
z0p41dIGtcv{2q4-b(;gQ)!SI^m1?7e{i;)4C!d
zcm*l#&&rIxaPrBTgODs@Lp3Oy&R+EnGR(0f3cuS4^7>L`%A-1NU=+ZacaVt=d=Ep3
zO+vjRg^*G=mNuPf_wsH&8*V;4o9!_c@IWkccu}{UtdzDor#$7-&9k>0yr-gtWKb=J
z(Qc*`gfl~zGZrI`CE_lAt+y|xIYyMBnWl2|=e792>T8_t-1U>%l^3F4CSJXmUD+|9
z#z$AoQ|Z`$F0Vl`TFujetQvX!E&LG&!H46e_=BE}_~;Y@&32gJGD_$k{)dm9h|$OA
z7xSi#zn;?z?fDKn4tssWeH+KY?END|^CzeH;m#x!cnV=NPjEA3{z8lc$n(U#>{
zTvR2??e3~a$2ZGm^qL>2a`oBtIjsnz(WCcIAzk
z3{G;38H!Lvu-kwNA{g{)Li^9>H6~;q^pri~S342>oO+1U)GTPa_z_4i-_Rs+J-k!S
z|2CT?l`+0rE<9p=b5f+0KvRsfuOm3ap@XwOGHvlfFg41$B4L2x0Ds>nQ<7_Bnsy+<
z2ma+MZ7f(NprVbBy#$hwujxfX90M$k|D^-^0RNGbrbm@uiv;QkedaUi@G~0i(@5R
z^sC*((N4afyiMXtRdTTycs+8SXE?x)udrcA*Di(93})FJ!C<*fU!QroQ4BcH+q@5`
zW{VhTVG0=OEeUe)7iNi*Tdb2@%LvQ#og-k!lQceIh!Xa!KB;;4(M=RXQKOpK*TQcP
z%$CB<72|JJJlX6h4ebH{kiqlKJz=Z-nGlca`88Qjq|EyhilF%;dYS45ZJ^bD(^WA(
zp>6{03~x{#R;Z!T)OA$%V1J+p0lA-n(enH8nv!%5;@;yAdM;NywF)s^Gqq_6d5mF=
zeuNLXw-2_e5n&wfOJw4$SMnAnB)WU(zsK~b+GHix>3Z(=VEr!JVt&$<{f9!{BI4Z?7P@++VE^%f^~A?6XRo@
z@+-*s0sN9&>zf9xcvW6I9j>Ulrz+@IJ$@mgc>2PJ7_CFjRZyC9k^(H+%5e
zCB=k9L9=}`6x!spCS7mW!gK)b5PsP4vIiGZz;i_k{#x?9x}4{;)Av1@Dot;7*~_or
zp}4rxgk52|!H%ajVGbAI$79hlU(g!X)#GtkF^nE|^Ee6Cdtfp>9prnIzwtU7YCgK(
zU>R5y6y1AV2|T?)vZbLQ-Z=dZfacw|WKL|##EdS!zBLN|)WY@EazCUm^U>o9Qhs+(8fRp!uvFuhVC)JXKP^67BQvbk{WZ}7|9fOO>bhqt2?>uL$uH6lm
zTSbW;?1nU+SHsFrcaw=Yj3c3cj@)-#eepBnw#Q~ziGO5{U3K@ND=GNAZ&EGs%eL~n
zOO(-TS|LdbUNqv65wrx2-uA*@K!mx%tK8YIW(};2qgE1KvDsxdYa
z6{gQAK$jQWDq;iaq#mmKwvgjXWR>WaSMuB$?bNo%ISDL-1rsUHWs3CI>=j
zC({<3FAZRaw!gr0d~Iv^Dz!r>iW~mprV~4+$lowoFiK+elzfT-r-P%i(0{h)fD}iQ
zvP%i-A2Oi|{t$Y2LMJ24g$>D1njtKUk9!6@UKGh$jPYa3z{?~Y2(GJ_=HQ3Xb9_8G
zD+c*{=wp@*6uo7^2MP+<*QmxV8B9QGqrQI(;dXr4)1b;Y(*?Q>A}TW{a)(p
zO{7gI=AB8K{>SHx?gc*;JSWdo&Ce>d-UAbZ73V1y7_WLr1{n|b05<$mT(zGGLoTE7
z4|==Cjjo`z0biaUAtZC?l1}sql`x#O4p_04F0d7R)wB1J#5!3NR`7qJAY+==N!F+v
z{5qJlhFH)M)caiqi+|9f;;fS#-M$a_j0xSpYtMEW*Of=8p82z25LE+LQ&fQXuQ$uN
zn%GEK2oL*JgQEF6{?7A!LNVk!nHZ`++f0IfUXBoYp!qS15EvHliJ-;GupMzW9r|KA
zaj!4lbrQd5eDy54DKy>{T>L?(xgqL;NE-UP#g(9VgpPXmQ>^ki-x
zL}qOhVZ`H!DTnsa+J#9BZn64z;
zE5Aeu8my^Yuvxu8$|IY$=F1}#_jH(RD!Oa$@VVMW>q>j`dtgI~W@hyuQc_y?zN#6m
zoQvB0R`1!kVelVDWIWw?Lp)qRH)#d7VC}2pk;VMk#TwRcaxG~AGw)Qf)(lnC&vcpx
zUM|ohA$0hNv+}{;wnr&6$m*%&kD$_GoplZLvFxudajv{(V4aqGj-Se@_paZzssOUIVe*+!If$aEIpwX@4uyoF
zXqpLvz`|I-SyuQ<^TG_~=v|J&a*2jxBW;=NLtjbTv&s-uaHpTH2a|hA*vJKul8p6Ro@$QBpgHub)pqF9JFz&kC7l!!DO7(T15>!N>Aoi>5mp
z9xOB}$7fu7R$8bqEQJL0a#xD%)cGVez8WC9LOh*@qi~<9?$_616-lIC*hVRXj
z(um6>XybuK?JqaILRv6gfTHo}OPoZu1o4=?f*71DJl6F!n_dL#fw;`|Rmv}Bf1aTZ
zCL<{2dAXsadLgQA-dh1$>
zN~|w^MpXo+pDoc8DHA!4)q0Z_KzzJtAYQh!RGWwAp-QEiBa8(|}cG$9h;fFSnu*C%1AJ
zz4Sao@?&)_H*!G*(-9wM%mfA%)5bb7T2MFEaN$+WX1+NzLa}OswP8HlsxpTaNd+?T
z@?913kq`74UJFcy(w)6(oeIS(0?{y-H_{2D-I
zdX+4_44+Ay0)Ar{pKE@lhrD}8RWj}0dhk)KXmpRGg|a?LhwMI3qfx04(f1046i*8+6Ix&=
zHRQ0&2eWvoCcb}YD1d-vm1964RoCs>mA3m)Ab=2W`IUIu7ROR$8QoOK|(!S
zGz{~#Gli9Atb0tTye-a3phAxP#Y+3SyOlfQgncqT|2<>Gv+2jh@18vj7XxesjoN+F
zHFf4X{*V{gDjApT?-UU4M2)|9O4HHw)(J&~7=LQU`p^#}->A;kw
zxwII0@zhY#h73R5bfZrl8h)m1kvev{+BtsF7{V?YXUVpH^Tvc+(6_pE9cqdZI`bmxtKtl6Z6F>(J@r_<{-rHrN5P
zt`Nj|H)1=E@aFX?PsTL$iFlj+bNeMTTU@+=Gbx-7CTX-u^ajhqekrW=ZB;7KGs4?C
zj0dfp;;vO+k=FE~;`g5f)bb6{iYDigll}C2FKX|Gr*&ziBhzKtd7B^QpBIll+eVo|
zpOaNw(e%JVBq>{wki@(3(Zv(FV!)!6h;$T>l4T@@U==V2}g64Dw$OQ
zIC+VRD32dTrgcj9hYXq{usr+9nx&7VT$&d(DzSE>+4$TV=u%VTPZwxUFA!@=Y$v;_
z6>kS*ZFstKF3yW=2fpRASTs@RZpY-W)!*R`SDNJE+EA@--s?6U*DG_8xx>s8?98W@cXt4hXs>|WyG91niJj8X`oZAa1laj2hQ*_Wwu@fd
ze(X|)#oUmn!g0bW??VtO$?W3oPKtdS7e}1nfdp|Bm4ecQT_S;6;N|L6^VExjWC^AV
z+n$zqZp7_Q3FfWIL%%~JTHCkMo^G4wTnUiD&;!>~OUswT7C_f2Y0Y8WuAiaG%+|7O
z&JY3LpJdy!m8_ghoZst+sUo@L#~)t#WX)O_$}TyCRx)4Qy?W`p`Lvp-c(5n9q=_UF7qc5OL
zL|3GId)`abv41N@j~Dw-*0`1afyLwchj}06v<~;@>PdqFbRJpf#bH}P+cM%J3wQxm)MbXQf2iFL1L!8VC{o14{gjrm@m6hN%As9VsEONQ~1g<
zUzQlr2PxClq=+AWCoCL--kgE2SDNc#>z6w9&<7^3FwMf$^WKXf-}5fY${17Ev71u2
zBVwhb1O3*kE&JB1DKGnEIYu8R&zZK9pyp57JG;Cr|V(R{ZQnR
z@78HG-^?4MY8}4E)%>Fc_cICfqokM9K2Fq)sFN{zsqZFyVKHv@i=KBwf*^-G^P^Tb
zb5H(UKE>HjIY
zAQEZZyMNHJEHBs|>+bzln3z?^PN>QqE(Iv1#lM+uzH>}V)({+htE|hd5`xJ5A)_WF
zr9P#5?0Jerr2DwJN?nfrdmYt0_UL64R79HTil6fd0zw_|g$kmGW79nQT;aDk3@u`d
z)yBHgnE*bz+K)YS_iIRl&L_Ia#v=;5B&X-QecT7JX<{^XCNvG+-%Tn*ckkGlei@pu
zD-^0KO$yb?OmmzRl|HoFtp2+DYDtFm`SP>+4fhG(R$5+V*&MZtOtYyTWmfFxmCJ)6
z^$F}eOr>EXv2NtkyCdank#9HJRr^E5Xj=5}j4umCB{OmTEU_1F*$ji#`OhX*%T`7R
z;G20V%xs#f*6ZlMyuPhn466zeLnM{gqP=VJLRpk`^b9?pBzv(yQlGMm%D?n
zn~SB(73LYy(TQtk7>SiC12eVUV&va1>%?#CThJfAMM8?+AIOdKdp0&Jpmp_AJtIgo
zvnx^a6WaO?F;e?km-CBz|CpN4wAVR7R_k;Arw$kj;~bt}w_;H|`{f#uX`0Z=|UE+2`eGHvmo_l8MEmdx&9{7%T!-+%cH9WaNBvSbLTDv}m
z(id;*@xSAHCf`VgiLuIVzZ7Z2&!G)S5WcQ0FHUqh`W<9^&)tEjAKb+BeYWysC3Rna71U0N(*XG609r4r}jS!1|{?VD6OEy%Umjl6x}w?v7^t;_k$DS
z%*#|8%Xgm7NqRO&Mod%$LcKG7PlkFqzRRTHS11rJ7w;O>m9?!1HO-4OpX_m(Wv*VL
zmi>kuH&UyPpO)TqW(r@(NG`zM-yD3KJ$QcCb{eucRhXAIM@IJtx)2}GhlHzhZ12=1
zbtNH^=G5FPDw#gdmY2k9ViO)zvNTu57iFF8(#4rvHPTc$v|$F6CQstMdXQ
zfvzavB-icRAb%J`U10}Z-0=ItRCL=_zhoRktRmkG_H3mxt_6yF?=G
z<_(W381y-g)rFB(r1hsvW_rYF6Jv+uQ|RQyC|e0&W20TliFn(gFLS%5R*f@jR
zO?NL#&H%-XCJJB8M|w+n9a9YUfWy&PM!ZTacikEvHWIQN6EmH4;`+1vcj4c+Ga7lEwvo$KC|!z
z(P^b+%;oT98;ujj9_U6cPU0Aw4f57#ZY`niDo9)TrPKw(Q~X$rhe?;Y$D}PGP}#Pt
z@BD%P=L6fS-5CE6B;3W)iYEINMLy_N9lZf5&@p+4T%hV1W${3arQzlq(lR!GcBl{P
zo-X%YY36P@TN4&!AK6rqNkKTIkj7y7OpmBqXBdkpl9h7UPk;Quk}SRk((s4&H-9j%
zDeBpUP19LTfX6FSban#Cm4wU0H$LVHue5Fq_rDbbZ4JXQJm(UuK^G{QCOQ-b2N^WR
zbd&Guk_j38b00hLnmo#o;>^d~e1JeDAX>@kfS|Aj__y%u$Swr4@!m&S9ZzsM!2sD<
zTcOg-r~)UC{YJcxkSqJMNnT8O_|~h$R%D*+8lQY4JOa=Lg8A22dYvJlT)_b*T?2V1
z{S>1~?^^c4?`h$~E*sleCtOt8Sh52)Q&0c-jj@Z%fO)b&CXxw6^
zneSq@PX>`Yo_OxD|DG=@F~wJyK}zMe4!uV7{z!WNyC_WU
z4C;;>H%FKaYTLwkmA8J2sYTeUExCWIX5e5@4D0Mpc4UzSd$wZiHz$7vf8e#<%%BCc
zGU@F^GZlUMh$+N!=UMM^P8u)ZX`y6YA7ECRnQ^t)tWk
zFXfM?1OCNLz#tH4ywg`Op&IEnuv5hQF
zcO3o%(_nL0N>I1pf!OdfJGNf3L(&>;lL8M;EZV6ybie+o8Tl%4xu^!{Y$$&g_GJN)
zn86M|g3(rDDUmsTBrY-KHJP33$G4A{yKdjRWu&A9oMR4(I7kUs-jvwCaT(8hJ!g^}
zl7@c1H}i2u*$a)W?3`07Z{~z_jSFd*bVA+-OibqJEM!J2sJZ9$K$!g0}Ta4F=Z$KEc5+H+6G1`6B(rZkijp@
zBstHFJwHx4dx@SIPHo!Ya}*AiOX~ZwYzew8J+Q$jmF198fK^*@#Bm4+;lnQwl;sd#;3J@*pdhH%Sn44B
zAAiK~vbznNo4up8lZC0NvlH82;AC~Twdr2`?DY0`T>j1713u?cq}I*%7wmMFC2_$o
zu#?_D|C98&y46@Nm(cb@d^~eu^VT?9F!k&J?2Np(a@K5rP(JK_bd81LweOHaB=n&k
zbaz_}y7c88a2xD4
z?8x_S#r_EF@xTYVIX;-y_x65Bhd%aiK_3q0uk`P44n%Kni-!}AmTX1fce8V=vLZ>)=&Jr##hfyWVZSDu*4~>)-d^jo(GW*6i)!+3P{D^?3fY`
zpvTh-4{wke_~!CK-xoG3%JR6kv%0!?wHa}ruHOcmKJvW;!8V)0zSq->dnAt!#cXYN
zl^yjU_q&4xkcjuq;N3Ln_GBImJI2x%x!=3&(sV0#Te}FlJp-M9z@S!J(MLaf@O=Wf
zEp2V(L0{x?V*NMU79EkBHnjvqOjk4B(TS7`!;Z!vVSPZFzO=5$r0T>vqr$HjJgzWU;w%mwy7Q{RMhFK3NMYhj9&UtI5H1
z=r3-RMCa3CP|?R!H~WYETEF|_J&%Wtdwu=Kn>Bq9Y+}^FVPsEWCai1Y`vXtT(4)HZI(|y4<9L{#;-M>Gn}+
z?m=109?!5mPP(Yr_J5X({<;7?Qt^qBI~oUhUllLR^iyJbFa+tAgW`%?ZdZP4y2hpG
zZxDGsbWxRW-Q6%RJ{noM8%X?
zMIc;W@DSI-$?(V(3>nMwVScZt{L2U>44WksECGfbJe(+vc0Llal!Kbvt_d)2*C?|C
z9(X(-s9KvJpQ`EbsxW*&1w?ZwVQ*OW*~a#ui%P@iXG(17>3M?s-uJ6m;JeE%@Z;vZ
zlHbET>~80UNUPz!GZO6N6qo)?2G%)0Ft5GLsbBth=l3|CpWXI&t`A!~(u3`YK33?m
z`d;0Lwuz2X4p4#ws?!IWohoy+bkkzUs5G}s-e{K%_bLAA;cq0s&G(|;s^X`=dh5VQ
zBbnN#hlywI_%r77uNdU$hT2Gc2lGfrx_+nAw{Od?linWAg>mEZZM@aIr<^{!vDUvX
z1Tw=wJJ|h{0^M7pk97(6wA~SnIJgPIKXoPe=21g}oSBeADZ76Cyp~*yDOuH(qHX`*S1ebwthd^qLUgXEOS^seeW?4>_0Q)(%fEqetDGZ=rZ>&o!}&LFUyuA)XY^&RACpZz{Poj);L35-ft99@Zpf7F
z?a;=XEnH9{Wlmf}bENp{;p+eiO>bOWq6g-;c8g)UDhmlQLcIa7`)McmLGBsvP!F@9
zv%8v-$3(^8N(_OH49SN#)tfnLvEzyMdU4Hib(d6GCx+;cb;t{aA#7-lnD6-gh^^`dM{Y>d1=Sv^?LFh5$psk5g`J5uOEH(TYa;KBoc)8RTH
zYj`T$_0~q3t-4k5#gt2#IeuZsdpJ>%Z!@MC?5=j_#2a3nm2Z2K6k_ghyRqeRc?TVM
zxP$(=*Gw>C>}EFvmkEEH@Q&A75v5%5$7&h{VOuz#CI-#C4+6
z1m`aIiMuJqUY~*PqXT$b_E=`CSC*mp(4hkDwzXK+y+0(%u`O181T>q&;8$_|pI4|w#
zvSDVNLX31V1jA;W;wE%7IpF)2Azv7jes1dk!M^aJ<$E{+0|fhL7C9pR#+wX)^fO6*
zbO6L5!wF3am}D`}1j&4_^5bQwBfD~J_O~o@O#BV^3;^;ANq%a1q2@EzZ{aiLOSFWJ
zsQdt8$e(2eWQs`M${!mAvhZO+K@k1SDGXci3p_~OdfNVIFybbnvY0cml
zo1GQT%m5jQrv9i-3|WrO3TI`29K(T)0fG>dwYM(>zTY1b9A5Bf5M`a(pCbuhV1zIO
z;e2rpX8`cvFrEQW!wBV_rA7a2mcZ^D_qlV##T8YIq}uCa%^y}nl$X+h8t{vy)pEq*
zi=*&QFwR&xyyk2>QvbkHit0!fR9k0=jjFq%0;gwbUzmP5QB;XH1$=^&s)4W?TKxK)
z<&gfmCO2#!H}I(q)2P6#VtBc%S`G#BZqage_Lp18ESCdXo{R>G6r8u)x02eKIVc@aB_w%g#t+64u-pb+dow)0HP;%86FnPjv;?5Pe_QtkHqLVqBzN)Q6T;$q_dVu~Gs2z6(k|aS{K-#X%8utQlFS!~!UhC6
zBfR0gecbQFDV+W)S)*erIGebE>U4^pr7bRaotS*db>Cy?J|_%G=FkCDQ96F4Iso278o!2QmdDhTL&
z^$w_oX#O8!XnF*ha>Sih?||B8rGT(D4AY#;0ziKof$3sBbxF9j(DHb9wcrtM|7>%&
zY8hU(KUFwxAg6jhK+Pre9|+`B^(sdR17a9@6l6Xr2E3&}PB`Cw6`}vaX;0zVSEaw2
zUSf8H%ar!HH>w`~__UZ`7W}_oARmAI4_pq%_5LgIH%)&Mk5v9!Wsh|V2{CtDqBR@V
zV@vwb^2gvzL!}HyHMUSdGO0)n4nUHj)+dBl_nsuw2^eiq!kWXa!-b1^BAz{g-M`VF
zYJisXYKQw|f2g^8e|C=+FNBB$0m|5^oAnI|NJ0x`fVB8$4afmH(@4LcmUqtZpR{HT
z=(!z^`_=gqLc2-aYK-6sDnMs0+79|BkA5_
zl_7?sSBbtt(2xB;Fy)EODhX8eAEE)!R7QqYk;cKtEzg?7Rr9ERJnn`?*V@82dqvpTvPxY1_eFK6DBoP+@d|J1kP}X?O0D;g*
zYJ21Wm?H5Bc2+0%8!HLM@rg*9mlz>g)RHrsa>~7B_5?lIZECUA%98vYA7c$aNH!9`
zS7`$Ml1YN!xW10c(pS?I4_?KT_+liCV0AbG
z`}c7pbl9!r8qXzFkgx>dPFm;b`P6-e&c~cU&b=Z)by;A1kB}XY*QG_z`$xL2F&)Vn
zm-=r=lG^1Z7Qeu*z!{%uWxLWc6nHU7ST(jM>DS%40bKbbLpkU20ogwP1HOMXE}H=H
zCMEpQj=;309NsOG1GU&u5g-Y5o0>mMAz8h_M10kLo|6l*EraGCCWwC#wR|Xsp{fl^
zj1e#qpA159`qz>M+AbY>Of6EvY3vD16$_r^gQptHPxTLE4zUmu7;f62aG3+$={fzA
zsx!h(U;pP!B0pK@z)eE>lf&~lSPShR;U%$LWb7Phgm>)k#aV2#q3@iGp5;#j;NFjl
zaqh1n{l^!w`>S+fONI3M2q-*FgdASU&OPCifhX!>;pL>A07I02JO$j>KAGUtH28G?
zzm62U`?~1KkNmxASU`$kHvF|LQF5N$*$M^mJt9UZI|6Yhf&mI5!YC~;AA{Rf(Hq*B
z6!bL8P7u?GUtJWnv;(PRTjwP#E7Nu;M-oqy9@w1AYu9z~97-!2MPQn+ogt>;iYhrv
zi*K619xFEEC3&Nm1AvC$ZwN+Z4d63DMhdgge=+_SEdS>;w_40@k2vL|>q8wk7vL_+
zn_e~cNFwy~*9Vn0JQur;g8z{D=R#lwrT>5Kag2or3f+G_X7yI;v^?yyS+ILCO18vP
zr=Dc-B$y2J&LuZE8Th`exmg4JOpxoMtW-D<(M$3>X~c4wKfy-~gyLDy0aF@rEjB}sGA<9cvQ
z8ikf-=}s@fJHyPb5&NdAv|FXY4Z&3q`dDotBuii-*2xjD4*
zfA|poEuL{u!b}g`o4KK{xctWc3)0GgPSTR@F&zGUB)}(#tRU^w)WTTdDhWKM&;U*j
z+h5)vCKuNAqvJq{A=oDwfUq1Y+vSJ3v0UyD}WlE^yMt#6~oVv9mkld0>#eWEo4@~z%tnU1ds6Raa}jyoAm!6d~u^yl@*nZuT{5F_zz*7UEa
zrIH^-r?Rm{F|-Dv=#Yue%wcy<1!C9n`d7HD{Wm0NY&1*Td6j|y5TAfg5t6~5h{Y#5
zv9kyRv1{4=3j#9b?HD1XaA%16mCELM2B7~tfvKaKO8iSENR@%4HlYyQ?wh)M_WK6W
zHj~OKP`_p|qaDK4^Pd}lGcVr%EfSQ$N7P!5UlssS5m)(v8~Rt7{Ikeu@izdYjY|3d
zOnpjrdhEl_0a)e?Pq(qYR*4Oh#@|@Qk!*CA_)ir+r9*mX0y*cdG60Akp^nKJfPB>d
z0O!wt9TA)#y6iH}`#Sa5Vq`(MpDsqmFbzM1PpPjzKAm~;M2;63S-Qdn0OR?;G2@1v
z_y0nyHA}Wo$Ku4lY-v=xg~aKKE0zn)aQFQ+sX(F9}?<=q_;n0K!S8!1i)4ni_Xr%t@E4ZIb#xV9D&-1S*`L`lQ(&nWC
zx0q^rKDNZWrXlwk4IHGeNbvDSVE=0s2tZ;4Qj2|fNT7lggKx^-hdq_Z1Tl!tLZ@T+
z2jFqXztJgP8PUM+&)G<-DtY98XD?*QBuDrph72tDw!iRQMF#?TCaKMz0boMLH^s=w
zD*5&?mgi?^75ssz_pYyx{~@Ea6Vm7gc#Q0N+&6LxG6Dh$Kl)dw!QB8Se8Thd__j<%bJjPM~{|%Lg(9dg&iG7?m{>
z&5ApTGa0$?%Qz)RoA9!kBuo@cqaT(T6T+LsU&u}}f*y-bbwbNnPq)ggVnjje>aUG1
zCv(YI&%K0&2l54XdkM@Y@;JrX;LEx>u5sx(cE28cSfeciy`LrXyqU9uJ-P?M7_*ByWXzs?NDKvGln7C>8@-@6X^MV{1;3IrS=rVmo)MU
zK5r;$l{qpoYkufUg4C>!svib!FxGcD#*tAsF}~trH6Iq&=cKy!^h`3aC?wO=PFZ0L
zd(btNm{eE%sbda;sv7U@Ulnm$cz=mw6D>R4_^sY{-vCKSpZeu~3Ew-;znD7V3%^(I
zO>`f4db~dl6Mjp6E|R8pa3&+oG<=-0ml@Swl?rme$LZsjIPI63Ng7|`Jm8>Zv3N&s
z&!q4B>1r?61+1txe9{s#kmlEBB5-_heoD|cLG8E5$HLP9v2D^d4ed}TG;e_HhEM$>
zZY>SRYJ^<>EdC41d;5_~gaRRj=mMh6kZa{V-(Pu8W3D~8B%|S4NFwPNr;-y-s%uJG
z_E&Lmwivk_N@;$S9aHBr#yFI5lI1f3li>O^1o|`_NEBT$nrSbe4zrX+qR|xL+W{Jy8mD3O@^Pe+vQJli=K6wN{D9BHCgxG
zVs&Cr~$`CMR4!At(2b
zJd%W~eHfB{OVU}?l~rkIsGGe<(oAe9?o5>+kiu0bmJx6>I(y{!?
z*q<>Rr-)supp$sC5ih$wfJ2<17e$?z=A&WwcIw0Ed?zuLRS1?~1Ki+V8FzO(3GKbr
ztTyi}e;d{~K;Ai}{sX7XG5kxMv41f+jqV2V3gho!xU%S2mA|5l`+uEA(d3tEfN*rG
zb4ck9rqP0<@7FoAk9FW@Yv37tj4uQ=wW+rm7ynu@V9oY3=_Z?9Jn1+W+|RYZ*%ow@Ye{eF!a+YKB%>8r&$PRMSR_
zX{c1wO39K;ToXx+(u6{xMT<(M$ke2njEI_)l4+w7ts?I4b!Y){#oSM!O6|?
zTQSMD=vGEL+q6iqRo%9z>&XlMHT^2-jYidil}mau9qFaksLE@(4U&8N<1IPcqS0+V
zr=UX)VfO^C{aD^v8%@b^Kbvx9t`u<6NM`oWPtr2^y`itB;9
zucg-Uv89
z;7;|8g|HK3pUmmnEWX=Cps2NyiNj2+p?BH&+cAxtJN_0qtasIPx4OTGY{A2158HYF&!FaPKFylPe
zJ9f;CzLmG`-?68c%^q+_o$7;sHF1jf-%7p_bemkr>`}Mnf+gw79GqL4Nm{l)Kk#G0
z$3fdTD?eLHn4h9DD>yE_!l^@@
zX`Rq?FhKU%wrFd!^-70J#pzw^{%xc*HN;ps(%;jYEJ6R@eIc@5-sebvju!S^tBVEK
zonTUL*QBnwCs2(^XD=!GSy)P^6gBvHVVVY&*2_Z+&>21P>#hWQhWU6|;0Vm=&g(V~
zQ++h9nP!pyX;=SG@m}4i8Z=D5N*<%&&a$+7qE(`T8{YOF`)(zvA5LwVllttocGL$h
zEV0Yn*ZX_kK#?g|x6e9$bNZuI_Oh>ki9Xf_pa1>G>e7kH>5heM>J10(iaZ12dUWj%
zEO6}jz{>IqrrH*jB&Uyi$E1(@#@wzMxPSLU#eS&`kZ`h(roQ>IN=M|a&jRp+kB-GDeue$Jr%ca(f<6c02ca
z2*%W$p5|P{zwcb+u%q0s5Z#CL-OF2#gT&mLt
zMm9&r8@IuTcK>bDq(qv%%vg75X0boZaZ9=W(w%%{OII^LWwprEFmu5_^;pemvdA=+
zxjBVRnMLQG5BNDPQ~!l&S!}riC(QOO&)tVF*Rtr1im0>y9eduS^O
zR0jd!l_kH{ri;biy*fIrjLcwH@DdF`}c(CwXYg8zt&oF
zIqs;Dik4p808Z$X9pcr!Ai!3;?3kKF+OX&ViLmEk>?fQ#f)zCc-3s;JYC2MB7oM*A
z@}&3lmqO}*Az?yKHV|?`ylk^curBqD$|U)|P;_d{`a&}oa9tuZ!s*YUKlxg;)INF6
zp|9cew?_t8+~8A{;|60VRn}=`Pqiucvt=!uC&!vb>uL6u=d2zUk;{Esjk-aE>BBhT
zxj&Ui57yGM$H$$^{rV1-`+b7HVfY(6R33fdN=WK6pU$V%S}J2jbR$KMUI53hk5HFiAeT
z{Rq+E`8NKSLL5(?c4h{SdNm`^8@-69+&`8-(@+Vo&%E_(3o>mBi-@9!8@-^MB{cDx7Kf4N6QesdJ|Te-q~
zUryjxoJ4$`?RJ4|?#CtSzrmMJU`(5-PxW1IH$`v`NA-P(gijK|ovb$xUr?gf>K?W6
zC)%ZM+dNQ+6XsW<+s#&4nRHQEne9(>Lnb`ve;u8(FUp@t=gO-|98Hlf6M??6CBG<|
zEIJv>TzQ@CpM07z_nZ6o_AZlL^Lf4YyR=0o4ZTzeaH9e{h~<2jGWnW=-d+-+Y94*Q
zPb~BPPW~r8;lwkMev7K);5D`+ihNdYHYy3@)lX0EP0+<}E);J&H^aD~C#NYqd2J)|
z{({5Q1)*Mhv{lw6C9f?CtgTvTWGI~W*{YmC?Vdk$qiQ=QNV3O~kF6uWo@XAp#vV)*
zZKztSG(pF#Vx&am6<+yZ&l?QM*kue=ug=W&&5x(TX{hQf{2&;gvA<9g(46nSJG
zy;m=39$6_9?R$JanV%7-p2iB`6Amm*>nA-Kt-Qeii0JZ9y!kS}V
z@~qLjN+6B#`4q)qLn<>3iURM)ptP2K4NtyThx}ud^nWQ{q+W&vAM?L%wq>)EyU4W%
zZ$ugvTWGWOMA|L-yn_sn%(Z5&OIczZ>VMX`6D1mbwxzCXLJ8MfO&4n_+e~)upuTK
zMECa{W!hHP5%RUXCg{Yw>}2QPhMy>!fy6m6=gs~9nUn7!$8V^(-^osYkcTg684}kT
zb!Y9Happ#nOHM$~)zM9!;-?ueUzP1U5Ot((e_wLd!q8qCcOEKBAc}kbskbmP9!53j
z9Jm{7>$0(KUXs+ga^&qej+J3x42aO_-L!d30{8s$_gK(j^mIbQfTiZV%Ay
zM`Jy|BF7(#B8y-{9-(|PfBN-_2Mj6>21Txaph@V|gLd$P^BD21`n+*I(o$!SF4AA4
zVddHnn5?lVN)5=~5edG3zv$!TPW)hEeV;LAT?QNEGgNd!t_LwOv>PU%48*!f1I`m?$YZ%O4Y67}m1Wjp^po
zz2plvKR{QC_V*in=s|tnB@gJ=U2sgaD{%H;8<+=qhwrpH|TmobPE8f{q5UUSC0VlRfXlQ;`Dn@yXOAm%$AT@!?uT
zcToUbMmaH@zuyRMm8qEFi5eh+jBpnR?TiQebCR7khUU@5JYFO2%eJnq{qij1YxbFa
z@~q*@nO{dNpnF{6%B$?qR(-1DL*(s5Sli{;-tNKK#Kf0&w&;ln%H1$Bw!A8;Z6p%~
zaNQjJ%#QVZ^oRc8<6?E75#|KC?5?-9*to`}ouSOkWHY-_HKTu*QjwfQoY{wGcGu59
z&eSVJ2FWUAT31s0Q|msdKwYh)r4P5x%4?VvY4hK0nJ9{DQD%SR$5$lBVb7-ePd^7@
z?fC>5vG-?{)^lC%tY+L)j>k+UY^g1-Bld4=1%${C+2slFkPg->w3SSq%c*SUK02Ek
z{Go~ZY%VrN3;w-hWIoRD=`Zyw9nGLoz9{?8SPC}iT7$x-aF<0lZ9GSfbw|WyRWosJ
zYclIzVfx!}9^+;nlLdu%-GTQBmq!zaVMaXB<>_>G&>l)er{tR#*X7?;jBY(q_ECG#
znK<6}{h9SMai~mpf;xrW5I7kMnMg|&Zx5~>T^z%SsFZ=;m5DmYI1C3ig^MuQ5hPK%
zJE&`(AaD0$!gKHvrJ~OM+;^#W1bf@vb!6C22jm3Ro2>h3C4<3MAo~=(3+F9|sCrJHYs>09TFt^(7Nvn@d(1ORI1*lcSNkT7?%hJ>&
zq4r(PALLoz3>{E;(+^0`j|5}+1bJV_Oogd2-)6VmaiM&{`of(B8w=mv
z9dXq5`m+z;feK#nlktv;o}KhPcP2Gajd)$t?7`Tth55L$)R*=2FI5TDpj%4fdrelQ
zPyND23P2WpFHs`uxI}IzzU}TUtXqD^R}bb2gU9C|rMs=~D%mV%Q-&VkBK`q_ymSUdE8T|Q0V9b6QL#cVuQl?R~zJ@H2S{2rg
z9Y(ygv?Ql%tpwq|VW)Czd*|$%aplv#{2*2PpAOKk{^WWrE{cCJ^G*2lhK?nYcGv)m
zQAI;A<+?0pvW{O5yfKQSM*6ajS0GbnF|g{LoWI)K%C)2QdF$WeC!MC?Rip|c@DJsf
zGN-)(K@Qq@6;$)hR%XQC)8|#(fJQ{*Q$|WZYSuq5j;Kmm{bGVXb-^(TR;=%Nb_Mf1
z$9n!)snS;bc=3k<=bQfCIpuczkYF4&DUb5Pz=(HbUMA`qC5wtGXX2=(sc5C_J1EHY
z289cMm*b+x3`6SLJn*ZD$?x#fw=qoj88%&Qg3p
zr?f~VNup(2!sM8I^}Us3mHV*V^XRA|_Z*sMx8|(Vb)Pf!`Yoshb_Tx=^K0sgeEt-W
zUwjpN_R_Rtlk)9DZ3^0TSBsRob!VSLw*)%XzLnR+N?s?uQ9nz3AhvDrz3AhiVN7W>
z85xwA6z&X)@^&NryMB!#zA43kSFlf%t$B&!b*8?UsAU{<=`iM=Ou82x(L;9o+ytUu
z4=oY4Tk(A_ky&-@Erep&5h<%#b+NE3*t{rmJDp3^lHd;^jK@NKFzqbEW72(PcuaoF
zy_J*~Aj);%nkrfFvyYKkxY$ptw;{ZV;P`h`_&PFWb*~vHs^^Bhvb&iYrCJ1uPyk=$;?ov}bd6Zt
zze0{1<
z-Mv}7Y0957ORYjx&z{z{OV0;~uH^x@?OS?a?ZuSUH4do@_Xi}7385_?CwCKY17}Y}
z-9UWPWJ8|raZ&cRs}!%PwZ*6-STH?+4^9uLJFEpWJ66XPIo7BaL%f-X6g-pKEv-pn
z)3U&X>F5}vHbZ#E)152Ij$%=~uC?Kto@1_ho&48umw7W}&-tuEbewL4Ub9acj%byfU(=|bHx(kg%gLYxz`0pv7ALuXCL#CXMIYAzWj`(=s)7#8ImZf*)bSc
z%jkoueBezd{dn@#62>VzBi^k0N}($WRjHydBdIIim9cUelHa+)H15;v>!|)O+Rb=8
zY)H*&K>SKYpSP!>or;$?;5j}c7@D2(D)D+C0k-0U$7d)HkqHOE%T>OE{QcL829qkGu_K<@%
zIEXG!3Mm<5L9ErSqU_El#6zCzQhlC6GO*I4tFq-4%Z#KSI47O6wGiXYSZ3!D${=t13iaL};mOOPFu5=h<5KxTpG=(&RH
z(icI=Nb+Un&lY6|JwocY>+@bs2Ps~D`Q4!hMVFvc%c^gl!N2uRqAQH1I^WdvWxbln
zkQJxo(U|8h3-dF%LX-kScYEI(kMEV~wz*qBhKJIOs8?%{Srk!r{6plQ
zlsNjD;VfsdOQLM$JCw-+u8Yob3h7G_tMp)!i`H&BQW2^~&^?rhdG>qm
zhb&8x@hS$R44ngDxXU|k&}Pe#xP%xlU5Q|
z=pW?|Z?$wtWSe@+^Od)_As@jn5$H2+vi*N6*zIa=Dm##+hXjOb%JTDT=)Wo~5IqbK
zxWX)rc~QT4;t>JiqZR+y74nqvSe-Wv^Z%(xV^3H|$A4CHd$y{O6pUa;4_s3%n
z$n~z*ogv>Ndh%5se%4g9n)l&&tt;S)1-`88lavU}*qqBRkkmaQg#vAs)(n8XvzNa_
z>aPbae3!EiPnQ!}q`r|B+QX*T8fvb@m2!p}SPpU;63!#+%M(!)ysk)FtWQla!4RzY
zW_`E9ovi98in(_zQ+_f7DtPUw!{&RI_d5if8mr4}h-rAWmjHgl;pgSZ``6s$7Km|v
z6euXcFAYyZD_hEbe8*h8lYe|8GIbW+n%FdqWddfmPb2omB>qtdb~NGvmpI;mZ_{E-
zJDM-1X`BBI7(&QsAILVp@G=Z##@JQBTShU6MK2u%!8)xJ505-q`(1Kx-8$jD&UJ>u
zjLHIM@u}NjH9l#YBdw2OWkuW9G||UzRt#}357GT7qr~A8>rT>79NBII6Wb
z3O(mXQg(&v|0t}pD8PGxqi^tE6oUuxa-oRevx~Jjtf%=hwC}1RIb{s6(3d9zuX}f5
z0KH$uO4?a53K0L@8{ToMk0j|2#tHu_aW-&OmR31s&(D$PEDgGo?^f=o+ZdYqbdzV1
zv2A}@5~jTyB||=u?bF>A`N-QLoDgv>_hUg$h^RzjoI)@AQAiU>)1vX;$#a-r!s*-=
z>_+{ta`N^)*(4Knni5f(!H67$3`Wo6CD+60_V<2xK
zeJ@VvF%nK+9g5f!`rE@WmdJ9BQqooD1YvoE%6fFXsQl;?bvQznhh
z`G6*^@T-+b)F_>jK7SXUG0oBbz+!GOV&m^=k0-(B(%t`!q*4cPBOZm-{_&!qx^%k6q28lTN1h
zdT!%qYjyq^tf`Xao1fKkNNKQYj*{+EVL(-ZbCGIx)f7g;oW?n%;ERx`TDdd3*(n8$
zt@8uFcQ83kN0hxT96iK!>fE!CVLNuQsu8O9zm8@w61E*wqn%f9TG@F{gXDGj;vx2d
zax(E1WGu);`)u=5kX%c_2_yEEi;1yxlNWYVM*hSJ5Bwv?N$1FMniS{YsX2MgsW+XA
zw(TtcC})Hz%0)+Lw9iBR>vT=G)Y3gdbeTi2oOJ*#@U>
zRGy_!D%#60aGoCkj;VO{m#CbYL(NKF$59-(%=zXcCQF^GWTePGah5#OFzLiXdCnqV
zO~P$o`@+#1!QQgSRR5dF#FPfH+)ZchaIp9d>B6Lv@K5w@$e#LGX8j#fgUKB1Z#1^o
z_#}j7qAzh!&CWaGmN5U|O7X;Rss2CkO6Hf7lQ-U>ROe_CPD~Qick2lTmL9@Ezzqjt
z0Q=f$44Iv-+rw}?*h7*$!XmFq
zLy=z}+%EgGDDA|LX)0N0~Cg?cA|Qb6HqG60_|<
zd9<=OcomQ3ji|fDaEmBf*#;c39A|lm$BRxvLN9Q8xgX_fIGjBN#O;C4!5xG3+AVi~
zs=xH2SW;aeYc51hl?oTr$L>&5zBeHATa@s_yF?1g0jzshDHXUR;>vD3vm8)Sc~;2n
z`nZL1e5MsrwapEgwN{BONhP066zF?zZ!YXs7-S@HW(_|OIS;`qD#UesI0*l@)F(Oq
zIzxe9@EW)DV1!+!MYvt&FnVCmUH*if$kdH=xR1Gh7kV-Ah$x!wr}Sr!3Q<*|l1q
zYEA9g3en(?Ow0=Ri#5kGTj&pi9Uw|wfabI{2$&B6{>C22Y47&@y4y1{+$9uz=Y`3E
zsGl$_>MLq^W%fgnkIl|AbAPydhXQVf$f6eY5cFFJ!tAdHeoNp$q5~xiNOKurRD->D
zRBkYM@D9>-Kf(0Vjms#`cUGJVpTbbxw-3dYb_1@o;RHo-mHux9Ll<;0}fT$FN|Ia?~}b5-)!5H02ZIV*nw>T*qH-rUZ{`5?N&TLDGY-L=p$F
z!VfT1|6A;nhTJ=9vfw{NDV>QS$8|74D9{Xd%d#mL^2}s)qynt@0+HL1l>G4pz|jKo
z=WmYMikA!iimJgX#rKy`*
z>SQQ&lFZ4FrebgfyR~SI|8_lX%~IJ}@L0S1K)GP7bA0S>oC**xuG;b^enERVF@*6L
z<{7Z)f&C(j_5Jum`xxU!^^ga1KIKz@I#^NtPx!QHz3l!cNgA@OQCFQ!%stv(i*E(a
z9f37hT4`GrA1Xijhlr5YhsINN>XKF(9SS#YJwbV*z8lvDk=zv!(yaOVnPmR{IGye3
zY^FVWhvfdDx6*9DSXBPx7-dBCK$Eum-#t$k`RsNW*P1=O5SRY_Y-ZQFe={{}W7NPF
zeGEqP{K@rsl_NM_NjQZx*O#?vQl^Hnq3~{nfB)7!%k9Q5t$niJz16jA-Px~$o@dv6
zI!Yoe*(XxifQCe;t{U*Xga~%+&zg3Lvayz}NtxrXN?0-hZjn`SiQ%2>q`#3R6VUKM
zJbwOIzWJw%32u$DY_$eq?V9HveIle#ygy%>?Jyp(aGXd^%B(+YJhE%PqFJqdX6cZ4
zf6OMx>ki!IL_a6b4g~}F4*CE%3z4I^s
zn4E6onO5z822w`X{Wan6-@4N0u9yix!fiYYje{E8&>YQ5msYpPmJN$SdULAImimXj
z&*=(Jo*44JIfYuYh`h(l%P(QmN?Df=Pw2TD)Z`Xy9MtOO27U^Tddsfos`O`DmaQ2U
z`7|24l^pqJZ
zX1ZwRQSRFr@a)|gG`I<9GW9Q~K`WLWCb`RmBr|BGB+H05tqs34f)Wvt0w
z?|k#4htnfSvcbA_=YS>{5%t}2etce^y1gv0wyu5IMi9FdM_sC)W_W<8K{878IqK1m
z#jh-4K58wB2rIYP^xCj65;ER7sO88SD#BzlY57x4V0aje$(tR?^(d77IKb|YtBrUJ
zA8uQoUZxpAp7p9)$!KWimp3HOV~c?GbEf|DWl$VN!8f!jHwaG)$E!!+dG|2@T6z@(
z7W@x*A(QK3aF(LD$q=1H;bVWPPhlA~t?NiCeUT@9}50aCKBN*juRd!*nt~cNSy3?hd}=
zf#|c~dd4NHSLf3dAC>eXdzuyr%1vy$SX^+5pXw+rc=sTc0`vXi)BUK>qa7$G%2I
z4B9qZl~#Jov2^MYK8-f~|t%?bZe5fa7hQ&uHk@%w^`sfZr}VU;32&I41k=!1J63-@|G^
zC2sORuf&EL9~R29f(;q-AH_1KS@DVHb_Nkou0-CCjd*J4`KQ*_PQ~^aaMpmtAzb
z;xR{U^u}6UDf&h)j=lNsCENi~_WY;FiT47F$TxW5CoZuM5(}SDu!)1zhLwL_atnX*
zD!9I#=$D^1St9QhKnq>YQ|}8|eU$TVYs&W?^Alc4Ot%G$csm0s{VrwKK|`M91cvD%
z18QXyWmmI4^*Dg8RaTsLC(s!RWW_KTs;>iVG^Go(=igQwA
z!Ia<~LR5tZzl32piK@0QYv?@X`Z+F<^#=cLDI?xPpSO#Lq|Oy-zmz$li=4K6>3-`p
zXa0TrCr85}rg_Y+klBn#ZMsx4SgxLo$7ED7X`c-c=s#DJm@-It_^K9O2-xnoSF!AX
zEO&zhF5CI9{E=V*Bny5e39#fw`2>1`W}`Cx!hA-2yFO2)3R!L-(ynH*ww$E^5i#xp
zcdYJj+gWK39^Y)Ox0`a<2Q^$NbdiFlKWkqCWup+y+SsFV{Cj0Aec#|M=lMiKTa@ke
zmn+70LZ|25wKMoUK}mbwM=_Bq3B#x5Z{~
z>+`>r6X@b!>_AKlgfllk-+utymW48_CkA(ERBU!GX}KM+@bWI%#;h%VeE8M>C`Dy9
zv3R>Q^YMRGiZAcSkm@!qkB3OY^|{i#kg2Z?glmQlfO(YQtye)_x*m+>v$?nT*=HI9
zwTo$SRxhdkGQ??Zwy=W?z*j(>*q*TqR~lmSir*~p0uqaW{P$W#JaYo$lsv}D0zwgl
z!syxo(Fo060U--$LJ74;)Cq4VW}YZh%p|VVmWuY)QV#d!rsbWG?r6VP$wHAXG{gxS
z^u>r-_a7p+bscO&x^t#7>{fk*B)6LpuXrK$OU0M#<75dKt6L46t@P<8XI?AfmrVoW
z%>D%sCO^J+*B({g0J*u`CrN%>;D1eBI7CMMa&b
z!>ga697`h$Reni6!
z@{VS%K^N%Dz$aBByxlLz{j%3qyLS^f3!99nEqQ@AAeUVByazu9G^5N~_ez<33h@8eXU$vgZZ8$>IczOwKKZD>G*SCQyln-i%|NrB
zGoCZGatYVQ6NBzi?ts?hdmMHTOSmdY=V3FbcZ>BBeCQ%W_$>;=u9iA#yxuJ!FkFP?=Of6UUo5h4oUCI2hi>bwItey^|Wk~m_$2r`6@t%
z;q+BPPdyYVHTF
zk|0;%DnvdV&xPUiWdCxwaxR(?m1jQ4E;SP?qeNeh#wvNv>4b2)D>NGq*y%(?lKcep
zcQHbhloh+TMqoE*$DYzeW1X)6`z~(Pi~AIo2lUoYVO9usffhz+P6@7<&Aa%_I$CGQ
zLV5n^HoDz!o;(LP1I5}U?{JdV;U920s;oNXZC0VqaWWH*L4BS3B$3_G*Dib541JAW`+Wnp4ElMd348Ym@g7c
zAK>fd*yZ8YU7V9?IJd)8$occhry#8D+Aq-GVZ}dmi7cwa$cl}Fg@D@!`c6zGX#l<-
z$HWPrypiK1mKz8cf2U=$kBy3xow;^u!dQ!P^0Hmo=F{hptW0nke#RgKU8YzkQed@)
z6Y^^0d*wJU893qlyl}cro->=HCd`F{A3>?kMczfu37uNBBlSGHDcAPM*^n|EVP5|n
zL+4fU2zZe;oH5}115}yt0FET|%flt`1)vC}+alG*ilo6&oNye>bCo^vFHJ+T$df+q
zmdPHAJrz81$_#>(ZJGsIN`~-3m9AM(BqRThqK7;MVN;z2MmIjul~mlfdIQfE(D?=
zvH!VJ^IX6dR}hqAs*42KUuAZyi6aeWeT;(PpG7W9Oz21-yeP8~C(
zPJV$bxhhRsCcHm+ee#`br@iHZ@9U0s`V!SHX?SVUv?sr`hmCb)UoBE#szLKUZh)IBe+&A$2$Ikj*(`I71$~IvwMAmWYno?A;
z>dAIbX>^YvOxmIh!%SKD@N@_T6lErs5-GoBkrTbs7d&)Op4d5(T?Y$f4khZNZ+{q@
z{0LY;XizG)>Q7iZYX8fw`x9?vrkmUPz=rV0$lf>E0esWTC~qI{s0w_^Tt^+J?B@5k
zfl8oG-|)^?4IYA4hc-@DFi_l<>&(q!aoPN$zr~p_wb97
z2#9gu4Is+Ctb4}*nFfOJ8>vZG74H3N21sE7mU(}AZ{@N{K}j$F0H(~NG?bmvkw&Q?
zB@xTp@fzzKyn0_3qFijSzsbR^;OlS2(KB}WLczER#i)#5SXFF!dDBMs0TH9s}nbbP2tmv@4xdJ_W@^bu}`?3}BMP&!d75>kTqpSq&jQ9YQ
z3<)NboIH#YvwsatpSlJJy69nqqa{Fh4@`YtfaCpBjiNYnE{L*SE$!T!AtD+F$JiB&
zZXK}J1^NJ({@|yh+;ul3K1_|!B|9i0Jqf^QKj@$h4EE%d7s!AfK#g5^4FcB%cLLbM
zY`W>eJ=*QYNiGW#D8039_&GO3(LnQsAIVr1#ERofw}Y%9h2S32iF+8FH9S$29S$g}
zwmx-P5ag&aH)6H7$O3SIlW8KqsgJ@tW=8Pq`^ldncz7YNFWhlk%C>2cCndy(m7Z<>
zRNzc2m%`$|jH`@u_c&BtxcCE@W`+cBbjv`vqgfH{Gyt-8N8Y+)$LIiTt*+?p3A~zB
zhrBmLhnydGRCUA!<~{_RfRha8VYZA
z%D4`)rgp8kcWIqYiR+Z!;mJA^JpYRc;CRQx9{+C#cy^u!2KG-+{cn7~M_X}XzCE0(
zF?;NQxv&6s_1>zw9NLQ>$RR5NWwmPDE5=qIWj3HfVGT+QRuyPx8au&1?0BTUD~k``jS(ba4B89
zTdX;5#djKZe&5esms*lbXxbFl?)YTU8V9@Bw<)*3*o9sC^5$i)r10p!bDj=dITF--
z^($^C@ej;Qx=KRvxk{*J8X
z7}TeQOFvul*Ik?abm;!M%&tEP83)|qD{$!T8UV%nxDCgkF}v`su+@CjRfW5NC%UWN
zfco$u+LX&%2|g}RWMS9!tB{Ui!ioGRHP8)Bf_e{VV8TR*Js&k86Q_u>Engx9a(=97
z#T2w5dQuMVK^xYL!aiV?7&4|2^r`gMSbpf}LubtiCpNin0`H#gBxQ@Swt6#ew%c&*
zo_%d8irVD)8v`*>v~2#FdUUjNOt3-EF_HF$nUPUSVTaKttw(lBgE_r@&i_!vDfPw6v|9yr%4CEfYbTo;#
zx)H?{bEpJi^haqt&aKvvnD0gy0|NaEw*1FA
zMWPFUik5ua<-dyeD7XA)<*b6(=xlNsSxusGL004r#O?p?@h*Rtnbxv;c(aqaEN%Dq
zH2QoZdrL;4zc-lwzfW!VjBE)DRh=pL(R!+=$XGekB~bKYhC#oAG6sF77uD2`6gYP)
zh_ZY62{3)=r?GAP2LB=yJ
z6{n(hzc~!C)%3g{{6%uCE%_9-f=Js$ABEfZ
ztO|EE(&8hk<}PMy5p*Z|vZDUrx-9*J
zA)fXS(Vb|)$!X~dN3X5J-d0NKJr#0C8MOEg{P1a!HgMy=Y-HTr5y1LVA#EfWo&d1R
zL{%~^85}0^Sp|XgB4a+WbH~0trk%bC$xk4CX+jXzb^?3%$M@`|8-cxxh6Fz!3s?!D
zE#Dx5EF!W$rN0vi3@XMG-pg0dUpe+NfWf5(fQ*t}2eORgl3aGs8Dgs*#K74ZTOj|p3AfwOnN2!{p4H(l(45?ykgqMY_3p2uk
zRl%jGbOPb63;o)(#(*k*iOll1I=2>JPOKAIG@~XIebyHqe4ph^o7au^oRs8pz`17@
zdIUlN(rt=t7h5{MMBPoC@9(_>N1bt%^5PG5iTB@94KTe=ev5Wi?nkh!Fa9u;B*u};
z?9_fMwy%!WG4wP~$xDLsO?O$Zha)ugnsJQ|zul6-yd?EtLid<1^ouKpG+}(F6@S$g
za_SgNS+z)Z#m~TEN3~+|_GfX-f3nzC0d)M^xfd2^k*6@@TxMt8Lj86iLRaa%RWMVJ-D0`MNL>
zR3rE-${wwjD%#wzrPWWZQC)&oE2#Rj)+eAON}`3kSCUJEl))^k1}KAQ(TP7!W=xAU
zK&hA$=|396aFBLGaxW-;xz|a_wXc!ii_#x5li!_rVy*0)BcHItA9b4HibP~na;hZDyg8qE!z|szi!2Y
z^Yh;sN^UiUw|$+3irrt@bR?)$ld!~Dq%gq~JNpY8MzPq4%CAJoD@6(hL98Wn7^;>#
zIc
z!!Mp5eV^y|7vHeq#KLfDZ6d)oTYzo092G?u2eV#;VQfLu8~RlBO2kr46up(n+Vv0R
zx@9c=-awJF=?(MOD0QQ&T`y+bk+CKaxDCJdux!I)@X}cYROzb{Fx43gdy4;;2_n6S
zTUr76rKr8D8xYmk5Lj)DL|NCdPJ6|}13Iz0!C4JsQ39qhtc%ga!{;0AL@lyAtiLa({pHG3K?b8@5IiT`GqG$zWSMv4&Nz)>?BqDHr8h#3265&>%)g`G2-ohjs^=Z
z3rGW_C<
zKDG1|`N-Sn$ooy;0PlUz+6WS9d_?Wg<1;m_2Y{$^9^LMXvnZ39NkmAJY-q-|JLreh
zF)mi&$e8xlkgCvtgx?b>R3sJqpvk4sJzEzJEV;N1jvB{XM*{J&^i*!mlolnnjs~4=
z7l=`)L;ixpi%gcZ;3KDHy@C766f@K(7jf71he1E&(I0x!^;7vv4G3xzaFS`yT&6M-
zW*mm&2Q7vUDO6R5Wca&A4fxO`Y@cx&K*5%;mIKks2``(K@QIgD-vu
z%{~eSbEZBUmcQOZ-%Y_5IdM-A|I&h=!GZ(ku{!BG%vRZIu4!LB+uwnazyyeL#Y=(y
zEgSxpeDe0&aZJlhSdBsy00xbz^CS(wX!5S*b~EUo7hk0uR;WeRP7@cS*ca&0tj-Tw#|NODVBOO4*DO_z%YxnsykrpU36=a&K6Wf`1_#e
zBjAj1i%5Ia8&wfA{CyaZk42@us1VD5edxn(dwC?`KDs98PcMX@X2DPQS!pnmz2_B*}Z_|KNFUJ?S(i@ID$HL15?4<}n
z2K?G^izvILOYp5Vs_k$s+f*yqa
zp^1R>je#$tL2zaWf5?aNI8;M;{h3vHH2lWmth@m@LwtWUuAQj&Yj>dgk-7NYU6j1=
z|IBlSV#B`m0-}V|O%NqyAFOT!)HkQ*UdtS><1?ROXVzP#QV}$bep5Ao^^XbTXR(Vj
z{mIX*we@*iV$D6!j%pzw)V!Iy(7h>HkG*Y#ZKuXdS;sIvH?O3|yn|XEy)c+HbdPeq
z3I&&0T0-=Qo%HAo#Rkqg#Ntd_|u+d>RT>VFo|P`Q}-}s76hDb}&>c>D7rm1R
zNi)O^r8^_nDB!9@UI?UWceEw#y$Hr58_jzJp(1#Q+pOP1R-81t+?
zzuP?n80V7(SVRU{Dpf0v+VPTbq6M7P15X?5xXi=@#`Md!qqpspE|~1Z19q1k+8e3$
zmpuhXC8Ul4-SO||@H&cTbJw2j!QTpHe2z$@>~(}#NP3oZ$Ll?2>ceDS)_-1A@K;dW
zv&Iq(#fnj8sQwecGQR-?2zVs`?e}wMQ911MvaAhd93cHt0eXNFqQK-`H3%7KEl&OT
zm&2lu)wQTr#T*MjSt`PSIftH@_(gl81Y(M)?Ez=kd+RAoh@#e~z>}HiiKOEdv?)yB
zTmwDjCy#8!xB#^p?P6p}75EF@s9`(<>hS${d%x?pOep%Eo){6N!9W_3rOpC)0S&i_rtKB<9{-hR#QXkH
zK;93w^@k6mO=lld(=c)93C>IVfMMFmmU6$%&)uNACE;|rT<3&vNDp5`gKaEP)8D*M
zj-%xaFG>n2_gf6_KY}y^6##rGGGiPF)brimsUPGyw}6NM>A^W9&xdlH)y}0ST1HXA
z1$w6He>02H*X|UogONmgp`N*hPfN0;>QPfxZY
zpd3Fvxs4I}9iLch!wZ%=JhI@OJU{+cG7^fzR?@OM)oEjbRkC(!&MT7qRd7POJq`_`
zrQrqX;{IXm-7A+MS47SPk{Z0v>RL)T{p@wAcSp5b-Xw2xcz*CV?~%P<;hk9FW6b98
zjw)snJ$%0^ZTUL6f{l?D1tyUeoOxNWkPrsO$C3FVar9hZg?|SqvNVdW-T?sdq@Cq{
zQ&Ym{7%617KDoRJTdv^6>a-7V(-fu=to2WiB0$F0<8B$CB3zfd*^aD@$s9^Y%=E0@KrC6Hz&mE2(H5ybT(Pd
zEp`iX&0IjBlY^W#B`84*r5qD^pI>i+qsCSvDdyw4cJmPUQ?U7|+5+^T&$P`zVIUE}VO3yzKoe4j_Yv}ZjvzkE
z7kw3$b+N0?QIpW}$&#Mw10Yz>=ZK=Sd|3e}!K2_32_5+Io1h1ibe!7QRx{{Dy4k=x
z_&?)=>3z7_>^mzS|MiI0N%WTEz#I8U!EqXdw7CL5+oJH~iUKT{jFmGK<{!sNnjMg?
z7C!k;CZyF`1p1>k{9CMCKhTA;j`+=d=Aws_%m;@h>*w!1qPfq+m_hab7sLVtC-)#t
zc23~_X!xPW4LxtxyRuhzfjbZYTyA^GE-C`u(SO;^McIUJuij+)@rw${+gBTQ1wmTW
zXD+;>iq=8Wh=(NG6lNE?IFW~q%f}-eWc1*LC7J}IjcNH9K?+ti99&@h{<8WF=Sokk
z9kxdqV;C$Z{rTAy@8y7tj8Yk+N53CEQ}Uog_pI9l$(^?Ylep7p$BtT_P3KmIzn!?)
zbg^|&ea^nbIe+hZ7GSwz$I->=IhEhbvicG!UE5=hwp4!b)u+5Ro&@gKyu`L+g%QMl
zrx2o>-)eA;YCLv%7n@}%J@`l6Mf{|z7r(&7ZVgMtMY&sHc7I=BWPIV%(5K7N4bJ3z
zFXP*+*45|Po>0M8wu>yxKS1A~FJ)K1v_LxWR?8WHd}#xXt2=|TkrCIHzF`)8%saU9
z>E1-vk-D|UYcH;8$M2aDqdl2v=4DFYbc;mr4uYg>aAacrgIjAJ#cPvR$5zHZ)ODf2
z5e3v&1y8FcgPWXEi+G5V=&QR1M-KHDwG$aS%W7A!Cv7=7L*%;EIZ1n_3ThW6>n#sO
z=oHNbq5V$?Jl~YvcJ+1yb8*7HFSW$tkp(GvT@PP1A#yjjLNB0Um)6^qUWY(dSo#fR
z@^buF8?+`jK2p344~w5Y62zLQ&JY)Z^|DE#f3@lA8*K5frixrUukIj>WOBsO+A!S8
z$vo>v?%fLTYA*HCXy}HzT$0Od7KM9VBAwESt6WZr45PfM)o?zM&w*p#5l{9}Srqz8GVv*k+S6aSo^nA~c9cYn8DU!BB%i>=7*>fId#
zTa`JwDr}8ebjS0ml7BWLwkmNjG17NJ@oA@fet$a@`kJxafKAWQ1B!O_zZUC9}=fW=w*A=Modo5ti71g
zIe*XAXzk_e)M14#EE2?zTkzW>$#AW;*BmPNu2%yR;pRIxb?X9spb6Iv$G_4v+95EQ%
z?swe@6&l|DWy^G5co%OcFl+?+unY;-9eGhASB2mU+P$j+SV=5Vc3t++w6EWt`X=mAbT|oX$566n8-fug((gy>xNzav
z_nId(yf(a}68IC>7bnSnBGkydtPp0mtKF#je2|Vs%L?{vZMrzR8$WLr&_z>n36Vz<
zwY^M9*q+o?G32o}GQ`R7;V8q}TFQ64mP=Uxz+>AVg1MQ@e$R^VHKamr8jnWgmLo`DP8CfT|h;8H=Z
zM^#oBNKA%%mDc0{mS&G^erGBMvLF;%D8<3MsD|3D^I<&
z(8+H#8pmZcAg(qd1*nPUsmL!#0o6c!C)iXoe`q>y+D=z$Uuw_XlC6aBdpFXZ8pJfW
z#JNq&L2c7@{`RvfdAGJ`E=G?fy&lr8Hi_JKW#JyvV!yxqjbTw{kaCiP>H;4rfOc2C
zK-mZ#LVb+cW;==l`6j`Pc(MU6_SR6N_l0zq89R$#CG*c-I579iwuZ9SfDs!M`3iC;pNNE+p*QQWb6Oe*Oh=JnMG~dW@4mGi=eE=8J8A!%G}Z>n##fy>T#8I6tOW3#_c9A67&9_m6>H
zN_XiKBF`Sg$G
z8QNe@z%GtFxkdzG9eG!j2{HJ=U>#V*9Z@FjJBxkA&XG+oYm0@RG21u4zrn~O`sS&P
zJ)5?N#nHFl{B-HZK{RxL&4I!MxS2C+Atd&MK&=Vn0_ttQ-}55F}jSE)U+~RWCOHjX95XpP@R$#l~+mB
zR%^{YVpfgEy4%_-L^b4vIh_^jj1E={}UQr>`UwG1^G)5lSYt2}KvckSXi5ba(VN5i1ajZDG%B{ECur4L3c`v#Fs
zTqh>pLT>EJeoNj;x~zAwSp)OSZrE|WjyzV2f0ZJN2F}mdCkaQM%9dKkHbKmtAM3|d
z$WgLp%3q#JEOHW;WXmPQuCvNRM%tKJmS_3eWZyJvTuuda3yk9%Md+6~70@p=Fl95S
zs|VISY`#Na3Rr~m8#(bk?$*Kajf?%VlH*r?DwOqnO*Mz22**U(q)atpU_C`-Kz3~>oh&O?;03bhuWtm$d`a5wIvvM73p&bpmQI5Npq>FUHd0xaem
zyR1ta3`a+D#tQk!vOG<6g!oiH<(FWp-n^V!h%ujUOng*{#BVO_qqA1Pj_b^SF;CVQ
z6FI=Abtc)MX`KnX?qWkYX~r9j0F_G4Ymu^y88k$BOySxaonUB~`0EaSK5
zSEeRdw6#S9&7l4bn|*`D_*byB+6*hKu+X|OL1j1W^j$`o$fS{0%-5+`vnO(e`1dm*
zde)ou;3VPo9Ij7b!ZGo4uli20NY%#&J1{dNJn3B(K1_mR2mN*r-Cn*EZ@<61AZM?T
z!_ikhdB7F!Eli0Z#^o?*Zix}bF=z`*OB@^zC#9Yv%&xYy4o-V`uU$Tw#`~%7k>=v`AMV7>pP%U3>eJA<
zP^6l20!vj2<>TD!So6LU$@iQ`EXQz@uGQn``}zr!6ZKy=bA5oGX6U8J8>+VBK%2n
zuNVq6L*(PZ1;fkFdyPs6Lj24!TTN5X@y+k=hche5Ki&_(&MM0YCoSp0*y5-Tr%Z@<
zQVHC?e=zaiaY-p%7=kl3PO_HmQ%~gQB>d3{2BuSSkxLKOt9lnPaII4@hr!on#QAUE}_OiFMDEg&p+iUlt#EF)>Moce-
zkDvwpUTXOU1|?_ch3+K>FptioiMQ(Td!LD--TYa0xrE0O(6C+%sjRB{e2mp9ra{B$
zfl4*cV9_iFx1TD;mG}ajt5|M$!A5vCTt9puL516kX$_&y?KB}8w&3}r<*xtuChAUq
zvAxxp7;-{ZDXT>0mr)Z&md6uRin}rW9@H~lCd80~juE$ut#EA(BEzyhtPlBw$M-&d
z&mCIVe%l34ofNt{^+oGGz9%j&89cXGY|qFI;8eDB-QxkgMYyXFQk2C2y$VZDa^Cmr6)Iyatf5ZdIWUEg_1
zTNM?ysQqpzUsp>j`!9S{tv5@nQpD@1!{?it5OEdAMR18qt$qIvbMRV1QrjU%%(iBA
z%#x90jZLcd@lTipxh^Rq+~=8n#!rmm4u59;@@uJlvdPhlhPFYScNv!
z>C413LVxq2fHO~re|v4ou1#*)A694P8gN`MKaSy~hKYgLoB4aOG3V{kg3D&c#J4s0
zy(OaPF+UdfJb|{&oAnGL9bij`&aZm@qw4Wb4&^V%Ite(AT+;8*aNz{nDsNWz
zERpU2gXQJw%~}#q2vPb&|C&(`#?M$}lL}OX#-g12ZItnaZ<1%P&>^R|b98BFI!lWr
z;;Df;g|A^>$zZN8
za&~%RM!3%|d!e2){t1JkEarUmVdEv{(0j^wTu<`LQzd5w?q2T(m6d@h;$M4Vk_-Mc
zlL8+|o!1HPlo_lge-dslSq2iRE;LV5ho1ghe?`@k^?+>oTc@x
z0@|wE4`UB^QyR^l|2I#Ke0V3jYuR#lnk~6uSwnTLRb;pOyL@4f+dKILU%4Rm^&?8-
zYOw}p{7{0*a1U}JwPX<9LEI+oey%0>8hQtcEpV{?In#@i^a_~3AO=t2PXg^1@N3S9
zqO}rbLk6&+T_MJmuHNDGcJS`%A#ETWz?{rd68db#Yk*Uky_dBYm=!LW5Yq!B;PGUr
zIQC%5eBq9Q$qA*Ah(@)D_A~mi!Pa?_4A0yUv1#s*ucg^`&DT2|6#sTE9#tOpnEX}j
z(m3+e8qbWAa(R7%Mh#72iyLi+gXRu`y36_mCoLf>p|tUlAHR)DAoUmU!b{uvir6Rx
zO9xYJO_JW#%e;cQmJUwgYMR=7E@gYFP79+u46VDEId;-#(IuY4ad_2n<*(igJcr&r
zC#s{TYSurcIk<5$8FY67;sCUldyub4_9%g3ik$i$Uyn+Wi9U3j-c@Ba^UO~(&$BLDW>Y6MG%nHG~?_Ri6YTXst
z(lg$XiZKrg+wHLHlDg)rKVQ~H^9oAVlYGLC^V_DX!b>NUMXpU_4kyB`eDiQsmJS$+
znkH*d5lak4vsE63mqs7ryYA2-Sr~P{w;FJ0daCuJh`i%o0G3#ov23{_HpUFi-u_fo
zbLG-Yyg(<1ZUjg%yt=ym*q;$M&g!36FE_l3W2=Z8!hP27!Iq$3)Vi_~Vq-kU3R^1b
zzwbJVJ;nYj$&BRVvMo}MVE7dhZi^F2L-Bc)avO+do2*g7M%CFGut}0ozH83QT377p
zfuj1Dl^5fMKaR+zKp_E-pK6UN1La+<7r;ag73u1lZ|sz?^jm1QQFXlnUI`n}W5V9^
z=1z90=5AmAvTD~0zwll24t({hmrLsuBGR%CXpxYYUiw?jqrNq0wncS#=}kAj>)s7;
z@2YNvm`eh7xPkT5I3U_KwQ`KmZ&9EA$nM-Ao4GoVGk@;ycEtPvvw`1R92-L}@(M;(
zNj?J>{OH4W*gsy>&Jw=_=hrdxFT1fH71}ssH=vq$kvHykNmic?QO7~uwb#SA_e_Em>N9=jCH@;a7
ze!>*pK6dm^nG>CG#o}%Vw*7f1zo2*z3zbWl9haa2Q?4=`r$>2^jQ
z5DrB`pI_VC4Fmf75mB3kp3I>iXfo8f_nQ9WvtG>cDaGZAtS!3K{v-NSL#8va$(ir6Is=aHbbv@?}J~O#)IJ$O;$dDZ%3%I
z_X`af9gBqFdh?xwsyw<=OsIOdLSuAaI$kns@R)X&IV1jD8ZzOnQK(SWGl_aJYDS7k
z-3MrHCQ!GB-au;Esl^T|2Kq^)M?&eN32l-SkiU!H6vo{
z6<+C2!ZA5yG9LWfebu9#dfTfsG(FYtfGORTd|L@9t_iT^UZvL&f$j6=j792$Dk6K^
z8+Tph6qNkf55U3&1LwzwA1AnI9sOfIn%$yFKwM}>_qY|c@-OBM&1<6T;BB8n&B`bBgu3mI8_NWR7^&(m03-6ot+%FNnI1z^JQ!BfJA{>(JREUetA%Y`=Cx
zHsV9{#%riNIg^SHJo6GrPeKn!9!|TMLo?|X6{7o5!2AAOhOK30g9|1Cl3m(PK!cFs
zP7ac!n*t!fP2HF2pL&~Zaf8@>Y8w8ON$}Sjt=7PF9*Kn}2L}UwS;lLd0!VduD;GmG
z)DYlBWi~1yaT`S5m%5EC!b9k0mV_Ulh4T_i0kB9$eFffqBwbM1X3b1tJ*eA
z;aHENFz`UfmwCAen`S1gPJs?jZ9~E~Wrz--?#3|_kwEAE6;C}pAiJmg0`aS+^MSwI
z>{wB7+f6osbBY{YPN{$fodRu@xAG_rVRdhh=Yn!$o@_)P{KrJndod#^Gk0(c=pLA7
zMt9d7V~Yx;Voca&SuowNks$GzXMoCl3cTQr$ohyqF(DE;UqF6b!diBEY^dzHNvMCI
zAn!Gj&Cj!z{;R6jP-?d`QTH~)oqlTQa@VyT9{i83{r3rA)VVX*48|F`g@DX)sT{Kg
zXWu-+VEhl$NWgTaoRQX2Kw7Ip99v3zzK`!+k&ZBbf-owd0_XK^ObFTB1C25X3`L)4{N>#KfLJSiBK!BCEuyP#L?a=x5S;q)
zQE`e4_#T>>pn7zv=?9nEHaM;Ymc?Wc`bWC@0}?sFdnY$8u1*c(a|{AxO^C{cFH-_U
zZTt?K6Do5175Z?q#@V_#BVZ@sS0K{H`&uM{3jr*hb*tG%7rS5-i^*s;1wQ^R25W$Z
zv>alVYQx%*i;Q#0L+1Ut=sdS@^D24ZdKPNIp!j(X>Hg2;AOlO@1&(=`|-U+cL-d17pAijY-b$r
zLw6OLIF-T>C*;P>E3m+Uyn^ENiSR}>d%4~#As!SMvE7Qf?6~TGmE`tYermxnvYvCl
z0V#rLf9UTxr%2h+1?!OQVu4rng+RRx?6$zW8j*@;XC%v8tN76Mn8)vCx@>VKBi4)N
zOa@fqA@Hax#CjV4=PyC;PhsRWEGx>$CPsplfCV5&=oSt;piW|3z}aj@)aFy<6h&`2
z!}&zG4EgP9Qcs*WDXcRcj<2#B0X?!_=Zp+`ZO1<|qnfIEMQ76Qnt`E2R>mfwPXU4~
zI~4-(=Y-Prm5A^4+D$$g+1R9EkN97`Kd){Yv>|_N>PK6UDp~&yL2Y534t9CU&VSfe
z3wyk>lvqJm2}n-~1!e_^8i2z=fZG2RoUTy31{QzZtioi2RpIbtO76Yi3wKlW^zI?x
z8a())f+AauWFgqAKX%S5w*b#8_JP=@ss&X^ucn7h7^@&JPvd;LK-(Dzf?K=+*1y2q
zb$GMrMmv#3w&6vq*^+Y}piYP-&zV2Dt!^f8ek1x*;~4Dqd7%(Nky?A!=HHU963CP2
zF{M`1-N560os6ywhL=4;CfTE$E(5csozmh%hgHtjQ7W`x1#>Kg{}G;+1;*G-w=xCpATn;&8NSGCTOD`3_v2Z-gGf{*DAMHU!#;66V{BcgP86)Z4Vz`+IOC%}n{%uarp
z%E|PP*X8Qiz)Ht(^QUq9lqShS^uOifTGV_0^u-
z(gT-5yWQ?>@V6&_A6Dhi=mTwAGkLTQY66a%QR|`s40Tr#8LW}j2HD9(wuKUypoJq;
z1OrJ4NiDJ07>PV~trx6>{5RxRPQF=CGQkLwJ_sI}=xOkpVoC?wL&Ky4l_gweeb{7Q
zx*%k-w1hdtkO~rur#y$B^cH4Gy~oVMI$M|n{gPI}z*PHyp#8K<@t9<)Y5ep8^Qrd-
zsblcX=U6Je61yI(B`;I&mtW
z`%L?2?#6*0aX?AP)z+!L4SAiWAoX;}
zh{S42(iIhDU1{CB@|yLkiOzr)X?M|rUg?-vFfi%W+c7CFTE&zm3{9qMjXf!mP7X>F
zn+H!n0d(T++jfB@wN<=*ys}|p(xo_T;zM7$-_gWtJp%_FOCFreYBrzf6G$S%0uPy+
zPQD+%!yNCN93K&wGo_TV?u}8sEhVA%A4xP`v&|EyK8&7Al#bpr4-3lmDj6M}dII^V
zd@E@(w>CF-*)-YT`m)Nprngha+)Ek~GS=|ay6c`)!N&6Yxi0FVO~hyvD&Oool#S(#
znTt`2QFG_cMeVzL{9ul#gC;?tPzJM5sAX{d@-L94KnBH=
ze%#BGuEQYF-MvYk6grZduVqc8ZCjlQawL$Mg=I7acH$p3${NL;D1cmxE5FGZms#b!47*=ow$>%+ViZQa$<<$`qS+<2l_ISX(iMwD7`BaN`d+x(%n6A

literal 140431
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%0ATlVqGv=i6d1SrYl&SK}0UUDjojMhE@L;fcf&
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 diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/nl/functions b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/nl/functions index ce0b30c..7fd661b 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/nl/functions +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Calculation/locale/nl/functions @@ -38,6 +38,7 @@ DVARP = DBVARP ## Datum- en tijdfuncties (Date & Time Functions) ## DATE = DATUM +DATEDIF = DATUMVERSCHIL DATESTRING = DATUMNOTATIE DATEVALUE = DATUMWAARDE DAY = DAG diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressHelper.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressHelper.php index 9b48f59..15920f5 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressHelper.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressHelper.php @@ -58,7 +58,7 @@ class AddressHelper $columnReference = (string) $currentColumnNumber; } // Bracketed C references are relative to the current column - if (is_string($columnReference) && $columnReference[0] === '[') { // @phpstan-ignore-line + if ($columnReference[0] === '[') { $columnReference = $currentColumnNumber + (int) trim($columnReference, '[]'); } $columnReference = (int) $columnReference; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressRange.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressRange.php index 22e9d11..12977c1 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressRange.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AddressRange.php @@ -7,11 +7,14 @@ namespace PhpOffice\PhpSpreadsheet\Cell; */ interface AddressRange { - public const MAX_ROW = 1048576; - + public const MAX_ROW = 1_048_576; public const MAX_COLUMN = 'XFD'; + public const MAX_COLUMN_INT = 16_384; - public const MAX_COLUMN_INT = 16384; + public const MAX_ROW_XLS_OLD = 16_384; + public const MAX_ROW_XLS = 65_536; + public const MAX_COLUMN_XLS = 'IV'; + public const MAX_COLUMN_INT_XLS = 256; /** * @return T diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AdvancedValueBinder.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AdvancedValueBinder.php index 1db0c0f..a0edb8a 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AdvancedValueBinder.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/AdvancedValueBinder.php @@ -60,7 +60,7 @@ class AdvancedValueBinder extends DefaultValueBinder implements IValueBinder // Check for currency if (preg_match(FormattedNumber::currencyMatcherRegexp(), (string) preg_replace('/(\d)' . $thousandsSeparator . '(\d)/u', '$1$2', $value), $matches, PREG_UNMATCHED_AS_NULL)) { // Convert value to number - $sign = ($matches['PrefixedSign'] ?? $matches['PrefixedSign2'] ?? $matches['PostfixedSign']) ?? null; + $sign = ($matches['PrefixedSign'] ?? $matches['PrefixedSign2'] ?? $matches['PostfixedSign']) ?? null; //* @phpstan-ignore nullCoalesce.unnecessary (Not sure whether Phpstan is correct) $currencyCode = $matches['PrefixedCurrency'] ?? $matches['PostfixedCurrency'] ?? ''; /** @var string */ $temp = str_replace([$decimalSeparatorNoPreg, $currencyCode, ' ', '-'], ['.', '', '', ''], (string) preg_replace('/(\d)' . $thousandsSeparator . '(\d)/u', '$1$2', $value)); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Cell.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Cell.php index e9fbabb..dd9b533 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Cell.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Cell.php @@ -15,6 +15,7 @@ use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\CellStyleAssessor; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Style\Protection; use PhpOffice\PhpSpreadsheet\Style\Style; +use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing; use PhpOffice\PhpSpreadsheet\Worksheet\Table; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Stringable; @@ -190,12 +191,22 @@ class Cell implements Stringable public function getFormattedValue(): string { $currentCalendar = SharedDate::getExcelCalendar(); - SharedDate::setExcelCalendar($this->getWorksheet()->getParent()?->getExcelCalendar()); - $formattedValue = (string) NumberFormat::toFormattedString( - $this->getCalculatedValueString(), - (string) $this->getStyle()->getNumberFormat()->getFormatCode(true) + SharedDate::setExcelCalendar( + $this->getWorksheet() + ->getParent() + ?->getExcelCalendar() ); - SharedDate::setExcelCalendar($currentCalendar); + + try { + $formattedValue = NumberFormat::toFormattedString( + $this->getCalculatedValueString(), + (string) $this->getStyle() + ->getNumberFormat() + ->getFormatCode(true) + ); + } finally { + SharedDate::setExcelCalendar($currentCalendar); + } return $formattedValue; } @@ -233,6 +244,9 @@ class Cell implements Stringable */ public function setValue(mixed $value, ?IValueBinder $binder = null): self { + if ($this->hadHyperlink) { + $this->clearHyperlink(); + } // Cells?->Worksheet?->Spreadsheet $binder ??= $this->parent?->getParent()?->getParent()?->getValueBinder() ?? self::getValueBinder(); if (!$binder->bindValue($this, $value)) { @@ -242,11 +256,31 @@ class Cell implements Stringable return $this; } + private bool $hadHyperlink = false; + + /** @internal */ + public function setHadHyperlink(bool $hadHyperlink): void + { + $this->hadHyperlink = $hadHyperlink; + } + + private function clearHyperlink(): void + { + $worksheet = $this->getWorksheetOrNull(); + if ($worksheet !== null) { + $coordinate = $this->getCoordinate(); + $worksheet->setHyperlink($coordinate, null); + } + $this->hadHyperlink = false; + } + /** * Set the value for a cell, with the explicit data type passed to the method (bypassing any use of the value binder). * * @param mixed $value Value * @param string $dataType Explicit data type, see DataType::TYPE_* + * This parameter is currently optional (default = string). + * Omitting it is ***DEPRECATED***, and the default will be removed in a future release. * Note that PhpSpreadsheet does not validate that the value and datatype are consistent, in using this * method, then it is your responsibility as an end-user developer to validate that the value and * the datatype match. @@ -257,6 +291,9 @@ class Cell implements Stringable */ public function setValueExplicit(mixed $value, string $dataType = DataType::TYPE_STRING): self { + if ($this->hadHyperlink) { + $this->clearHyperlink(); + } $oldValue = $this->value; $quotePrefix = false; @@ -307,6 +344,14 @@ class Cell implements Stringable $this->value = SharedDate::convertIsoDate($value); $dataType = DataType::TYPE_NUMERIC; + break; + case DataType::TYPE_DRAWING_IN_CELL: + if ($value instanceof BaseDrawing) { + $this->value = $value; + } else { + throw new SpreadsheetException('Item is not a drawing'); + } + break; case DataType::TYPE_ERROR: $this->value = DataType::checkErrorCode($value); @@ -325,16 +370,16 @@ class Cell implements Stringable $worksheet = $this->getWorksheet(); $spreadsheet = $worksheet->getParent(); if (isset($spreadsheet) && $spreadsheet->getIndex($worksheet, true) >= 0) { - $originalSelected = $worksheet->getSelectedCells(); - $activeSheetIndex = $spreadsheet->getActiveSheetIndex(); - $style = $this->getStyle(); - $oldQuotePrefix = $style->getQuotePrefix(); + // Avoid Worksheet::getStyle() (selection + validation) unless quotePrefix must change. + $oldQuotePrefix = $spreadsheet->getCellXfByIndex($this->getXfIndex())->getQuotePrefix(); if ($oldQuotePrefix !== $quotePrefix) { - $style->setQuotePrefix($quotePrefix); - } - $worksheet->setSelectedCells($originalSelected); - if ($activeSheetIndex >= 0) { - $spreadsheet->setActiveSheetIndex($activeSheetIndex); + $originalSelected = $worksheet->getSelectedCells(); + $activeSheetIndex = $spreadsheet->getActiveSheetIndex(); + $this->getStyle()->setQuotePrefix($quotePrefix); + $worksheet->setSelectedCells($originalSelected); + if ($activeSheetIndex >= 0) { + $spreadsheet->setActiveSheetIndex($activeSheetIndex); + } } } @@ -391,7 +436,7 @@ class Cell implements Stringable $value = array_shift($value); } - return StringHelper::convertToString($value, false); + return StringHelper::convertToString($value, false, convertBool: true); } /** @@ -503,7 +548,7 @@ class Cell implements Stringable $coordinate = $this->getCoordinate(); $ref = $oldAttributesRef; if (preg_match('/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/', $ref, $matches) === 1) { - if (isset($matches[3])) { + if (isset($matches[5])) { $minCol = $matches[1]; $minRow = (int) $matches[2]; $maxCol = $matches[4]; @@ -746,7 +791,8 @@ class Cell implements Stringable throw new SpreadsheetException('Cannot get hyperlink for cell that is not bound to a worksheet'); } - return $this->getWorksheet()->getHyperlink($this->getCoordinate()); + return $this->getWorksheet() + ->getHyperlink($this->getCoordinate()); } /** @@ -760,7 +806,8 @@ class Cell implements Stringable throw new SpreadsheetException('Cannot set hyperlink for cell that is not bound to a worksheet'); } - $this->getWorksheet()->setHyperlink($this->getCoordinate(), $hyperlink); + $this->getWorksheet() + ->setHyperlink($this->getCoordinate(), $hyperlink); return $this->updateInCollection(); } @@ -973,6 +1020,19 @@ class Cell implements Stringable return $this->updateInCollection(); } + /** + * Set the XF index without triggering updateInCollection(). + * + * This is intended for use by readers that will immediately follow with + * setValueExplicit(), avoiding a redundant cache write. + * + * @internal + */ + public function setXfIndexNoUpdate(int $indexValue): void + { + $this->xfIndex = $indexValue; + } + /** * Set the formula attributes. * @@ -1034,4 +1094,96 @@ class Cell implements Stringable return $hidden !== Protection::PROTECTION_UNPROTECTED; } + + /** + * Return cell $right positions to the right of this one. + */ + public function cursorRight(int $right = 1): self + { + $row = $this->getRow(); + $col = $this->getColumn(); + $colIndex = Coordinate::columnIndexFromString($col); + $newCol = max( + 1, + min($colIndex + $right, AddressRange::MAX_COLUMN_INT) + ); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } + + /** + * Return cell $left positions to the left of this one. + */ + public function cursorLeft(int $left = 1): self + { + return $this->cursorRight(-$left); + } + + /** + * Return cell $down positions below this one. + */ + public function cursorDown(int $down = 1): self + { + $row = $this->getRow(); + $col = $this->getColumn(); + $newRow = max( + 1, + min($row + $down, AddressRange::MAX_ROW) + ); + + return $this->getWorksheet()->getCell("$col$newRow"); + } + + /** + * Return cell $up positions above this one. + */ + public function cursorUp(int $up = 1): self + { + return $this->cursorDown(-$up); + } + + /** + * Return cell at row $row in current column. + */ + public function cursorRow(int $row = 1): self + { + $col = $this->getColumn(); + $newRow = max( + 1, + min($row, AddressRange::MAX_ROW) + ); + + return $this->getWorksheet()->getCell("$col$newRow"); + } + + /** + * Return cell at column $column in current row. + */ + public function cursorColumn(string $column = 'A'): self + { + $row = $this->getRow(); + $colIndex = Coordinate::columnIndexFromString($column); + $newCol = max( + 1, + min($colIndex, AddressRange::MAX_COLUMN_INT) + ); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } + + /** + * Return cell adjusted for Xls limits if applicable. + */ + public function cursorXlsLimits(): self + { + $row = min($this->getRow(), AddressRange::MAX_ROW_XLS); + $column = $this->getColumn(); + $colIndex = Coordinate::columnIndexFromString($column); + $newCol = min($colIndex, AddressRange::MAX_COLUMN_INT_XLS); + $newColStr = Coordinate::stringFromColumnIndex($newCol); + + return $this->getWorksheet()->getCell("$newColStr$row"); + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Coordinate.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Coordinate.php index 227f2c1..02e1d9e 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Coordinate.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Coordinate.php @@ -35,7 +35,11 @@ abstract class Coordinate public static function coordinateFromString(string $cellAddress): array { if (preg_match(self::A1_COORDINATE_REGEX, $cellAddress, $matches)) { - return [$matches['col'], $matches['row']]; + $row = (int) ltrim($matches['row'], '$'); + // reluctantly allow row 0 due to regression problems + if (/*$row > 0 &&*/ $row <= AddressRange::MAX_ROW) { + return [$matches['col'], $matches['row']]; + } } elseif (self::coordinateIsRange($cellAddress)) { throw new Exception('Cell coordinate string can not be a range of cells'); } elseif ($cellAddress == '') { @@ -186,7 +190,7 @@ abstract class Coordinate /** * Build range from coordinate strings. * - * @param mixed[] $range Array containing one or more arrays containing one or two coordinate strings + * @param array> $range Array containing one or more arrays containing one or two coordinate strings * * @return string String representation of $pRange */ @@ -206,6 +210,7 @@ abstract class Coordinate $range[$i] = implode(':', $range[$i]); } + /** @var array $range */ return implode(',', $range); } @@ -316,7 +321,7 @@ abstract class Coordinate $worksheet = $matches['worksheet']; if ($worksheet !== '') { - if (substr($worksheet, 0, 1) === "'" && substr($worksheet, -1, 1) === "'") { + if (str_starts_with($worksheet, "'") && str_ends_with($worksheet, "'")) { $worksheet = substr($worksheet, 1, -1); } $data['worksheet'] = strtolower($worksheet); @@ -401,7 +406,7 @@ abstract class Coordinate return $indexCache[$columnAddress]; } // It's surprising how costly the strtoupper() and ord() calls actually are, so we use a lookup array - // rather than use ord() and make it case insensitive to get rid of the strtoupper() as well. + // rather than use ord() and make it case-insensitive to get rid of the strtoupper() as well. // Because it's a static, there's no significant memory overhead either. /** @var array */ static $columnLookup = [ @@ -420,22 +425,28 @@ abstract class Coordinate $indexCache[$columnAddress] = $columnLookup[$columnAddress]; return $indexCache[$columnAddress]; - } elseif (!isset($columnAddress[2])) { + } + if (!isset($columnAddress[2])) { $indexCache[$columnAddress] = $columnLookup[$columnAddress[0]] * 26 + $columnLookup[$columnAddress[1]]; return $indexCache[$columnAddress]; - } elseif (!isset($columnAddress[3])) { - $indexCache[$columnAddress] = $columnLookup[$columnAddress[0]] * 676 + } + if (!isset($columnAddress[3])) { + $temp = $columnLookup[$columnAddress[0]] * 676 + $columnLookup[$columnAddress[1]] * 26 + $columnLookup[$columnAddress[2]]; - return $indexCache[$columnAddress]; + if ($temp <= AddressRange::MAX_COLUMN_INT) { + $indexCache[$columnAddress] = $temp; + + return $temp; + } } } throw new Exception( - 'Column string index can not be ' . ((isset($columnAddress[0])) ? 'longer than 3 characters' : 'empty') + 'Column string index can not be ' . ((isset($columnAddress[0])) ? ('beyond ' . AddressRange::MAX_COLUMN) : 'empty') ); } @@ -446,11 +457,19 @@ abstract class Coordinate * * @param int|numeric-string $columnIndex Column index (A = 1) */ - public static function stringFromColumnIndex(int|string $columnIndex): string + public static function stringFromColumnIndex(int|string $columnIndex, bool $tolerateZero = false): string { /** @var string[] */ static $indexCache = []; + $columnIndex2 = (int) $columnIndex; + if ($columnIndex2 === 0 && $tolerateZero) { + return ''; + } + if ($columnIndex2 < 1 || $columnIndex2 > AddressRange::MAX_COLUMN_INT) { + throw new Exception("Invalid column index $columnIndex"); + } + $columnIndex = $columnIndex2; if (!isset($indexCache[$columnIndex])) { $indexValue = $columnIndex; $base26 = ''; @@ -503,8 +522,7 @@ abstract class Coordinate } /** @var string[] */ - $cellList = array_merge(...$cells); //* @phpstan-ignore-line - // Unsure how to satisfy phpstan in line above + $cellList = array_merge(...$cells); //* @phpstan-ignore argument.type (Unsure how to satisfy phpstan) $retVal = array_map( fn (string $cellAddress) => ($worksheet !== '') ? "{$quoted}{$worksheet}{$quoted}!{$cellAddress}" : $cellAddress, @@ -516,7 +534,7 @@ abstract class Coordinate /** * @param mixed[] $operators - * @param mixed[][] $cells + * @param string[][] $cells * * @return mixed[] */ @@ -554,7 +572,7 @@ abstract class Coordinate $row = 0; sscanf($coordinate, '%[A-Z]%d', $column, $row); /** @var int $row */ - $key = (--$row * 16384) + self::columnIndexFromString((string) $column); + $key = (--$row * AddressRange::MAX_COLUMN_INT) + self::columnIndexFromString((string) $column); $sortKeys[$key] = $coordinate; } ksort($sortKeys); @@ -628,10 +646,8 @@ abstract class Coordinate // Range... [$rangeStart, $rangeEnd] = $range; - [$startColumn, $startRow] = self::coordinateFromString($rangeStart); - [$endColumn, $endRow] = self::coordinateFromString($rangeEnd); - $startColumnIndex = self::columnIndexFromString($startColumn); - $endColumnIndex = self::columnIndexFromString($endColumn); + [$startColumnIndex, $startRow, $startColumn] = self::indexesFromString($rangeStart); + [$endColumnIndex, $endRow, $endColumn] = self::indexesFromString($rangeEnd); ++$endColumnIndex; // Current data @@ -642,8 +658,6 @@ abstract class Coordinate // Loop cells while ($currentColumnIndex < $endColumnIndex) { - /** @var int $currentRow */ - /** @var int $endRow */ while ($currentRow <= $endRow) { $returnValue[] = self::stringFromColumnIndex($currentColumnIndex) . $currentRow; ++$currentRow; @@ -671,7 +685,7 @@ abstract class Coordinate * * @param array $coordinateCollection associative array mapping coordinates to values * - * @return array associative array mapping coordinate ranges to valuea + * @return array associative array mapping coordinate ranges to values */ public static function mergeRangesInCollection(array $coordinateCollection): array { @@ -726,7 +740,7 @@ abstract class Coordinate } } - if ($rowStart !== null) { // @phpstan-ignore-line + if ($rowStart !== null) { if ($rowStart == $rowEnd) { $ranges[] = $hashedValue->col . $rowStart; } else { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DataType.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DataType.php index 774a57b..71cde4a 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DataType.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DataType.php @@ -17,6 +17,7 @@ class DataType const TYPE_INLINE = 'inlineStr'; const TYPE_ERROR = 'e'; const TYPE_ISO_DATE = 'd'; + const TYPE_DRAWING_IN_CELL = 'drawingCell'; /** * List of error codes. diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DefaultValueBinder.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DefaultValueBinder.php index 42a45a9..49b2238 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DefaultValueBinder.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/DefaultValueBinder.php @@ -4,11 +4,12 @@ namespace PhpOffice\PhpSpreadsheet\Cell; use Composer\Pcre\Preg; use DateTimeInterface; -use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Calculation\CalculationParserOnly; use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use PhpOffice\PhpSpreadsheet\RichText\RichText; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; +use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing; use Stringable; class DefaultValueBinder implements IValueBinder @@ -33,6 +34,11 @@ class DefaultValueBinder implements IValueBinder $value = $value->format('Y-m-d H:i:s'); } elseif ($value instanceof Stringable) { $value = (string) $value; + } elseif ($value instanceof BaseDrawing) { + $value->setCoordinates($cell->getCoordinate()); + $value->setResizeProportional(false); + $value->setInCell(true); + $value->setWorksheet($cell->getWorksheet(), true); } else { throw new SpreadsheetException('Unable to bind unstringable ' . gettype($value)); } @@ -68,6 +74,9 @@ class DefaultValueBinder implements IValueBinder if ($value instanceof RichText) { return DataType::TYPE_INLINE; } + if ($value instanceof BaseDrawing) { + return DataType::TYPE_DRAWING_IN_CELL; + } if ($value instanceof Stringable) { $value = (string) $value; } @@ -77,8 +86,7 @@ class DefaultValueBinder implements IValueBinder throw new SpreadsheetException("unusable type $gettype"); } if (strlen($value) > 1 && $value[0] === '=') { - $calculation = new Calculation(); - $calculation->disableBranchPruning(); + $calculation = CalculationParserOnly::getParserInstance(); try { if (empty($calculation->parseFormula($value))) { @@ -107,7 +115,7 @@ class DefaultValueBinder implements IValueBinder return DataType::TYPE_STRING; } } - if (!is_numeric($value)) { + if (!is_numeric($value) || !is_finite((float) $value)) { return DataType::TYPE_STRING; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Hyperlink.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Hyperlink.php index 5f1c521..2acc743 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Hyperlink.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/Hyperlink.php @@ -14,6 +14,8 @@ class Hyperlink */ private string $tooltip; + private string $display = ''; + /** * Create a new Hyperlink. * @@ -80,6 +82,23 @@ class Hyperlink return $this->isInternal() ? '' : 'External'; } + public function getDisplay(): string + { + return $this->display; + } + + /** + * This can be displayed in cell rather than actual cell contents. + * It seems to be ignored by Excel. + * It may be used by Google Sheets. + */ + public function setDisplay(string $display): self + { + $this->display = $display; + + return $this; + } + /** * Get hash code. * @@ -89,7 +108,11 @@ class Hyperlink { return md5( $this->url + . ',' . $this->tooltip + . ',' + . $this->display + . ',' . __CLASS__ ); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/IgnoredErrors.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/IgnoredErrors.php index 0d88fe3..37b4de4 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/IgnoredErrors.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Cell/IgnoredErrors.php @@ -14,6 +14,8 @@ class IgnoredErrors private bool $evalError = false; + private bool $misleadingFormat = false; + public function setNumberStoredAsText(bool $value): self { $this->numberStoredAsText = $value; @@ -73,4 +75,16 @@ class IgnoredErrors { return $this->evalError; } + + public function setMisleadingFormat(bool $value): self + { + $this->misleadingFormat = $value; + + return $this; + } + + public function getMisleadingFormat(): bool + { + return $this->misleadingFormat; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Axis.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Axis.php index dc1fca4..243c11f 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Axis.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Axis.php @@ -336,7 +336,7 @@ class Axis extends Properties { parent::__clone(); $this->majorGridlines = ($this->majorGridlines === null) ? null : clone $this->majorGridlines; - $this->majorGridlines = ($this->minorGridlines === null) ? null : clone $this->minorGridlines; + $this->minorGridlines = ($this->minorGridlines === null) ? null : clone $this->minorGridlines; $this->axisText = ($this->axisText === null) ? null : clone $this->axisText; $this->dispUnitsTitle = ($this->dispUnitsTitle === null) ? null : clone $this->dispUnitsTitle; $this->fillColor = clone $this->fillColor; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Chart.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Chart.php index e750edc..6288ea1 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Chart.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Chart.php @@ -110,6 +110,37 @@ class Chart private bool $roundedCorners = false; + private bool $date1904 = false; + + private string $lang = 'en-GB'; + + /** @var array{ + * b?: numeric-string, + * l?: numeric-string, + * r?: numeric-string, + * t?: numeric-string, + * header?: numeric-string, + * footer?: numeric-string, + * } + */ + private array $pageMargins = [ + 'b' => '0.75', + 'l' => '0.7', + 'r' => '0.7', + 't' => '0.75', + 'header' => '0.3', + 'footer' => '0.3', + ]; + + /** @var array{ + * paperSize?: string, + * orientation?: string, + * } + */ + private array $pageSetup = [ + 'orientation' => 'portrait', + ]; + private GridLines $borderLines; private ChartColor $fillColor; @@ -680,9 +711,11 @@ class Chart return $this->autoTitleDeleted; } - public function setAutoTitleDeleted(bool $autoTitleDeleted): self + public function setAutoTitleDeleted(?bool $autoTitleDeleted): self { - $this->autoTitleDeleted = $autoTitleDeleted; + if (is_bool($autoTitleDeleted)) { + $this->autoTitleDeleted = $autoTitleDeleted; + } return $this; } @@ -725,6 +758,34 @@ class Chart return $this; } + public function getDate1904(): bool + { + return $this->date1904; + } + + public function setDate1904(?bool $date1904): self + { + if ($date1904 !== null) { + $this->date1904 = $date1904; + } + + return $this; + } + + public function getLang(): string + { + return $this->lang; + } + + public function setLang(?string $lang): self + { + if ($lang !== null && $lang !== '') { + $this->lang = $lang; + } + + return $this; + } + public function getBorderLines(): GridLines { return $this->borderLines; @@ -782,4 +843,58 @@ class Chart $this->borderLines = clone $this->borderLines; $this->fillColor = clone $this->fillColor; } + + /** @return array{ + * b?: numeric-string, + * l?: numeric-string, + * r?: numeric-string, + * t?: numeric-string, + * header?: numeric-string, + * footer?: numeric-string, + * } + */ + public function getPageMargins(): array + { + return $this->pageMargins; + } + + /** @param mixed $pageMargins expecting array matching $this->pageMargins */ + public function setPageMargins(mixed $pageMargins): self + { + if (is_array($pageMargins)) { + foreach (['b', 'l', 'r', 't', 'header', 'footer'] as $key) { + $value = $pageMargins[$key] ?? null; + if (is_string($value) && is_numeric($value)) { + $this->pageMargins[$key] = "$value"; + } + } + } + + return $this; + } + + /** @return array{ + * paperSize?: string, + * orientation?: string, + * } + */ + public function getPageSetup(): array + { + return $this->pageSetup; + } + + /** @param mixed $pageSetup expecting array matching $this->pageSetup */ + public function setPageSetup(mixed $pageSetup): self + { + if (is_array($pageSetup)) { + foreach (['paperSize', 'orientation'] as $key) { + $value = $pageSetup[$key] ?? null; + if (is_string($value)) { + $this->pageSetup[$key] = "$value"; + } + } + } + + return $this; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/ChartColor.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/ChartColor.php index 6ac4b5a..a138163 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/ChartColor.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/ChartColor.php @@ -114,7 +114,7 @@ class ChartColor return $this; } - /** @param array{value: ?string, alpha: null|int|string, brightness?: null|int|string, type: ?string} $color */ + /** @param array{value: ?string, alpha?: null|int|string, brightness?: null|int|string, type?: null|string} $color */ public function setColorPropertiesArray(array $color): self { return $this->setColorProperties( diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataSeries.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataSeries.php index 06f1c3c..972a385 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataSeries.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataSeries.php @@ -387,7 +387,7 @@ class DataSeries $plotLabels = $this->plotLabel; $this->plotLabel = []; foreach ($plotLabels as $plotLabel) { - $this->plotLabel[] = $plotLabel; + $this->plotLabel[] = clone $plotLabel; } $plotCategories = $this->plotCategory; $this->plotCategory = []; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataTable.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataTable.php new file mode 100644 index 0000000..71c18fe --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/DataTable.php @@ -0,0 +1,62 @@ +showHorizontalBorder; + } + + public function getShowVerticalBorder(): bool + { + return $this->showVerticalBorder; + } + + public function getShowOutline(): bool + { + return $this->showOutline; + } + + public function getShowKeys(): bool + { + return $this->showKeys; + } + + public function setShowHorizontalBorder(bool $showHorizontalBorder): self + { + $this->showHorizontalBorder = $showHorizontalBorder; + + return $this; + } + + public function setShowVerticalBorder(bool $showVerticalBorder): self + { + $this->showVerticalBorder = $showVerticalBorder; + + return $this; + } + + public function setShowOutline(bool $showOutline): self + { + $this->showOutline = $showOutline; + + return $this; + } + + public function setShowKeys(bool $showKeys): self + { + $this->showKeys = $showKeys; + + return $this; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Layout.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Layout.php index 95e113d..dbf9f75 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Layout.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Layout.php @@ -99,6 +99,26 @@ class Layout private ?Properties $labelEffects = null; + /** @var array{ + * vertOverflow?: string, + * horzOverflow?: string, + * wrap?: string, + * lIns?: numeric-string, + * tIns?: numeric-string, + * rIns?: numeric-string, + * bIns?: numeric-string, + * anchor?: string, + * } + */ + private array $bodyPr = [ + 'wrap' => 'square', + 'lIns' => '38100', + 'tIns' => '19050', + 'rIns' => '38100', + 'bIns' => '19050', + 'anchor' => 'ctr', + ]; + /** * Create a new Layout. * @@ -106,7 +126,22 @@ class Layout */ public function __construct(array $layout = []) { - /** @var array{layoutTarget?: string, xMode?: string, yMode?: string, x?: float, y?: float, w?:float, h?:float, dLblPos?: string, labelFont?: ?mixed, labelFontColor?: ?mixed, labelEffects?: ?mixed, numFmtCode?: string} $layout */ + /** @var array{ + * layoutTarget?: string, + * xMode?: string, + * yMode?: string, + * x?: float, + * y?: float, + * w?:float, + * h?:float, + * dLblPos?: string, + * labelFont?: ?mixed, + * labelFontColor?: ?mixed, + * labelEffects?: ?mixed, + * numFmtCode?: string, + * bodyPr?: mixed, + * } $layout + */ if (isset($layout['layoutTarget'])) { $this->layoutTarget = $layout['layoutTarget']; } @@ -156,6 +191,10 @@ class Layout if ($labelEffects instanceof Properties) { $this->labelEffects = $labelEffects; } + $bodyPr = $layout['bodyPr'] ?? null; + if (is_array($bodyPr)) { + $this->setBodyPr($bodyPr); + } } /** @param mixed[] $layout */ @@ -469,11 +508,7 @@ class Layout public function getLabelFontColor(): ?ChartColor { - if ($this->labelFont === null) { - return null; - } - - return $this->labelFont->getChartColor(); + return $this->labelFont?->getChartColor(); } public function setLabelFontColor(?ChartColor $chartColor): self @@ -523,6 +558,45 @@ class Layout return $this; } + /** @return array{ + * vertOverflow?: string, + * horzOverflow?: string, + * wrap?: string, + * lIns?: numeric-string, + * tIns?: numeric-string, + * rIns?: numeric-string, + * bIns?: numeric-string, + * anchor?: string, + * } + */ + public function getBodyPr(): array + { + return $this->bodyPr; + } + + /** + * @param mixed $bodyPr expect array matching $this->bodyPr + */ + public function setBodyPr(mixed $bodyPr): self + { + if (is_array($bodyPr)) { + foreach (['vertOverflow', 'horzOverflow', 'wrap', 'anchor'] as $key) { + $value = $bodyPr[$key] ?? null; + if (is_string($value)) { + $this->bodyPr[$key] = "$value"; + } + } + foreach (['lIns', 'tIns', 'rIns', 'bIns'] as $key) { + $value = $bodyPr[$key] ?? null; + if (is_string($value) && is_numeric($value)) { + $this->bodyPr[$key] = "$value"; + } + } + } + + return $this; + } + /** * Implement PHP __clone to create a deep clone, not just a shallow copy. */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Legend.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Legend.php index 7736fb7..f10642d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Legend.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Legend.php @@ -66,7 +66,7 @@ class Legend } /** - * Get legend position as an excel string value. + * Get legend position as an Excel string value. */ public function getPosition(): string { @@ -74,7 +74,7 @@ class Legend } /** - * Get legend position using an excel string value. + * Get legend position using an Excel string value. * * @param string $position see self::POSITION_* */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/PlotArea.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/PlotArea.php index e5e3907..f23febc 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/PlotArea.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/PlotArea.php @@ -38,6 +38,8 @@ class PlotArea */ private array $plotSeries; + private ?DataTable $dataTable = null; + /** * Create a new PlotArea. * @@ -195,16 +197,34 @@ class PlotArea return $this; } + public function setDataTable(DataTable $dataTable): self + { + $this->dataTable = $dataTable; + + return $this; + } + + public function getDataTable(): ?DataTable + { + return $this->dataTable; + } + /** * Implement PHP __clone to create a deep clone, not just a shallow copy. */ public function __clone() { $this->layout = ($this->layout === null) ? null : clone $this->layout; + $this->dataTable = ($this->dataTable === null) ? null : clone $this->dataTable; $plotSeries = $this->plotSeries; $this->plotSeries = []; foreach ($plotSeries as $series) { $this->plotSeries[] = clone $series; } + $gradientFillStops = $this->gradientFillStops; + $this->gradientFillStops = []; + foreach ($gradientFillStops as $gradientFillStop) { + $this->gradientFillStops[] = [$gradientFillStop[0], clone $gradientFillStop[1]]; + } } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Properties.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Properties.php index 700ccd1..9bf1599 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Properties.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Properties.php @@ -99,9 +99,11 @@ abstract class Properties const SHADOW_PRESETS_PERSPECTIVE_LOWER_RIGHT = 22; const SHADOW_PRESETS_PERSPECTIVE_LOWER_LEFT = 23; - const POINTS_WIDTH_MULTIPLIER = 12700; - const ANGLE_MULTIPLIER = 60000; // direction and size-kx size-ky - const PERCENTAGE_MULTIPLIER = 100000; // size sx and sy + const POINTS_WIDTH_MULTIPLIER = 12_700; + const ANGLE_MULTIPLIER = 60_000; // direction and size-kx size-ky + const PERCENTAGE_MULTIPLIER = 100_000; // size sx and sy, and gradient pos + const MAX_SKEW_ANGLE_XML = 90 * self::ANGLE_MULTIPLIER - 1; + const MAX_SKEW_ANGLE_DEGREES = self::MAX_SKEW_ANGLE_XML / self::ANGLE_MULTIPLIER; // max for size-kx size-ky protected bool $objectState = false; // used only for minor gridlines @@ -417,7 +419,7 @@ abstract class Properties } foreach ($elements as $keys) { - $reference = &$reference[$keys]; //* @phpstan-ignore-line + $reference = &$reference[$keys]; //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I'm not sure what this is doing) } return $reference; @@ -519,7 +521,7 @@ abstract class Properties return $this->softEdges['size']; } - /** @param null|array{value?: ?string, alpha?: null|int|string, brightness?: null|int|string, type?: ?string}|float|string $value */ + /** @param null|array{value?: ?string, alpha?: null|int|string, brightness?: null|int|string, type?: ?string, sx?: ?float, sy?: ?float, ky?: ?float, kx?: ?float}|float|string $value */ public function setShadowProperty(string $propertyName, mixed $value): self { $this->activateObject(); @@ -853,7 +855,7 @@ abstract class Properties public function setLineStyleProperty(string $propertyName, mixed $value): self { $this->activateObject(); - $this->lineStyleProperties[$propertyName] = $value; //* @phpstan-ignore-line + $this->lineStyleProperties[$propertyName] = $value; //* @phpstan-ignore assign.propertyType (Too complicated) return $this; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraph.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraph.php index faadd9a..ba30a35 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraph.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraph.php @@ -3,7 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Chart\Renderer; /** - * Jpgraph is not oficially maintained in Composer, so the version there + * Jpgraph is not officially maintained in Composer, so the version there * could be out of date. For that reason, all unit test requiring Jpgraph * are skipped. So, do not measure code coverage for this class till that * is fixed. diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraphRendererBase.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraphRendererBase.php index 151d5b5..ce08ee0 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraphRendererBase.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Chart/Renderer/JpGraphRendererBase.php @@ -81,7 +81,7 @@ abstract class JpGraphRendererBase implements IRenderer } /** - * This method should be overriden in descendants to do real JpGraph library initialization. + * This method should be overridden in descendants to do real JpGraph library initialization. */ abstract protected static function init(): void; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php index 0661867..af1d5c8 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Collection/Cells.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Collection; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; @@ -11,7 +12,8 @@ use Psr\SimpleCache\CacheInterface; class Cells { - protected const MAX_COLUMN_ID = 16384; + /** @deprecated 5.6.0 use AddressRange::MAX_COLUMN_INT */ + protected const MAX_COLUMN_ID = AddressRange::MAX_COLUMN_INT; private CacheInterface $cache; @@ -242,9 +244,9 @@ class Cells // Lookup highest column and highest row $maxRow = $maxColumn = 1; foreach ($this->index as $coordinate) { - $row = (int) floor(($coordinate - 1) / self::MAX_COLUMN_ID) + 1; + $row = (int) floor(($coordinate - 1) / AddressRange::MAX_COLUMN_INT) + 1; $maxRow = ($maxRow > $row) ? $maxRow : $row; - $column = ($coordinate % self::MAX_COLUMN_ID) ?: self::MAX_COLUMN_ID; + $column = ($coordinate % AddressRange::MAX_COLUMN_INT) ?: AddressRange::MAX_COLUMN_INT; $maxColumn = ($maxColumn > $column) ? $maxColumn : $column; } @@ -274,14 +276,14 @@ class Cells } $maxColumn = 1; - $toRow = $row * self::MAX_COLUMN_ID; - $fromRow = --$row * self::MAX_COLUMN_ID; + $toRow = $row * AddressRange::MAX_COLUMN_INT; + $fromRow = --$row * AddressRange::MAX_COLUMN_INT; foreach ($this->index as $coordinate) { if ($coordinate < $fromRow || $coordinate >= $toRow) { continue; } - $column = ($coordinate % self::MAX_COLUMN_ID) ?: self::MAX_COLUMN_ID; - $maxColumn = $maxColumn > $column ? $maxColumn : $column; + $column = ($coordinate % AddressRange::MAX_COLUMN_INT) ?: AddressRange::MAX_COLUMN_INT; + $maxColumn = max($column, $maxColumn); } return Coordinate::stringFromColumnIndex($maxColumn); @@ -304,10 +306,10 @@ class Cells $maxRow = 1; $columnIndex = Coordinate::columnIndexFromString($column); foreach ($this->index as $coordinate) { - if ($coordinate % self::MAX_COLUMN_ID !== $columnIndex) { + if ($coordinate % AddressRange::MAX_COLUMN_INT !== $columnIndex) { continue; } - $row = (int) floor($coordinate / self::MAX_COLUMN_ID) + 1; + $row = (int) floor($coordinate / AddressRange::MAX_COLUMN_INT) + 1; $maxRow = ($maxRow > $row) ? $maxRow : $row; } @@ -371,12 +373,12 @@ class Cells throw new PhpSpreadsheetException('Row number must be a positive integer'); } - $toRow = $row * self::MAX_COLUMN_ID; - $fromRow = --$row * self::MAX_COLUMN_ID; + $toRow = $row * AddressRange::MAX_COLUMN_INT; + $fromRow = --$row * AddressRange::MAX_COLUMN_INT; foreach ($this->index as $coordinate) { if ($coordinate >= $fromRow && $coordinate < $toRow) { - $row = (int) floor($coordinate / self::MAX_COLUMN_ID) + 1; - $column = Coordinate::stringFromColumnIndex($coordinate % self::MAX_COLUMN_ID); + $row = (int) floor($coordinate / AddressRange::MAX_COLUMN_INT) + 1; + $column = Coordinate::stringFromColumnIndex($coordinate % AddressRange::MAX_COLUMN_INT); $this->delete("{$column}{$row}"); } } @@ -393,9 +395,9 @@ class Cells $columnIndex = Coordinate::columnIndexFromString($column); foreach ($this->index as $coordinate) { - if ($coordinate % self::MAX_COLUMN_ID === $columnIndex) { - $row = (int) floor($coordinate / self::MAX_COLUMN_ID) + 1; - $column = Coordinate::stringFromColumnIndex($coordinate % self::MAX_COLUMN_ID); + if ($coordinate % AddressRange::MAX_COLUMN_INT === $columnIndex) { + $row = (int) floor($coordinate / AddressRange::MAX_COLUMN_INT) + 1; + $column = Coordinate::stringFromColumnIndex($coordinate % AddressRange::MAX_COLUMN_INT); $this->delete("{$column}{$row}"); } } @@ -443,7 +445,7 @@ class Cells $row = ''; sscanf($cellCoordinate, '%[A-Z]%d', $column, $row); /** @var int $row */ - $this->index[$cellCoordinate] = (--$row * self::MAX_COLUMN_ID) + Coordinate::columnIndexFromString((string) $column); + $this->index[$cellCoordinate] = (--$row * AddressRange::MAX_COLUMN_INT) + Coordinate::columnIndexFromString((string) $column); // Clear index sorted flag and index caches $this->indexSorted = false; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Downloader.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Downloader.php index 0c39efe..7aa1494 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Downloader.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Downloader.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Helper; +use DateTimeImmutable; +use DateTimeZone; use PhpOffice\PhpSpreadsheet\Exception; /** @@ -29,19 +31,16 @@ class Downloader public function __construct(string $folder, string $filename, ?string $filetype = null) { - if ((is_dir($folder) === false) || (is_readable($folder) === false)) { - throw new Exception('Folder is not accessible'); - } - $filepath = "{$folder}/{$filename}"; - $this->filepath = (string) realpath($filepath); - $this->filename = basename($filepath); clearstatcache(); - if ((is_file($this->filepath) === false) || (is_readable($this->filepath) === false)) { - throw new Exception('File not found, or not a regular file, or cannot be read'); + $filepath = realpath("{$folder}/{$filename}"); + if ($filepath === false || !is_file($filepath) || !is_readable($filepath)) { + throw new Exception('File not found, or cannot be read'); } + $this->filepath = $filepath; + $this->filename = basename($this->filepath); - $filetype ??= pathinfo($filename, PATHINFO_EXTENSION); - if (array_key_exists(strtolower($filetype), self::CONTENT_TYPES) === false) { + $filetype ??= pathinfo($this->filename, PATHINFO_EXTENSION); + if (!array_key_exists(strtolower($filetype), self::CONTENT_TYPES)) { throw new Exception('Invalid filetype: file cannot be downloaded'); } $this->filetype = strtolower($filetype); @@ -90,7 +89,8 @@ class Downloader // If you're serving to IE over SSL, then the following may be needed header('Expires: Mon, 26 Jul 1997 05:00:00 GMT'); // Date in the past - header('Last-Modified: ' . gmdate('D, d M Y H:i:s') . ' GMT'); // always modified + $dt = new DateTimeImmutable(timezone: new DateTimeZone('UTC')); + header('Last-Modified: ' . $dt->format('D, d M Y H:i:s') . ' GMT'); // always modified header('Cache-Control: cache, must-revalidate'); // HTTP/1.1 header('Pragma: public'); // HTTP/1.0 } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Handler.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Handler.php index fb1b9a0..514b7d7 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Handler.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Handler.php @@ -2,13 +2,14 @@ namespace PhpOffice\PhpSpreadsheet\Helper; -class Handler +/** @internal */ +final class Handler { private static string $invalidHex = 'Y'; // A bunch of methods to show that we continue // to capture messages even using PhpUnit 10. - public static function suppressed(): bool + public static function suppressed(): true { return @trigger_error('hello'); } @@ -28,17 +29,17 @@ class Handler return file_get_contents(__FILE__ . 'noexist') !== false; } - public static function userDeprecated(): bool + public static function userDeprecated(): true { return trigger_error('hello', E_USER_DEPRECATED); } - public static function userNotice(): bool + public static function userNotice(): true { return trigger_error('userNotice', E_USER_NOTICE); } - public static function userWarning(): bool + public static function userWarning(): true { return trigger_error('userWarning', E_USER_WARNING); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Sample.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Sample.php index 729d5d7..8cdfe41 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Sample.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/Sample.php @@ -131,16 +131,21 @@ class Sample $callStartTime = microtime(true); $writer->save($path); $this->logWrite($writer, $path, $callStartTime); - if ($this->isCli() === false) { - // @codeCoverageIgnoreStart - echo 'Download ' . basename($path) . '
'; - // @codeCoverageIgnoreEnd - } + $this->addDownloadLink($path); } $this->logEndingNotes(); } + public function addDownloadLink(string $path): void + { + if ($this->isCli() === false) { + // @codeCoverageIgnoreStart + echo 'Download ' . basename($path) . '
'; + // @codeCoverageIgnoreEnd + } + } + protected function isDirOrMkdir(string $folder): bool { return \is_dir($folder) || \mkdir($folder); @@ -242,9 +247,15 @@ class Sample } /** @param mixed[][] $matrix */ - public function displayGrid(array $matrix): void + public function displayGrid(array $matrix, null|bool|TextGridRightAlign $numbersRight = null): void { $renderer = new TextGrid($matrix, $this->isCli()); + if (is_bool($numbersRight)) { + $numbersRight = $numbersRight ? TextGridRightAlign::numeric : TextGridRightAlign::none; + } + if ($numbersRight !== null) { + $renderer->setNumbersRight($numbersRight); + } echo $renderer->render(); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGrid.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGrid.php index 9c64c61..cd6fb2f 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGrid.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGrid.php @@ -6,7 +6,7 @@ use PhpOffice\PhpSpreadsheet\Shared\StringHelper; class TextGrid { - private bool $isCli; + protected bool $isCli; /** @var mixed[][] */ protected array $matrix; @@ -17,16 +17,18 @@ class TextGrid /** @var string[] */ protected array $columns; - private string $gridDisplay; + protected string $gridDisplay; - private bool $rowDividers = false; + protected bool $rowDividers = false; - private bool $rowHeaders = true; + protected bool $rowHeaders = true; - private bool $columnHeaders = true; + protected bool $columnHeaders = true; + + protected TextGridRightAlign $numbersRight = TextGridRightAlign::none; /** @param mixed[][] $matrix */ - public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true) + public function __construct(array $matrix, bool $isCli = true, bool $rowDividers = false, bool $rowHeaders = true, bool $columnHeaders = true, TextGridRightAlign $numbersRight = TextGridRightAlign::none) { $this->rows = array_keys($matrix); $this->columns = array_keys($matrix[$this->rows[0]]); @@ -44,6 +46,12 @@ class TextGrid $this->rowDividers = $rowDividers; $this->rowHeaders = $rowHeaders; $this->columnHeaders = $columnHeaders; + $this->numbersRight = $numbersRight; + } + + public function setNumbersRight(TextGridRightAlign $numbersRight): void + { + $this->numbersRight = $numbersRight; } public function render(): string @@ -52,7 +60,7 @@ class TextGrid if (!empty($this->rows)) { $maxRow = max($this->rows); - $maxRowLength = strlen((string) $maxRow) + 1; + $maxRowLength = $this->strlen((string) $maxRow) + 1; $columnWidths = $this->getColumnWidths(); $this->renderColumnHeader($maxRowLength, $columnWidths); @@ -68,7 +76,7 @@ class TextGrid } /** @param int[] $columnWidths */ - private function renderRows(int $maxRowLength, array $columnWidths): void + protected function renderRows(int $maxRowLength, array $columnWidths): void { foreach ($this->matrix as $row => $rowData) { if ($this->rowHeaders) { @@ -86,18 +94,27 @@ class TextGrid * @param mixed[] $rowData * @param int[] $columnWidths */ - private function renderCells(array $rowData, array $columnWidths): void + protected function renderCells(array $rowData, array $columnWidths): void { foreach ($rowData as $column => $cell) { $valueForLength = $this->getString($cell); $displayCell = $this->isCli ? $valueForLength : htmlentities($valueForLength); $this->gridDisplay .= '| '; - $this->gridDisplay .= $displayCell . str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength) + 1); + if ($this->rightAlign($displayCell, $cell)) { + $this->gridDisplay .= str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength)) . $displayCell . ' '; + } else { + $this->gridDisplay .= $displayCell . str_repeat(' ', $columnWidths[$column] - $this->strlen($valueForLength) + 1); + } } } + protected function rightAlign(string $displayCell, mixed $cell = null): bool + { + return ($this->numbersRight === TextGridRightAlign::numeric && is_numeric($displayCell)) || ($this->numbersRight === TextGridRightAlign::floatOrInt && (is_int($cell) || is_float($cell))); + } + /** @param int[] $columnWidths */ - private function renderColumnHeader(int $maxRowLength, array &$columnWidths): void + protected function renderColumnHeader(int $maxRowLength, array &$columnWidths): void { if (!$this->columnHeaders) { $this->renderFooter($maxRowLength, $columnWidths); @@ -129,7 +146,7 @@ class TextGrid } /** @param int[] $columnWidths */ - private function renderFooter(int $maxRowLength, array $columnWidths): void + protected function renderFooter(int $maxRowLength, array $columnWidths): void { if ($this->rowHeaders) { $this->gridDisplay .= '+' . str_repeat('-', $maxRowLength + 1); @@ -142,7 +159,7 @@ class TextGrid } /** @return int[] */ - private function getColumnWidths(): array + protected function getColumnWidths(): array { $columnCount = count($this->matrix, COUNT_RECURSIVE) / count($this->matrix); $columnWidths = []; @@ -154,7 +171,7 @@ class TextGrid } /** @param mixed[] $columnData */ - private function getColumnWidth(array $columnData): int + protected function getColumnWidth(array $columnData): int { $columnWidth = 0; $columnData = array_values($columnData); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGridRightAlign.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGridRightAlign.php new file mode 100644 index 0000000..2921e5c --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Helper/TextGridRightAlign.php @@ -0,0 +1,10 @@ + CsvNoEscape::class]; + public const WRITER_XLSX = 'Xlsx'; public const WRITER_XLS = 'Xls'; public const WRITER_ODS = 'Ods'; @@ -54,6 +57,31 @@ abstract class IOFactory 'Mpdf' => Writer\Pdf\Mpdf::class, ]; + /** @internal */ + public static function restoreDefaultReadersAndWriters(): void + { + self::$readers = [ + self::READER_XLSX => Reader\Xlsx::class, + self::READER_XLS => Reader\Xls::class, + self::READER_XML => Reader\Xml::class, + self::READER_ODS => Reader\Ods::class, + self::READER_SLK => Reader\Slk::class, + self::READER_GNUMERIC => Reader\Gnumeric::class, + self::READER_HTML => Reader\Html::class, + self::READER_CSV => Reader\Csv::class, + ]; + self::$writers = [ + self::WRITER_XLS => Writer\Xls::class, + self::WRITER_XLSX => Writer\Xlsx::class, + self::WRITER_ODS => Writer\Ods::class, + self::WRITER_CSV => Writer\Csv::class, + self::WRITER_HTML => Writer\Html::class, + 'Tcpdf' => Writer\Pdf\Tcpdf::class, + 'Dompdf' => Writer\Pdf\Dompdf::class, + 'Mpdf' => Writer\Pdf\Mpdf::class, + ]; + } + /** * Create Writer\IWriter. */ @@ -75,18 +103,22 @@ abstract class IOFactory /** * Create IReader. + * + * @param array> $mergeArray + * Array to use to find reader, self::$readers will be used if $readers is empty. */ - public static function createReader(string $readerType): IReader + public static function createReader(string $readerType, array $mergeArray = []): IReader { /** @var class-string */ $className = $readerType; - if (!in_array($readerType, self::$readers, true)) { - if (!isset(self::$readers[$readerType])) { + $readers = empty($mergeArray) ? self::$readers : $mergeArray; + if (!in_array($readerType, $readers, true)) { + if (!isset($readers[$readerType])) { throw new Reader\Exception("No reader found for type $readerType"); } // Instantiate reader - $className = self::$readers[$readerType]; + $className = $readers[$readerType]; } return new $className(); @@ -106,10 +138,11 @@ abstract class IOFactory * list of Readers so it will only try the subset that you specify here. * Values in this list can be any of the constant values defined in the set * IOFactory::READER_*. + * @param array> $mergeArray */ - public static function load(string $filename, int $flags = 0, ?array $readers = null): Spreadsheet + public static function load(string $filename, int $flags = 0, ?array $readers = null, array $mergeArray = []): Spreadsheet { - $reader = self::createReaderForFile($filename, $readers); + $reader = self::createReaderForFile($filename, $readers, $mergeArray); return $reader->load($filename, $flags); } @@ -118,10 +151,11 @@ abstract class IOFactory * Identify file type using automatic IReader resolution. * * @param string[] $readers + * @param array> $mergeArray */ - public static function identify(string $filename, ?array $readers = null, bool $fullClassName = false): string + public static function identify(string $filename, ?array $readers = null, bool $fullClassName = false, array $mergeArray = []): string { - $reader = self::createReaderForFile($filename, $readers); + $reader = self::createReaderForFile($filename, $readers, $mergeArray); $className = $reader::class; if ($fullClassName) { return $className; @@ -139,12 +173,16 @@ abstract class IOFactory * list of Readers so it will only try the subset that you specify here. * Values in this list can be any of the constant values defined in the set * IOFactory::READER_*. + * @param array> $mergeArray supplied readers will be merged with + * default readers, allowing specification of a partial list. + * + * @throws Reader\Exception */ - public static function createReaderForFile(string $filename, ?array $readers = null): IReader + public static function createReaderForFile(string $filename, ?array $readers = null, array $mergeArray = []): IReader { File::assertFile($filename); - $testReaders = self::$readers; + $testReaders = array_merge(self::$readers, $mergeArray); if ($readers !== null) { $readers = array_map('strtoupper', $readers); $testReaders = array_filter( @@ -157,7 +195,7 @@ abstract class IOFactory // First, lucky guess by inspecting file extension $guessedReader = self::getReaderTypeFromExtension($filename); if (($guessedReader !== null) && array_key_exists($guessedReader, $testReaders)) { - $reader = self::createReader($guessedReader); + $reader = self::createReader($guessedReader, $testReaders); // Let's see if we are lucky if ($reader->canRead($filename)) { @@ -170,7 +208,7 @@ abstract class IOFactory foreach ($testReaders as $readerType => $class) { // Ignore our original guess, we know that won't work if ($readerType !== $guessedReader) { - $reader = self::createReader($readerType); + $reader = self::createReader($readerType, $testReaders); if ($reader->canRead($filename)) { return $reader; } @@ -227,8 +265,7 @@ abstract class IOFactory */ public static function registerWriter(string $writerType, string $writerClass): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($writerClass, IWriter::class, true)) { //* @phpstan-ignore-line + if (!is_a($writerClass, IWriter::class, true)) { throw new Writer\Exception('Registered writers must implement ' . IWriter::class); } @@ -242,11 +279,22 @@ abstract class IOFactory */ public static function registerReader(string $readerType, string $readerClass): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($readerClass, IReader::class, true)) { //* @phpstan-ignore-line + if (!is_a($readerClass, IReader::class, true)) { throw new Reader\Exception('Registered readers must implement ' . IReader::class); } self::$readers[$readerType] = $readerClass; } + + /** + * @return array> + * + * @internal + * + * @codeCoverageIgnore + */ + public static function getReaders(): array + { + return self::$readers; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php index c4184bd..da18657 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/BaseReader.php @@ -9,7 +9,7 @@ use PhpOffice\PhpSpreadsheet\Reader\Security\XmlScanner; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Spreadsheet; -abstract class BaseReader implements IReader +abstract class BaseReader implements IReader2 { /** * Read data only? @@ -60,6 +60,14 @@ abstract class BaseReader implements IReader */ protected bool $createBlankSheetIfNoneRead = false; + /** + * Enable drawing pass-through? + * Identifies whether the Reader should preserve unsupported drawing elements (shapes, grouped images, etc.) + * by storing the original XML for pass-through during write operations. + * When enabled, drawings cannot be modified programmatically but are preserved exactly. + */ + protected bool $enableDrawingPassThrough = false; + /** * IReadFilter instance. */ @@ -72,6 +80,9 @@ abstract class BaseReader implements IReader protected ?IValueBinder $valueBinder = null; + /** @var null|callable(string):bool function to return whether image path is okay */ + protected $isWhitelisted; + public function __construct() { $this->readFilter = new DefaultReadFilter(); @@ -82,7 +93,7 @@ abstract class BaseReader implements IReader return $this->readDataOnly; } - public function setReadDataOnly(bool $readCellValuesOnly): self + public function setReadDataOnly(bool $readCellValuesOnly): static { $this->readDataOnly = $readCellValuesOnly; @@ -94,7 +105,7 @@ abstract class BaseReader implements IReader return $this->readEmptyCells; } - public function setReadEmptyCells(bool $readEmptyCells): self + public function setReadEmptyCells(bool $readEmptyCells): static { $this->readEmptyCells = $readEmptyCells; @@ -106,7 +117,7 @@ abstract class BaseReader implements IReader return $this->ignoreRowsWithNoCells; } - public function setIgnoreRowsWithNoCells(bool $ignoreRowsWithNoCells): self + public function setIgnoreRowsWithNoCells(bool $ignoreRowsWithNoCells): static { $this->ignoreRowsWithNoCells = $ignoreRowsWithNoCells; @@ -118,13 +129,25 @@ abstract class BaseReader implements IReader return $this->includeCharts; } - public function setIncludeCharts(bool $includeCharts): self + public function setIncludeCharts(bool $includeCharts): static { $this->includeCharts = $includeCharts; return $this; } + public function getEnableDrawingPassThrough(): bool + { + return $this->enableDrawingPassThrough; + } + + public function setEnableDrawingPassThrough(bool $enableDrawingPassThrough): static + { + $this->enableDrawingPassThrough = $enableDrawingPassThrough; + + return $this; + } + /** @return null|string[] */ public function getLoadSheetsOnly(): ?array { @@ -132,7 +155,7 @@ abstract class BaseReader implements IReader } /** @param null|string|string[] $sheetList */ - public function setLoadSheetsOnly(string|array|null $sheetList): self + public function setLoadSheetsOnly(string|array|null $sheetList): static { if ($sheetList === null) { return $this->setLoadAllSheets(); @@ -143,7 +166,7 @@ abstract class BaseReader implements IReader return $this; } - public function setLoadAllSheets(): self + public function setLoadAllSheets(): static { $this->loadSheetsOnly = null; @@ -155,7 +178,7 @@ abstract class BaseReader implements IReader return $this->readFilter; } - public function setReadFilter(IReadFilter $readFilter): self + public function setReadFilter(IReadFilter $readFilter): static { $this->readFilter = $readFilter; @@ -163,11 +186,12 @@ abstract class BaseReader implements IReader } /** - * Allow external images. Use with caution. - * Improper specification of these within a spreadsheet - * can subject the caller to security exploits. + * USE WITH CAUTION (and in conjunction with setIsWhiteListed)! + * Allow external images; + * these can be specified within a spreadsheet + * in a way that can subject the caller to security exploits. */ - public function setAllowExternalImages(bool $allowExternalImages): self + public function setAllowExternalImages(bool $allowExternalImages): static { $this->allowExternalImages = $allowExternalImages; @@ -179,11 +203,27 @@ abstract class BaseReader implements IReader return $this->allowExternalImages; } + /** + * USE WITH CAUTION! + * Supply a callback to determine whether a path should be whitelisted, + * used in conjunction with setAllowExternalImages; + * supplying a method which might return true + * can subject the caller to security exploits. + * + * @param callable(string):bool $isWhitelisted + */ + public function setIsWhitelisted(callable $isWhitelisted): static + { + $this->isWhitelisted = $isWhitelisted; + + return $this; + } + /** * Create a blank sheet if none are read, * possibly due to a typo when using LoadSheetsOnly. */ - public function setCreateBlankSheetIfNoneRead(bool $createBlankSheetIfNoneRead): self + public function setCreateBlankSheetIfNoneRead(bool $createBlankSheetIfNoneRead): static { $this->createBlankSheetIfNoneRead = $createBlankSheetIfNoneRead; @@ -305,7 +345,7 @@ abstract class BaseReader implements IReader return $this->valueBinder; } - public function setValueBinder(?IValueBinder $valueBinder): self + public function setValueBinder(?IValueBinder $valueBinder): static { $this->valueBinder = $valueBinder; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv.php index 55fe08a..e20b0fb 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv.php @@ -34,47 +34,48 @@ class Csv extends BaseReader /** * Input encoding. */ - private string $inputEncoding = 'UTF-8'; + protected string $inputEncoding = 'UTF-8'; /** * Fallback encoding if guess strikes out. */ - private string $fallbackEncoding = self::DEFAULT_FALLBACK_ENCODING; + protected string $fallbackEncoding = self::DEFAULT_FALLBACK_ENCODING; /** * Delimiter. */ - private ?string $delimiter = null; + protected ?string $delimiter = null; /** * Enclosure. */ - private string $enclosure = '"'; + protected string $enclosure = '"'; /** * Sheet index to read. */ - private int $sheetIndex = 0; + protected int $sheetIndex = 0; /** * Load rows contiguously. */ - private bool $contiguous = false; + protected bool $contiguous = false; /** * The character that can escape the enclosure. * This will probably become unsupported in Php 9. * Not yet ready to mark deprecated in order to give users * a migration path. + * Strongly encourage that this value be set to null-string. */ - private ?string $escapeCharacter = null; + protected ?string $escapeCharacter = null; /** * Callback for setting defaults in construction. * * @var ?callable */ - private static $constructorCallback; + protected static $constructorCallback; /** Changed from true to false in release 4.0.0 */ public const DEFAULT_TEST_AUTODETECT = false; @@ -82,23 +83,23 @@ class Csv extends BaseReader /** * Attempt autodetect line endings (deprecated after PHP8.1)? */ - private bool $testAutodetect = self::DEFAULT_TEST_AUTODETECT; + protected bool $testAutodetect = self::DEFAULT_TEST_AUTODETECT; protected bool $castFormattedNumberToNumeric = false; protected bool $preserveNumericFormatting = false; - private bool $preserveNullString = false; + protected bool $preserveNullString = false; - private bool $sheetNameIsFileName = false; + protected bool $sheetNameIsFileName = false; - private string $getTrue = 'true'; + protected ?string $getTrue = null; - private string $getFalse = 'false'; + protected ?string $getFalse = null; - private string $thousandsSeparator = ','; + protected ?string $thousandsSeparator = null; - private string $decimalSeparator = '.'; + protected ?string $decimalSeparator = null; /** * Create a new CSV Reader instance. @@ -106,7 +107,7 @@ class Csv extends BaseReader public function __construct() { parent::__construct(); - $callback = self::$constructorCallback; + $callback = static::$constructorCallback; if ($callback !== null) { $callback($this); } @@ -120,12 +121,12 @@ class Csv extends BaseReader */ public static function setConstructorCallback(?callable $callback): void { - self::$constructorCallback = $callback; + static::$constructorCallback = $callback; } public static function getConstructorCallback(): ?callable { - return self::$constructorCallback; + return static::$constructorCallback; } public function setInputEncoding(string $encoding): self @@ -248,7 +249,7 @@ class Csv extends BaseReader $rowData = self::getCsv($fileHandle, 0, $delimiter, $this->enclosure, $this->escapeCharacter); } - $worksheetInfo[0]['lastColumnLetter'] = Coordinate::stringFromColumnIndex($worksheetInfo[0]['lastColumnIndex'] + 1); + $worksheetInfo[0]['lastColumnLetter'] = Coordinate::stringFromColumnIndex($worksheetInfo[0]['lastColumnIndex'] + 1, true); $worksheetInfo[0]['totalColumns'] = $worksheetInfo[0]['lastColumnIndex'] + 1; $worksheetInfo[0]['sheetState'] = Worksheet::SHEETSTATE_VISIBLE; @@ -300,18 +301,27 @@ class Csv extends BaseReader } $this->openFile($filename); if ($this->inputEncoding !== 'UTF-8') { - fclose($this->fileHandle); - $entireFile = file_get_contents($filename); - $fileHandle = fopen('php://memory', 'r+b'); - if ($fileHandle !== false && $entireFile !== false) { - $this->fileHandle = $fileHandle; - $data = StringHelper::convertEncoding($entireFile, 'UTF-8', $this->inputEncoding); - fwrite($this->fileHandle, $data); - $this->skipBOM(); - } + $this->convertNonUtf8($filename); } } + protected function convertNonUtf8(string $filename): void + { + fclose($this->fileHandle); + $entireFile = file_get_contents($filename); + if ($entireFile === false) { + throw new ReaderException("Unable to get contents of $filename"); // @codeCoverageIgnore + } + $fileHandle = fopen('php://memory', 'r+b'); + if ($fileHandle === false) { + throw new ReaderException('Unable to open php://memory'); // @codeCoverageIgnore + } + $this->fileHandle = $fileHandle; + $data = StringHelper::convertEncoding($entireFile, 'UTF-8', $this->inputEncoding); + fwrite($this->fileHandle, $data); + $this->skipBOM(); + } + public function setTestAutoDetect(bool $value): self { $this->testAutodetect = $value; @@ -417,10 +427,10 @@ class Csv extends BaseReader $rowData = self::getCsv($fileHandle, 0, $delimiter, $this->enclosure, $this->escapeCharacter); $valueBinder = $this->valueBinder ?? Cell::getValueBinder(); $preserveBooleanString = method_exists($valueBinder, 'getBooleanConversion') && $valueBinder->getBooleanConversion(); - $this->getTrue = Calculation::getTRUE(); - $this->getFalse = Calculation::getFALSE(); - $this->thousandsSeparator = StringHelper::getThousandsSeparator(); - $this->decimalSeparator = StringHelper::getDecimalSeparator(); + $this->getTrue ??= Calculation::getTRUE(); + $this->getFalse ??= Calculation::getFALSE(); + $this->thousandsSeparator ??= StringHelper::getThousandsSeparator(); + $this->decimalSeparator ??= StringHelper::getDecimalSeparator(); while (is_array($rowData)) { $noOutputYet = true; $columnLetter = 'A'; @@ -465,9 +475,9 @@ class Csv extends BaseReader private function convertBoolean(mixed &$rowDatum): void { if (is_string($rowDatum)) { - if (strcasecmp($this->getTrue, $rowDatum) === 0 || strcasecmp('true', $rowDatum) === 0) { + if (strcasecmp((string) $this->getTrue, $rowDatum) === 0 || strcasecmp('true', $rowDatum) === 0) { $rowDatum = true; - } elseif (strcasecmp($this->getFalse, $rowDatum) === 0 || strcasecmp('false', $rowDatum) === 0) { + } elseif (strcasecmp((string) $this->getFalse, $rowDatum) === 0 || strcasecmp('false', $rowDatum) === 0) { $rowDatum = false; } } else { @@ -483,15 +493,15 @@ class Csv extends BaseReader $numberFormatMask = ''; if ($this->castFormattedNumberToNumeric === true && is_string($rowDatum)) { $numeric = str_replace( - [$this->thousandsSeparator, $this->decimalSeparator], + [(string) $this->thousandsSeparator, (string) $this->decimalSeparator], ['', '.'], $rowDatum ); if (is_numeric($numeric)) { - $decimalPos = strpos($rowDatum, $this->decimalSeparator); + $decimalPos = strpos($rowDatum, (string) $this->decimalSeparator); if ($this->preserveNumericFormatting === true) { - $numberFormatMask = (str_contains($rowDatum, $this->thousandsSeparator)) + $numberFormatMask = (str_contains($rowDatum, (string) $this->thousandsSeparator)) ? '#,##0' : '0'; if ($decimalPos !== false) { $decimals = strlen($rowDatum) - $decimalPos - 1; @@ -759,4 +769,32 @@ class Csv extends BaseReader { return $version < 90000 ? '\\' : ''; } + + public function setGetTrue(?string $getTrue): self + { + $this->getTrue = $getTrue; + + return $this; + } + + public function setGetFalse(?string $getFalse): self + { + $this->getFalse = $getFalse; + + return $this; + } + + public function setDecimalSeparator(?string $decimalSeparator): self + { + $this->decimalSeparator = $decimalSeparator; + + return $this; + } + + public function setThousandsSeparator(?string $thousandsSeparator): self + { + $this->thousandsSeparator = $thousandsSeparator; + + return $this; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv/Delimiter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv/Delimiter.php index 05b898f..a3370e0 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv/Delimiter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Csv/Delimiter.php @@ -4,7 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Csv; class Delimiter { - protected const POTENTIAL_DELIMETERS = [',', ';', "\t", '|', ':', ' ', '~']; + protected const POTENTIAL_DELIMITERS = [',', ';', "\t", '|', ':', ' ', '~']; /** @var resource */ protected $fileHandle; @@ -34,7 +34,7 @@ class Delimiter public function getDefaultDelimiter(): string { - return self::POTENTIAL_DELIMETERS[0]; + return self::POTENTIAL_DELIMITERS[0]; } public function linesCounted(): int @@ -44,8 +44,8 @@ class Delimiter protected function countPotentialDelimiters(): void { - $this->counts = array_fill_keys(self::POTENTIAL_DELIMETERS, []); - $delimiterKeys = array_flip(self::POTENTIAL_DELIMETERS); + $this->counts = array_fill_keys(self::POTENTIAL_DELIMITERS, []); + $delimiterKeys = array_flip(self::POTENTIAL_DELIMITERS); // Count how many times each of the potential delimiters appears in each line $this->numberLines = 0; @@ -61,7 +61,7 @@ class Delimiter $distribution = array_count_values($splitString); $countLine = array_intersect_key($distribution, $delimiterKeys); - foreach (self::POTENTIAL_DELIMETERS as $delimiter) { + foreach (self::POTENTIAL_DELIMITERS as $delimiter) { $this->counts[$delimiter][] = $countLine[$delimiter] ?? 0; } } @@ -73,7 +73,7 @@ class Delimiter $meanSquareDeviations = []; $middleIdx = (int) floor(($this->numberLines - 1) / 2); - foreach (self::POTENTIAL_DELIMETERS as $delimiter) { + foreach (self::POTENTIAL_DELIMITERS as $delimiter) { $series = $this->counts[$delimiter]; sort($series); @@ -94,7 +94,7 @@ class Delimiter // ... and pick the delimiter with the smallest mean square deviation // (in case of ties, the order in potentialDelimiters is respected) $min = INF; - foreach (self::POTENTIAL_DELIMETERS as $delimiter) { + foreach (self::POTENTIAL_DELIMITERS as $delimiter) { if (!isset($meanSquareDeviations[$delimiter])) { continue; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/CsvNoEscape.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/CsvNoEscape.php new file mode 100644 index 0000000..9eea2b0 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/CsvNoEscape.php @@ -0,0 +1,51 @@ +escapeCharacter = $escapeCharacter; + + return $this; + } + + /** + * Changing auto-detect not allowed in this class. + */ + public function setTestAutoDetect(bool $value): self + { + if ($value !== false) { + throw new ReaderException('This class requires that testAutoDetect be false'); + } + $this->testAutodetect = $value; + + return $this; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/EmptyWorksheetInfo.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/EmptyWorksheetInfo.php new file mode 100644 index 0000000..ec5718b --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/EmptyWorksheetInfo.php @@ -0,0 +1,31 @@ + + */ + public function listWorksheetInfo(string $filename): array + { + return []; + } + + /** + * Return empty array as names of the worksheets from a file, + * possibly without parsing the whole file to a Spreadsheet object. + * + * @return string[] + */ + public function listWorksheetNames(string $filename): array + { + return []; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric.php index 5e68360..67cbba9 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric.php @@ -50,8 +50,14 @@ class Gnumeric extends BaseReader private ReferenceHelper $referenceHelper; - /** @var array{'dataType': string[]} */ - public static array $mappings = [ + /** + * @deprecated 5.10.0 No longer used, replaced by const which is not user-accessible. + * + * @var array{'dataType': string[]} + */ + public static array $mappings = self::MAPPINGS; + + private const MAPPINGS = [ 'dataType' => [ '10' => DataType::TYPE_NULL, '20' => DataType::TYPE_BOOL, @@ -64,6 +70,14 @@ class Gnumeric extends BaseReader ], ]; + protected int $maxLength; + + private const LENGTH_MULTIPLIER = [ + 'G' => 1024 * 1024 * 1024, + 'M' => 1024 * 1024, + 'K' => 1024, + ]; + /** * Create a new Gnumeric. */ @@ -72,6 +86,20 @@ class Gnumeric extends BaseReader parent::__construct(); $this->referenceHelper = ReferenceHelper::getInstance(); $this->securityScanner = XmlScanner::getInstance($this); + $limit = ini_get('memory_limit') ?: '128M'; + $limit = trim(str_replace('-1', '128M', $limit)); + $unit = strtoupper(substr($limit, -1)); + $limit = (int) $limit; + $multiplier = self::LENGTH_MULTIPLIER[$unit] ?? 1; + $limit *= $multiplier; + $this->maxLength = intdiv($limit, 4); + } + + public function setMaxLength(int $maxLength): self + { + $this->maxLength = $maxLength; + + return $this; } /** @@ -176,7 +204,7 @@ class Gnumeric extends BaseReader break; } } - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true); $worksheetInfo[] = $tmpInfo; } } @@ -192,7 +220,7 @@ class Gnumeric extends BaseReader if (str_starts_with($contents, "\x1f\x8b")) { // Check if gzlib functions are available if (function_exists('gzdecode')) { - $contents = @gzdecode($contents); + $contents = @gzdecode($contents, $this->maxLength); if ($contents !== false) { $data = $contents; } @@ -208,10 +236,14 @@ class Gnumeric extends BaseReader return $data; } - /** @return mixed[] */ + /** + * @return mixed[] + * + * @internal + */ public static function gnumericMappings(): array { - return array_merge(self::$mappings, Styles::$mappings); + return array_merge(self::MAPPINGS, Styles::MAPPINGS); } private function processComments(SimpleXMLElement $sheet): void @@ -260,8 +292,7 @@ class Gnumeric extends BaseReader $gFileData = $this->gzfileGetContents($filename); - /** @var XmlScanner */ - $securityScanner = $this->securityScanner; + $securityScanner = $this->getSecurityScannerOrThrow(); $xml2 = simplexml_load_string($securityScanner->scan($gFileData)); $xml = self::testSimpleXml($xml2); @@ -311,7 +342,7 @@ class Gnumeric extends BaseReader $column = Coordinate::stringFromColumnIndex($column + 1); // Read cell? - if (!$this->getReadFilter()->readCell($column, $row, $worksheetName)) { + if (!$this->readFilter->readCell($column, $row, $worksheetName)) { continue; } @@ -589,8 +620,8 @@ class Gnumeric extends BaseReader $type = DataType::TYPE_FORMULA; } elseif ($isArrayFormula === false) { $vtype = (string) $ValueType; - if (array_key_exists($vtype, self::$mappings['dataType'])) { - $type = self::$mappings['dataType'][$vtype]; + if (array_key_exists($vtype, self::MAPPINGS['dataType'])) { + $type = self::MAPPINGS['dataType'][$vtype]; } if ($vtype === '20') { // Boolean $cell = $cell == 'TRUE'; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php index e947ae2..07f8a70 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Gnumeric/Styles.php @@ -18,8 +18,15 @@ class Styles protected bool $readDataOnly; - /** @var array */ - public static array $mappings = [ + /** + * @deprecated 5.10.0 No longer used, replaced by const which is not user-accessible. + * + * @var array + */ + public static array $mappings = self::MAPPINGS; + + /** @internal */ + public const MAPPINGS = [ 'borderStyle' => [ '0' => Border::BORDER_NONE, '1' => Border::BORDER_THIN, @@ -47,7 +54,7 @@ class Styles '8' => Fill::FILL_PATTERN_DARKVERTICAL, // vertical stripe '9' => Fill::FILL_PATTERN_DARKDOWN, // diagonal stripe '10' => Fill::FILL_PATTERN_DARKUP, // reverse diagonal stripe - '11' => Fill::FILL_PATTERN_DARKGRID, // diagoanl crosshatch + '11' => Fill::FILL_PATTERN_DARKGRID, // diagonal crosshatch '12' => Fill::FILL_PATTERN_DARKTRELLIS, // thick diagonal crosshatch '13' => Fill::FILL_PATTERN_LIGHTHORIZONTAL, '14' => Fill::FILL_PATTERN_LIGHTVERTICAL, @@ -143,6 +150,7 @@ class Styles { $ucDirection = ucfirst($direction); if (isset($srssb->$ucDirection)) { + // https://github.com/phpstan/phpstan/issues/14925 /** @var SimpleXMLElement */ $temp = $srssb->$ucDirection; $styleArray['borders'][$direction] = self::parseBorderAttributes($temp->attributes()); @@ -160,19 +168,19 @@ class Styles return $rotation; } - /** @param mixed[][] $styleArray */ + /** @param array|string> $styleArray */ private static function addStyle(array &$styleArray, string $key, string $value): void { - if (array_key_exists($value, self::$mappings[$key])) { - $styleArray[$key] = self::$mappings[$key][$value]; //* @phpstan-ignore-line + if (array_key_exists($value, self::MAPPINGS[$key])) { + $styleArray[$key] = self::MAPPINGS[$key][$value]; } } /** @param mixed[][] $styleArray */ private static function addStyle2(array &$styleArray, string $key1, string $key, string $value): void { - if (array_key_exists($value, self::$mappings[$key])) { - $styleArray[$key1][$key] = self::$mappings[$key][$value]; + if (array_key_exists($value, self::MAPPINGS[$key])) { + $styleArray[$key1][$key] = self::MAPPINGS[$key][$value]; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Html.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Html.php index 579f389..5135ba7 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Html.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Html.php @@ -2,11 +2,13 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; use DOMAttr; use DOMDocument; use DOMElement; use DOMNode; use DOMText; +use LibXMLError; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -132,6 +134,32 @@ class Html extends BaseReader /** @var array */ protected array $rowspan = []; + /** + * Default setting uses current setting of libxml_use_internal_errors. + * It will probably change to 'true' in a future release. + */ + protected ?bool $suppressLoadWarnings = null; + + /** @var LibXMLError[] */ + protected array $libxmlMessages = []; + + /** + * Suppress load warning messages, keeping them available + * in $this->libxmlMessages(). + */ + public function setSuppressLoadWarnings(?bool $suppressLoadWarnings): self + { + $this->suppressLoadWarnings = $suppressLoadWarnings; + + return $this; + } + + /** @return LibXMLError[] */ + public function getLibxmlMessages(): array + { + return $this->libxmlMessages; + } + /** * Create a new HTML Reader instance. */ @@ -153,7 +181,7 @@ class Html extends BaseReader return false; } - $beginning = preg_replace(self::STARTS_WITH_BOM, '', $this->readBeginning()) ?? ''; + $beginning = Preg::replace(self::STARTS_WITH_BOM, '', $this->readBeginning()); $startWithTag = self::startsWithTag($beginning); $containsTags = self::containsTags($beginning); @@ -175,7 +203,7 @@ class Html extends BaseReader { $meta = stream_get_meta_data($this->fileHandle); // Phpstan incorrectly flags following line for Php8.2-, corrected in 8.3 - $filename = $meta['uri']; //@phpstan-ignore-line + $filename = $meta['uri']; //@phpstan-ignore offsetAccess.notFound (fixable when Php < 8.3 is no longer possible) clearstatcache(true, $filename); $size = (int) filesize($filename); @@ -224,6 +252,8 @@ class Html extends BaseReader * Data Array used for testing only, should write to * Spreadsheet object on completion of tests. * + * @deprecated 5.4.0 No replacement. + * * @var mixed[][] */ protected array $dataArray = []; @@ -261,10 +291,11 @@ class Html extends BaseReader * * @param string[] $attributeArray * - * @param-out string $cellContent In one case, it can be bool + * @param-out string $cellContentx */ - protected function flushCell(Worksheet $sheet, string $column, int|string $row, mixed &$cellContent, array $attributeArray): void + protected function flushCell(Worksheet $sheet, string $column, int|string $row, mixed &$cellContentx, array $attributeArray): void { + $cellContent = $cellContentx; if (is_string($cellContent)) { // Simple String content if (trim($cellContent) > '') { @@ -273,6 +304,10 @@ class Html extends BaseReader // ... we return the cell, so we can mess about with styles more easily // Set cell value explicitly if there is data-type attribute + if (isset($attributeArray['data-checkbox'])) { + $sheet->getStyle($column . $row) + ->setCheckBox(true); + } if (isset($attributeArray['data-type'])) { $datatype = $attributeArray['data-type']; if (in_array($datatype, [DataType::TYPE_STRING, DataType::TYPE_STRING2, DataType::TYPE_INLINE])) { @@ -285,29 +320,65 @@ class Html extends BaseReader } if ($datatype === DataType::TYPE_BOOL) { // This is the case where we can set cellContent to bool rather than string - $cellContent = self::convertBoolean($cellContent); //* @phpstan-ignore-line - if (!is_bool($cellContent)) { - $attributeArray['data-type'] = DataType::TYPE_STRING; + if ($cellContent === '☑') { + $cellContent = true; + $sheet->getStyle($column . $row) + ->setCheckBox(true); + } elseif ($cellContent === '☐') { + $cellContent = false; + $sheet->getStyle($column . $row) + ->setCheckBox(true); + } else { + $cellContent = self::convertBoolean($cellContent); + if (!is_bool($cellContent)) { + $attributeArray['data-type'] = DataType::TYPE_STRING; + } } } //catching the Exception and ignoring the invalid data types + $hyperlink = $sheet->hyperlinkExists($column . $row) ? $sheet->getHyperlink($column . $row) : null; + try { - $sheet->setCellValueExplicit($column . $row, $cellContent, $attributeArray['data-type']); + if (isset($attributeArray['data-formula'])) { + $sheet->setCellValueExplicit( + $column . $row, + $attributeArray['data-formula'], + DataType::TYPE_FORMULA + ); + $sheet->getCell($column . $row) + ->setCalculatedValue( + $cellContent + ); + } else { + $sheet->setCellValueExplicit( + $column . $row, + $attributeArray['data-value'] ?? $cellContent, + $attributeArray['data-type'] + ); + } } catch (SpreadsheetException) { $sheet->setCellValue($column . $row, $cellContent); } + $sheet->setHyperlink($column . $row, $hyperlink); } else { - $sheet->setCellValue($column . $row, $cellContent); + $hyperlink = null; + if ($sheet->hyperlinkExists($column . $row)) { + $hyperlink = $sheet->getHyperlink($column . $row); + } + $sheet->setCellValue($column . $row, $attributeArray['data-value'] ?? $cellContent); + $sheet->setHyperlink($column . $row, $hyperlink); } - $this->dataArray[$row][$column] = $cellContent; + $this->dataArray[$row][$column] = $cellContent; // @phpstan-ignore property.deprecated (fixable when $this.data is removed) } } else { - // We have a Rich Text run + // We have a Rich Text run. + // I don't actually see any way to reach this line. // TODO - $this->dataArray[$row][$column] = 'RICH TEXT: ' . StringHelper::convertToString($cellContent); + // @phpstan-ignore property.deprecated (fixable when $this->data is removed) + $this->dataArray[$row][$column] = 'RICH TEXT: ' . StringHelper::convertToString($cellContent); // @codeCoverageIgnore } - $cellContent = (string) ''; + $cellContentx = ''; } /** @var array> */ @@ -387,7 +458,7 @@ class Html extends BaseReader } if (isset($attributeArray['style'])) { $alignStyle = $attributeArray['style']; - if (preg_match('/\btext-align:\s*(left|right|center|justify)\b/', (string) $alignStyle, $matches) === 1) { + if (Preg::isMatch('/\btext-align:\s*(left|right|center|justify)\b/', (string) $alignStyle, $matches)) { $sheet->getComment($column . $row)->setAlignment($matches[1]); } } @@ -533,6 +604,10 @@ class Html extends BaseReader $sheet->setShowGridlines(in_array('gridlines', $classes, true)); $sheet->setPrintGridlines(in_array('gridlinesp', $classes, true)); } + if (isset($attributeArray['data-printarea'])) { + $sheet->getPageSetup() + ->setPrintArea($attributeArray['data-printarea']); + } if ('rtl' === ($attributeArray['dir'] ?? '')) { $sheet->setRightToLeft(true); } @@ -698,7 +773,7 @@ class Html extends BaseReader { foreach ($element->childNodes as $child) { if ($child instanceof DOMText) { - $domText = (string) preg_replace('/\s+/', ' ', trim($child->nodeValue ?? '')); + $domText = Preg::replace('/\s+/', ' ', trim($child->nodeValue ?? '')); if ($domText === "\u{a0}") { $domText = ''; } @@ -726,12 +801,23 @@ class Html extends BaseReader $dom = new DOMDocument(); // Reload the HTML file into the DOM object + if (is_bool($this->suppressLoadWarnings)) { + $useErrors = libxml_use_internal_errors($this->suppressLoadWarnings); + } else { + $useErrors = null; + } + try { $convert = $this->getSecurityScannerOrThrow()->scanFile($filename); $convert = static::replaceNonAsciiIfNeeded($convert); $loaded = ($convert === null) ? false : $dom->loadHTML($convert); } catch (Throwable $e) { $loaded = false; + } finally { + $this->libxmlMessages = libxml_get_errors(); + if (is_bool($useErrors)) { + libxml_use_internal_errors($useErrors); + } } if ($loaded === false) { throw new Exception('Failed to load file ' . $filename . ' as a DOM Document', 0, $e ?? null); @@ -798,7 +884,7 @@ class Html extends BaseReader break; default: - if (preg_match('/^custom[.](bool|date|float|int|string)[.](.+)$/', $metaName, $matches) === 1) { + if (Preg::isMatch('/^custom[.](bool|date|float|int|string)[.](.+)$/', $metaName, $matches)) { match ($matches[1]) { 'bool' => $properties->setCustomProperty($matches[2], (bool) $metaContent, Properties::PROPERTY_TYPE_BOOLEAN), 'float' => $properties->setCustomProperty($matches[2], (float) $metaContent, Properties::PROPERTY_TYPE_FLOAT), @@ -822,15 +908,15 @@ class Html extends BaseReader return '&#' . mb_ord($matches[0], 'UTF-8') . ';'; } + /** @internal */ protected static function replaceNonAsciiIfNeeded(string $convert): ?string { - if (preg_match(self::STARTS_WITH_BOM, $convert) !== 1 && preg_match(self::DECLARES_CHARSET, $convert) !== 1) { + if (!Preg::isMatch(self::STARTS_WITH_BOM, $convert) && !Preg::isMatch(self::DECLARES_CHARSET, $convert)) { $lowend = "\u{80}"; $highend = "\u{10ffff}"; $regexp = "/[$lowend-$highend]/u"; - /** @var callable $callback */ - $callback = [self::class, 'replaceNonAscii']; - $convert = preg_replace_callback($regexp, $callback, $convert); + // use native preg because of "u" modifier + $convert = preg_replace_callback($regexp, self::replaceNonAscii(...), $convert); } return $convert; @@ -845,12 +931,23 @@ class Html extends BaseReader $dom = new DOMDocument(); // Reload the HTML file into the DOM object + if (is_bool($this->suppressLoadWarnings)) { + $useErrors = libxml_use_internal_errors($this->suppressLoadWarnings); + } else { + $useErrors = null; + } + try { $convert = $this->getSecurityScannerOrThrow()->scan($content); $convert = static::replaceNonAsciiIfNeeded($convert); $loaded = ($convert === null) ? false : $dom->loadHTML($convert); } catch (Throwable $e) { $loaded = false; + } finally { + $this->libxmlMessages = libxml_get_errors(); + if (is_bool($useErrors)) { + libxml_use_internal_errors($useErrors); + } } if ($loaded === false) { throw new Exception('Failed to load content as a DOM Document', 0, $e ?? null); @@ -915,7 +1012,7 @@ class Html extends BaseReader * and only takes 'background-color' and 'color'; property with HEX color * * TODO : - * - Implement to other propertie, such as border + * - Implement to other properties, such as border * * @param string[] $attributeArray */ @@ -1129,7 +1226,7 @@ class Html extends BaseReader $styleArray = self::getStyleArray($attributes); $src = $attributes['src']; - if (substr($src, 0, 5) !== 'data:') { + if (!str_starts_with($src, 'data:')) { $src = urldecode($src); } $width = isset($attributes['width']) ? (float) $attributes['width'] : ($styleArray['width'] ?? null); @@ -1137,7 +1234,7 @@ class Html extends BaseReader $name = $attributes['alt'] ?? null; $drawing = new Drawing(); - $drawing->setPath($src, false, allowExternal: $this->allowExternalImages); + $drawing->setPath($src, false, allowExternal: $this->allowExternalImages, isWhitelisted: $this->isWhitelisted); if ($drawing->getPath() === '') { return; } @@ -1177,6 +1274,11 @@ class Html extends BaseReader $drawing->setOpacity((int) ($opacity * 100000)); } } + /** @var string */ + $transform = $styleArray['transform'] ?? ''; + if (Preg::isMatch('/rotate[(](-?\d{1,3})deg[)]$/', $transform, $matches)) { + $drawing->setRotation((int) $matches[1]); + } } /** @@ -1195,13 +1297,13 @@ class Html extends BaseReader $arrayKey = trim($value[0]); $arrayValue = trim($value[1]); if ($arrayKey === 'width') { - if (substr($arrayValue, -2) === 'px') { + if (str_ends_with($arrayValue, 'px')) { $arrayValue = (string) (((float) substr($arrayValue, 0, -2))); } else { $arrayValue = (new CssDimension($arrayValue))->toUnit(CssDimension::UOM_PIXELS); } } elseif ($arrayKey === 'height') { - if (substr($arrayValue, -2) === 'px') { + if (str_ends_with($arrayValue, 'px')) { $arrayValue = substr($arrayValue, 0, -2); } else { $arrayValue = (new CssDimension($arrayValue))->toUnit(CssDimension::UOM_PIXELS); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/IReader2.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/IReader2.php new file mode 100644 index 0000000..065e7d6 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/IReader2.php @@ -0,0 +1,21 @@ + + */ + public function listWorksheetInfo(string $filename): array; + + /** + * Returns names of the worksheets from a file, + * possibly without parsing the whole file to a Spreadsheet object. + * + * @return string[] + */ + public function listWorksheetNames(string $filename): array; +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods.php index d243f4d..bb4b7b1 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Ods.php @@ -2,11 +2,15 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; +use DateTime; +use DateTimeZone; use DOMAttr; use DOMDocument; use DOMElement; use DOMNode; use DOMText; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Helper\Dimension as HelperDimension; @@ -21,7 +25,13 @@ use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\Alignment; +use PhpOffice\PhpSpreadsheet\Style\Border; +use PhpOffice\PhpSpreadsheet\Style\Borders; +use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Style\Protection; +use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use Throwable; use XMLReader; @@ -31,6 +41,10 @@ class Ods extends BaseReader { const INITIAL_FILE = 'content.xml'; + private ZipArchive $zip; + + private string $filename; + /** * Create a new Ods Reader instance. */ @@ -141,7 +155,14 @@ class Ods extends BaseReader /** * Return worksheet info (Name, Last Column Letter, Last Column Index, Total Rows, Total Columns). * - * @return array + * @return array */ public function listWorksheetInfo(string $filename): array { @@ -187,28 +208,41 @@ class Ods extends BaseReader ]; // Loop through each child node of the table:table element reading - $currCells = 0; + $currRow = 0; do { $xml->read(); if ($xml->name == 'table:table-row' && $xml->nodeType == XMLReader::ELEMENT) { $rowspan = $xml->getAttribute('table:number-rows-repeated'); $rowspan = empty($rowspan) ? 1 : (int) $rowspan; - $tmpInfo['totalRows'] += $rowspan; - $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells); - $currCells = 0; + self::checkRowsRepeated($currRow, $rowspan); + $currRow += $rowspan; + $currCol = 0; // Step into the row $xml->read(); do { $doread = true; if ($xml->name == 'table:table-cell' && $xml->nodeType == XMLReader::ELEMENT) { + $mergeSize = $xml->getAttribute('table:number-columns-repeated'); + $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize; + self::checkColumnsRepeatedInt( + $currCol, + $mergeSize + ); + $currCol += $mergeSize; if (!$xml->isEmptyElement) { - ++$currCells; + $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCol); + $tmpInfo['totalRows'] = $currRow; $xml->next(); $doread = false; } } elseif ($xml->name == 'table:covered-table-cell' && $xml->nodeType == XMLReader::ELEMENT) { $mergeSize = $xml->getAttribute('table:number-columns-repeated'); - $currCells += (int) $mergeSize; + $mergeSize = empty($mergeSize) ? 1 : (int) $mergeSize; + self::checkColumnsRepeatedInt( + $currCol, + $mergeSize + ); + $currCol += (int) $mergeSize; } if ($doread) { $xml->read(); @@ -217,9 +251,8 @@ class Ods extends BaseReader } } while ($xml->name != 'table:table'); - $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells); $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1; - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true); $worksheetInfo[] = $tmpInfo; } } @@ -240,6 +273,53 @@ class Ods extends BaseReader return $this->loadIntoExisting($filename, $spreadsheet); } + /** @var array + */ + private array $allStyles; + + /** @var string[] */ + private array $numberFormats; + + private int $highestDataIndex; + /** * Loads PhpSpreadsheet from file into PhpSpreadsheet instance. */ @@ -247,7 +327,8 @@ class Ods extends BaseReader { File::assertFile($filename, self::INITIAL_FILE); - $zip = new ZipArchive(); + $this->zip = $zip = new ZipArchive(); + $this->filename = $filename; $zip->open($filename); // Meta @@ -257,7 +338,7 @@ class Ods extends BaseReader ->scan($zip->getFromName('meta.xml')) ); if ($xml === false) { - throw new Exception('Unable to read data from {$pFilename}'); + throw new Exception("Unable to read data from {$filename}"); } /** @var array{meta?: string, office?: string, dc?: string} */ @@ -267,34 +348,82 @@ class Ods extends BaseReader // Styles - $dom = new DOMDocument('1.01', 'UTF-8'); - $dom->loadXML( - $this->getSecurityScannerOrThrow() - ->scan($zip->getFromName('styles.xml')) - ); + $this->allStyles = $this->numberFormats = []; + $dom = $this->loadDom('styles.xml', $zip); + $officeNs = (string) $dom->lookupNamespaceUri('office'); + $styleNs = (string) $dom->lookupNamespaceUri('style'); + $fontNs = (string) $dom->lookupNamespaceUri('fo'); + $numberNs = (string) $dom->lookupNamespaceUri('number'); + $tableNs = (string) $dom->lookupNamespaceUri('table'); + $textNs = (string) $dom->lookupNamespaceUri('text'); + $xlinkNs = (string) $dom->lookupNamespaceUri('xlink'); + + $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'styles')->item(0); + $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs); + $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'default-style'); + foreach ($automaticStyles as $automaticStyle) { + $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family'); + if ($styleFamily === 'table-cell') { + $fonts = []; + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) { + $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs); + } + if (!empty($fonts)) { + $spreadsheet->getDefaultStyle() + ->getFont() + ->applyFromArray($fonts); + } + } + } + $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style'); + foreach ($automaticStyles as $automaticStyle) { + $styleName = $automaticStyle->getAttributeNS($styleNs, 'name'); + $styleFamily = $automaticStyle->getAttributeNS($styleNs, 'family'); + if ($styleFamily === 'table-cell') { + $fills = $fonts = []; + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) { + $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs); + } + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) { + $fills = $this->getFillStyles($tableCellProperty, $fontNs); + } + if ($styleName !== '') { + if (!empty($fonts)) { + $this->allStyles[$styleName]['font'] = $fonts; + if ($styleName === 'Default') { + $spreadsheet->getDefaultStyle() + ->getFont() + ->applyFromArray($fonts); + } + } + if (!empty($fills)) { + $this->allStyles[$styleName]['fill'] = $fills; + if ($styleName === 'Default') { + $spreadsheet->getDefaultStyle() + ->getFill() + ->applyFromArray($fills); + } + } + } + } + } + + $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0); + $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs); $pageSettings = new PageSettings($dom); // Main Content - $dom = new DOMDocument('1.01', 'UTF-8'); - $dom->loadXML( - $this->getSecurityScannerOrThrow() - ->scan($zip->getFromName(self::INITIAL_FILE)) - ); - - $officeNs = (string) $dom->lookupNamespaceUri('office'); - $tableNs = (string) $dom->lookupNamespaceUri('table'); - $textNs = (string) $dom->lookupNamespaceUri('text'); - $xlinkNs = (string) $dom->lookupNamespaceUri('xlink'); - $styleNs = (string) $dom->lookupNamespaceUri('style'); + $dom = $this->loadDom(self::INITIAL_FILE, $zip); $pageSettings->readStyleCrossReferences($dom); $autoFilterReader = new AutoFilter($spreadsheet, $tableNs); $definedNameReader = new DefinedNames($spreadsheet, $tableNs); $columnWidths = []; - $automaticStyle0 = $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0); + $automaticStyle0 = $this->readDataOnly ? null : $dom->getElementsByTagNameNS($officeNs, 'automatic-styles')->item(0); + $this->processSomeNumberFormats($automaticStyle0, $numberNs, $styleNs); $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($styleNs, 'style'); foreach ($automaticStyles as $automaticStyle) { $styleName = $automaticStyle->getAttributeNS($styleNs, 'name'); @@ -307,6 +436,46 @@ class Ods extends BaseReader $columnWidths[$styleName] = $columnWidth; } } + if ($styleFamily === 'table-cell') { + $fonts = $fills = $alignment1 = $alignment2 = $protection = $borders = []; + $numberFormatName = $automaticStyle->getAttributeNS($styleNs, 'data-style-name'); + $numberFormat = $this->numberFormats[$numberFormatName] ?? ''; + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'text-properties') as $textProperty) { + $fonts = $this->getFontStyles($textProperty, $styleNs, $fontNs); + } + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) { + $fills = $this->getFillStyles($tableCellProperty, $fontNs); + $borders = $this->getBorderStyles($tableCellProperty, $fontNs, $styleNs); + $protection = $this->getProtectionStyles($tableCellProperty, $styleNs); + } + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'table-cell-properties') as $tableCellProperty) { + $alignment1 = $this->getAlignment1Styles($tableCellProperty, $styleNs, $fontNs); + } + foreach ($automaticStyle->getElementsByTagNameNS($styleNs, 'paragraph-properties') as $paragraphProperty) { + $alignment2 = $this->getAlignment2Styles($paragraphProperty, $styleNs, $fontNs); + } + if ($styleName !== '') { + if (!empty($fonts)) { + $this->allStyles[$styleName]['font'] = $fonts; + } + if (!empty($fills)) { + $this->allStyles[$styleName]['fill'] = $fills; + } + $alignment = array_merge($alignment1, $alignment2); + if (!empty($alignment)) { + $this->allStyles[$styleName]['alignment'] = $alignment; + } + if (!empty($protection)) { + $this->allStyles[$styleName]['protection'] = $protection; + } + if (!empty($borders)) { + $this->allStyles[$styleName]['borders'] = $borders; + } + if ($numberFormat !== '') { + $this->allStyles[$styleName]['numberFormat']['formatCode'] = $numberFormat; + } + } + } } // Content @@ -343,12 +512,14 @@ class Ods extends BaseReader // Use false for $updateFormulaCellReferences to prevent adjustment of worksheet references in // formula cells... during the load, all formulae should be correct, and we're simply // bringing the worksheet name in line with the formula, not the reverse - $spreadsheet->getActiveSheet()->setTitle((string) $worksheetName, false, false); + $spreadsheet->getActiveSheet() + ->setTitle((string) $worksheetName, false, false); } // Go through every child of table element $rowID = 1; $tableColumnIndex = 1; + $this->highestDataIndex = AddressRange::MAX_COLUMN_INT; foreach ($worksheetDataSet->childNodes as $childNode) { /** @var DOMElement $childNode */ @@ -386,37 +557,6 @@ class Ods extends BaseReader $spreadsheet ); - break; - case 'table-header-columns': - case 'table-columns': - $this->processTableHeaderColumns( - $childNode, - $tableNs, - $columnWidths, - $tableColumnIndex, - $spreadsheet - ); - - break; - case 'table-column-group': - $this->processTableColumnGroup( - $childNode, - $tableNs, - $columnWidths, - $tableColumnIndex, - $spreadsheet - ); - - break; - case 'table-column': - $this->processTableColumn( - $childNode, - $tableNs, - $columnWidths, - $tableColumnIndex, - $spreadsheet - ); - break; case 'table-row': $this->processTableRow( @@ -430,6 +570,43 @@ class Ods extends BaseReader $spreadsheet ); + break; + case 'table-header-columns': + case 'table-columns': + $this->processTableColumnHeader( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + $this->readEmptyCells, + true + ); + + break; + case 'table-column-group': + $this->processTableColumnGroup( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + $this->readEmptyCells, + true + ); + + break; + case 'table-column': + $this->processTableColumn( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + $this->readEmptyCells, + true + ); + break; } } @@ -446,10 +623,94 @@ class Ods extends BaseReader if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { $spreadsheet->createSheet(); } + } + + foreach ($spreadsheets as $workbookData) { + /** @var DOMElement $workbookData */ + $tables = $workbookData->getElementsByTagNameNS($tableNs, 'table'); + + $worksheetID = 0; + foreach ($tables as $worksheetDataSet) { + /** @var DOMElement $worksheetDataSet */ + $worksheetName = $worksheetDataSet->getAttributeNS($tableNs, 'name'); + + // Check loadSheetsOnly + if ( + $this->loadSheetsOnly !== null + && $worksheetName + && !in_array($worksheetName, $this->loadSheetsOnly) + ) { + continue; + } + + // Create sheet + $spreadsheet->setActiveSheetIndex($worksheetID); + $highestDataColumn = $spreadsheet->getActiveSheet()->getHighestDataColumn(); + $this->highestDataIndex = Coordinate::columnIndexFromString($highestDataColumn); + + // Go through every child of table element processing column widths + $rowID = 1; + $tableColumnIndex = 1; + foreach ($worksheetDataSet->childNodes as $childNode) { + /** @var DOMElement $childNode */ + if (empty($columnWidths) || $this->readEmptyCells) { + break; + } + + // Filter elements which are not under the "table" ns + if ($childNode->namespaceURI != $tableNs) { + continue; + } + + $key = self::extractNodeName($childNode->nodeName); + + switch ($key) { + case 'table-header-columns': + case 'table-columns': + $this->processTableColumnHeader( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + true, + false + ); + + break; + case 'table-column-group': + $this->processTableColumnGroup( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + true, + false + ); + + break; + case 'table-column': + $this->processTableColumn( + $childNode, + $tableNs, + $columnWidths, + $tableColumnIndex, + $spreadsheet, + true, + false + ); + + break; + } + } + ++$worksheetID; + } $autoFilterReader->read($workbookData); $definedNameReader->read($workbookData); } + $spreadsheet->setActiveSheetIndex(0); if ($zip->locateName('settings.xml') !== false) { @@ -564,6 +825,8 @@ class Ods extends BaseReader } else { $rowRepeats = 1; } + self::checkRowsRepeated($rowID, $rowRepeats); + $worksheet = $spreadsheet->getSheetByName($worksheetName); $columnID = 'A'; /** @var DOMElement|DOMText $cellData */ @@ -571,18 +834,94 @@ class Ods extends BaseReader if ($cellData instanceof DOMText) { continue; // should just be whitespace } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { - if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); + if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { + $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); + } else { + $colRepeats = 1; + } + $columnIndex = Coordinate::columnIndexFromString($columnID); + self::checkColumnsRepeated($columnID, $colRepeats); + $styleName = $cellData->getAttributeNS($tableNs, 'style-name'); + if ($styleName === '') { + if ($worksheet === null || !$worksheet->columnDimensionExists($columnID)) { + $assignedNumberFormat = ''; } else { - $colRepeats = 1; + $colStyle = $worksheet->getColumnDimension($columnID)->getXfIndex() ?? 0; + $assignedNumberFormat = $spreadsheet + ->getCellXfByIndex($colStyle) + ->getNumberFormat()->getFormatCode(); + if ($assignedNumberFormat === NumberFormat::FORMAT_GENERAL) { + $assignedNumberFormat = ''; + } } + } else { + $assignedNumberFormat = $this->allStyles[$styleName]['numberFormat']['formatCode'] ?? ''; + } - for ($i = 0; $i < $colRepeats; ++$i) { + // When a cell has number-columns-repeated, check if ANY column in the + // repeated range passes the read filter. If not, skip the entire group. + // If some columns pass, we need to fall through to the processing block + // which will handle per-column filtering. + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { + if ($colRepeats <= 1) { StringHelper::stringIncrement($columnID); + + continue; } - continue; + // Check if any column within this repeated group passes the filter + $anyColumnPasses = false; + $tempCol = $columnID; + for ($i = 0; $i < $colRepeats; ++$i) { + if ($i > 0) { + StringHelper::stringIncrement($tempCol); + } + if ($this->readFilter->readCell($tempCol, $rowID, $worksheetName)) { + $anyColumnPasses = true; + + break; + } + } + + if (!$anyColumnPasses) { + for ($i = 0; $i < $colRepeats; ++$i) { + StringHelper::stringIncrement($columnID); + } + + continue; + } + // Fall through to process the cell, with per-column filter checks + } + $tempSpannedRange = "$columnID$rowID"; + $spannedRange = ''; + if ($worksheet !== null && ($cellData->hasChildNodes() || ($cellData->nextSibling !== null)) && isset($this->allStyles[$styleName])) { + $spannedRange = "$columnID$rowID"; + // the following is sufficient for ods, + // and does no harm for xlsx/xls. + $worksheet->getStyle($spannedRange) + ->applyFromArray($this->allStyles[$styleName]); + // the rest of this block is needed for xlsx/xls, + // and does no harm for ods. + if (isset($this->allStyles[$styleName]['borders'])) { + $spannedRows = $cellData->getAttributeNS($tableNs, 'number-columns-spanned'); + $spannedColumns = $cellData->getAttributeNS($tableNs, 'number-rows-spanned'); + $spannedRows = max((int) $spannedRows, 1); + $spannedColumns = max((int) $spannedColumns, 1); + if ($spannedRows > 1 || $spannedColumns > 1) { + $endRow = $rowID + $spannedRows - 1; + $endCol = $columnID; + while ($spannedColumns > 1) { + StringHelper::stringIncrement($endCol); + --$spannedColumns; + } + $spannedRange .= ":$endCol$endRow"; + $worksheet->getStyle($spannedRange) + ->getBorders() + ->applyFromArray( + $this->allStyles[$styleName]['borders'] + ); + } + } } // Initialize variables @@ -651,10 +990,13 @@ class Ods extends BaseReader // Filter text:p elements if ($item->nodeName == 'text:p') { $paragraphs[] = $item; + } elseif ($item->nodeName === 'draw:frame' && $worksheet !== null) { + $this->processDrawFrame($spannedRange ?: $tempSpannedRange, $item, $worksheet); } } if (count($paragraphs) > 0) { + $dataValue = null; // Consolidate if there are multiple p records (maybe with spans as well) $dataArray = []; @@ -669,6 +1011,19 @@ class Ods extends BaseReader $allCellDataText = implode("\n", $dataArray); $type = $cellData->getAttributeNS($officeNs, 'value-type'); + $symbol = ''; + $leftHandCurrency = Preg::isMatch('/\$|£|¥/', $allCellDataText, $matches); + if ($leftHandCurrency) { + $type = str_replace('float', 'currency', $type); + $symbol = (string) $matches[0]; + } + $customFormatting = ''; + if ($this->formatCallback !== null) { + $temp = ($this->formatCallback)($type, $allCellDataText); + if ($temp !== '') { + $customFormatting = $temp; + } + } switch ($type) { case 'string': @@ -689,30 +1044,47 @@ class Ods extends BaseReader break; case 'percentage': + if (!str_contains($allCellDataText, '.')) { + $formatting = NumberFormat::FORMAT_PERCENTAGE; + } elseif (substr($allCellDataText, -3, 1) === '.') { + $formatting = NumberFormat::FORMAT_PERCENTAGE_0; + } else { + $formatting = NumberFormat::FORMAT_PERCENTAGE_00; + } $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - // percentage should always be float - //if (floor($dataValue) == $dataValue) { - // $dataValue = (int) $dataValue; - //} - $formatting = NumberFormat::FORMAT_PERCENTAGE_00; - break; case 'currency': $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - if (floor($dataValue) == $dataValue) { - $dataValue = (int) $dataValue; + $currency = $cellData->getAttributeNS($officeNs, 'currency'); + if ($leftHandCurrency) { + $typeValue = 'currency'; + $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER; + if ($symbol !== '$') { + $formatting = str_replace('$', $symbol, $formatting); + } + } elseif (str_contains($allCellDataText, '€')) { + $typeValue = 'currency'; + $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_EUR : NumberFormat::FORMAT_CURRENCY_EUR_INTEGER; } - $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER; break; case 'float': $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); + if ($assignedNumberFormat !== '') { + $formatting = $assignedNumberFormat; + } elseif ($dataValue !== floor($dataValue)) { + // do nothing + } elseif (substr($allCellDataText, -2, 1) === '.') { + $formatting = NumberFormat::FORMAT_NUMBER_0; + } elseif (substr($allCellDataText, -3, 1) === '.') { + $formatting = NumberFormat::FORMAT_NUMBER_00; + } if (floor($dataValue) == $dataValue) { if ($dataValue == (int) $dataValue) { $dataValue = (int) $dataValue; @@ -724,13 +1096,20 @@ class Ods extends BaseReader $type = DataType::TYPE_NUMERIC; $value = $cellData->getAttributeNS($officeNs, 'date-value'); $dataValue = Date::convertIsoDate($value); + $format15 = Preg::isMatch('/\d\d\d\d/', $allCellDataText) ? NumberFormat::FORMAT_DATE_XLSX15_YYYY : NumberFormat::FORMAT_DATE_XLSX15; - if ($dataValue != floor($dataValue)) { - $formatting = NumberFormat::FORMAT_DATE_XLSX15 + if (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d$/', $allCellDataText)) { + $formatting = 'yyyy-mm-dd'; + } elseif (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d \d\d:\d\d(:\d\d)?$/', $allCellDataText)) { + $formatting = NumberFormat::FORMAT_DATE_DATETIME_BETTER; + } elseif (Preg::isMatch('/^\d\d?-[a-zA-Z]+-\d\d\d\d$/', $allCellDataText)) { + $formatting = 'd-mmm-yyyy'; + } elseif ($dataValue != floor($dataValue) || str_contains($allCellDataText, ':')) { + $formatting = $format15 . ' ' . NumberFormat::FORMAT_DATE_TIME4; } else { - $formatting = NumberFormat::FORMAT_DATE_XLSX15; + $formatting = $format15; } break; @@ -738,18 +1117,34 @@ class Ods extends BaseReader $type = DataType::TYPE_NUMERIC; $timeValue = $cellData->getAttributeNS($officeNs, 'time-value'); - - $dataValue = Date::PHPToExcel( - strtotime( - '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? []) - ) - ); - $formatting = NumberFormat::FORMAT_DATE_TIME4; + $minus = ''; + if (str_starts_with($timeValue, '-')) { + $minus = '-'; + $timeValue = substr($timeValue, 1); + } + $timeArray = sscanf($timeValue, 'PT%dH%dM%dS'); + if (is_array($timeArray)) { + /** @var array{int, int, int} $timeArray */ + $days = intdiv($timeArray[0], 24); + $hours = $timeArray[0] % 24; + $dt = new DateTime("1899-12-30 $hours:{$timeArray[1]}:{$timeArray[2]}", new DateTimeZone('UTC')); + $dt->modify("+$days days"); + $dataValue = Date::PHPToExcel($dt); + if ($minus === '-') { + $dataValue *= -1; + $formatting = '[hh]:mm:ss'; + } else { + $formatting = NumberFormat::FORMAT_DATE_TIME4; + } + } break; default: $dataValue = null; } + if ($customFormatting !== '') { + $formatting = $customFormatting; + } } else { $type = DataType::TYPE_NULL; $dataValue = null; @@ -761,59 +1156,55 @@ class Ods extends BaseReader $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula); } - if ($cellData->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $colRepeats = (int) $cellData->getAttributeNS($tableNs, 'number-columns-repeated'); - } else { - $colRepeats = 1; - } + for ($i = 0; $i < $colRepeats; ++$i) { + if ($i > 0) { + StringHelper::stringIncrement($columnID); + } - if ($type !== null) { // @phpstan-ignore-line - for ($i = 0; $i < $colRepeats; ++$i) { - if ($i > 0) { - StringHelper::stringIncrement($columnID); - } + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { + continue; + } - if ($type !== DataType::TYPE_NULL) { - for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { - $rID = $rowID + $rowAdjust; + if ($type !== DataType::TYPE_NULL) { + for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { + $rID = $rowID + $rowAdjust; - $cell = $spreadsheet->getActiveSheet() - ->getCell($columnID . $rID); + $cell = $spreadsheet->getActiveSheet() + ->getCell($columnID . $rID); - // Set value - if ($hasCalculatedValue) { - $cell->setValueExplicit($cellDataFormula, $type); - if ($cellDataType === 'array') { - $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); - } - } elseif ($type !== '' || $dataValue !== null) { - $cell->setValueExplicit($dataValue, $type); + // Set value + if ($hasCalculatedValue) { + $cell->setValueExplicit($cellDataFormula, $type); + if ($cellDataType === 'array') { + $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); } + } elseif ($type !== '' || $dataValue !== null) { + $cell->setValueExplicit($dataValue, $type); + } - if ($hasCalculatedValue) { - $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); - } + if ($hasCalculatedValue) { + $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); + } - // Set other properties - if ($formatting !== null) { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode($formatting); - } else { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode(NumberFormat::FORMAT_GENERAL); - } + // Set other properties + if ($formatting !== null) { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode($formatting); + } else { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_GENERAL); + } - if ($hyperlink !== null) { - if ($hyperlink[0] === '#') { - $hyperlink = 'sheet://' . substr($hyperlink, 1); - } - $cell->getHyperlink() - ->setUrl($hyperlink); + if ($hyperlink !== null) { + if ($hyperlink[0] === '#') { + $hyperlink = 'sheet://' . substr($hyperlink, 1); } + $cell->getHyperlink() + ->setUrl($hyperlink); } } } @@ -827,6 +1218,61 @@ class Ods extends BaseReader $rowID += $rowRepeats; } + private function processDrawFrame(string $spannedRange, DOMElement $item, Worksheet $worksheet): void + { + $drawName = $item->getAttribute('draw:name'); + $svgWidth = $item->getAttribute('svg:width'); + $svgHeight = $item->getAttribute('svg:height'); + $styleName = $item->getAttribute('draw:style-name'); + $drawImage = null; + foreach ($item->childNodes as $node) { + // Check if the node is a standard element tag + if ($node->nodeType === XML_ELEMENT_NODE && $node->nodeName === 'draw:image') { + /** @var DOMElement */ + $drawImage = $node; + + break; + } + } + + $xlinkHref = $xlinkType = $xlinkShow = ''; + if ($drawImage !== null) { + $xlinkHref = $drawImage->getAttribute('xlink:href'); + $xlinkType = $drawImage->getAttribute('xlink:type'); + $xlinkShow = $drawImage->getAttribute('xlink:show'); + } + if ( + $drawName !== '' + && Preg::isMatch('/(\d+([.]\d+)?)(cm|in)/', $svgWidth, $matchWidth) + && Preg::isMatch('/(\d+([.]\d+)?)(cm|in)/', $svgHeight, $matchHeight) + //&& $styleName === 'gr1' + && (str_starts_with($xlinkHref, 'Pictures/') || str_starts_with($xlinkHref, 'media/')) + && $xlinkType === 'simple' + && $xlinkShow === 'embed' + ) { + $drawing = new Drawing(); + $drawing->setPath( + "zip://{$this->filename}#$xlinkHref", + true, + $this->zip, + false + ); + $unit = [ + 'cm' => HelperDimension::ABSOLUTE_UNITS[HelperDimension::UOM_CENTIMETERS], + 'in' => HelperDimension::ABSOLUTE_UNITS[HelperDimension::UOM_INCHES], + ]; + $width = ((float) $matchWidth[1]) * $unit[$matchWidth[3]]; + $height = ((float) $matchHeight[1]) * $unit[$matchHeight[3]]; + if ($drawing->getPath()) { + $drawing->setCoordinates($spannedRange) + ->setWidth((int) $width) + ->setHeight((int) $height) + ->setName($drawName) + ->setWorksheet($worksheet); + } + } + } + private static function extractNodeName(string $key): string { // Remove ns from node name @@ -841,12 +1287,14 @@ class Ods extends BaseReader /** * @param string[] $columnWidths */ - private function processTableHeaderColumns( + private function processTableColumnHeader( DOMElement $childNode, string $tableNs, array $columnWidths, int &$tableColumnIndex, - Spreadsheet $spreadsheet + Spreadsheet $spreadsheet, + bool $processWidths = true, + bool $processStyles = true ): void { foreach ($childNode->childNodes as $grandchildNode) { /** @var DOMElement $grandchildNode */ @@ -858,7 +1306,9 @@ class Ods extends BaseReader $tableNs, $columnWidths, $tableColumnIndex, - $spreadsheet + $spreadsheet, + $processWidths, + $processStyles ); break; @@ -874,7 +1324,9 @@ class Ods extends BaseReader string $tableNs, array $columnWidths, int &$tableColumnIndex, - Spreadsheet $spreadsheet + Spreadsheet $spreadsheet, + bool $processWidths = true, + bool $processStyles = true ): void { foreach ($childNode->childNodes as $grandchildNode) { /** @var DOMElement $grandchildNode */ @@ -886,18 +1338,22 @@ class Ods extends BaseReader $tableNs, $columnWidths, $tableColumnIndex, - $spreadsheet + $spreadsheet, + $processWidths, + $processStyles ); break; case 'table-header-columns': case 'table-columns': - $this->processTableHeaderColumns( + $this->processTableColumnHeader( $grandchildNode, $tableNs, $columnWidths, $tableColumnIndex, - $spreadsheet + $spreadsheet, + $processWidths, + $processStyles ); break; @@ -907,7 +1363,9 @@ class Ods extends BaseReader $tableNs, $columnWidths, $tableColumnIndex, - $spreadsheet + $spreadsheet, + $processWidths, + $processStyles ); break; @@ -923,35 +1381,61 @@ class Ods extends BaseReader string $tableNs, array $columnWidths, int &$tableColumnIndex, - Spreadsheet $spreadsheet + Spreadsheet $spreadsheet, + bool $processWidths = true, + bool $processStyles = true ): void { if ($childNode->hasAttributeNS($tableNs, 'number-columns-repeated')) { - $rowRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated'); + $colRepeats = (int) $childNode->getAttributeNS($tableNs, 'number-columns-repeated'); } else { - $rowRepeats = 1; + $colRepeats = 1; } + // called routine expects index to be 1 less than it is + self::checkColumnsRepeatedInt($tableColumnIndex - 1, $colRepeats); $tableStyleName = $childNode->getAttributeNS($tableNs, 'style-name'); - if (isset($columnWidths[$tableStyleName])) { - $columnWidth = new HelperDimension($columnWidths[$tableStyleName]); - $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex); - for ($rowRepeats2 = $rowRepeats; $rowRepeats2 > 0; --$rowRepeats2) { - /** @var string $tableColumnString */ - $spreadsheet->getActiveSheet() - ->getColumnDimension($tableColumnString) - ->setWidth($columnWidth->toUnit('cm'), 'cm'); - StringHelper::stringIncrement($tableColumnString); + if ($processWidths) { + if (isset($columnWidths[$tableStyleName])) { + $columnWidth = new HelperDimension($columnWidths[$tableStyleName]); + $tableColumnIndex2 = $tableColumnIndex; + $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2); + for ($colRepeats2 = $colRepeats; $colRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$colRepeats2) { + if (!$this->readEmptyCells && $tableColumnIndex2 > $this->highestDataIndex) { + break; + } + $spreadsheet->getActiveSheet() + ->getColumnDimension($tableColumnString) + ->setWidth($columnWidth->toUnit('cm'), 'cm'); + StringHelper::stringIncrement( + $tableColumnString + ); + ++$tableColumnIndex2; + } } } - $tableColumnIndex += $rowRepeats; + if ($processStyles) { + $defaultStyleName = $childNode->getAttributeNS($tableNs, 'default-cell-style-name'); + if ($defaultStyleName !== 'Default' && isset($this->allStyles[$defaultStyleName])) { + $tableColumnIndex2 = $tableColumnIndex; + $tableColumnString = Coordinate::stringFromColumnIndex($tableColumnIndex2); + for ($colRepeats2 = $colRepeats; $colRepeats2 > 0 && $tableColumnIndex2 <= AddressRange::MAX_COLUMN_INT; --$colRepeats2) { + $spreadsheet->getActiveSheet() + ->getStyle($tableColumnString) + ->applyFromArray( + $this->allStyles[$defaultStyleName] + ); + StringHelper::stringIncrement( + $tableColumnString + ); + ++$tableColumnIndex2; + } + } + } + $tableColumnIndex += $colRepeats; } private function processSettings(ZipArchive $zip, Spreadsheet $spreadsheet): void { - $dom = new DOMDocument('1.01', 'UTF-8'); - $dom->loadXML( - $this->getSecurityScannerOrThrow() - ->scan($zip->getFromName('settings.xml')) - ); + $dom = $this->loadDom('settings.xml', $zip); $configNs = (string) $dom->lookupNamespaceUri('config'); $officeNs = (string) $dom->lookupNamespaceUri('office'); $settings = $dom->getElementsByTagNameNS($officeNs, 'settings') @@ -1097,4 +1581,346 @@ class Ods extends BaseReader } } } + + /** @var null|callable(string, string):string format callback routine */ + private $formatCallback; + + /** @param callable(string, string):string $formatCallback format callback routine */ + public function setFormatCallback(callable $formatCallback): void + { + $this->formatCallback = $formatCallback; + } + + /** @return array{ + * autoColor?: true, + * bold?: true, + * color?: array{rgb: string}, + * italic?: true, + * name?: non-empty-string, + * size?: float|int, + * strikethrough?: true, + * underline?: 'double'|'single', + * } + */ + protected function getFontStyles(DOMElement $textProperty, string $styleNs, string $fontNs): array + { + $fonts = []; + $temp = $textProperty->getAttributeNs($styleNs, 'font-name') ?: $textProperty->getAttributeNs($fontNs, 'font-family'); + if ($temp !== '') { + $fonts['name'] = $temp; + } + $temp = $textProperty->getAttributeNs($fontNs, 'font-size'); + if ($temp !== '' && str_ends_with($temp, 'pt')) { + $fonts['size'] = (float) substr($temp, 0, -2); + } + $temp = $textProperty->getAttributeNs($fontNs, 'font-style'); + if ($temp === 'italic') { + $fonts['italic'] = true; + } + $temp = $textProperty->getAttributeNs($fontNs, 'font-weight'); + if ($temp === 'bold') { + $fonts['bold'] = true; + } + $temp = $textProperty->getAttributeNs($fontNs, 'color'); + if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) { + $fonts['color'] = ['rgb' => substr($temp, 1)]; + } + $temp = $textProperty->getAttributeNs($styleNs, 'use-window-font-color'); + if ($temp === 'true') { + $fonts['autoColor'] = true; + } + $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-type'); + if ($temp === '') { + $temp = $textProperty->getAttributeNs($styleNs, 'text-underline-style'); + if ($temp !== '' && $temp !== 'none') { + $temp = 'single'; + } + } + if ($temp === 'single' || $temp === 'double') { + $fonts['underline'] = $temp; + } + $temp = $textProperty->getAttributeNs($styleNs, 'text-line-through-type'); + if ($temp !== '' && $temp !== 'none') { + $fonts['strikethrough'] = true; + } + + return $fonts; + } + + /** @return array{ + * fillType?: string, + * startColor?: array{rgb: string}, + * } + */ + protected function getFillStyles(DOMElement $tableCellProperties, string $fontNs): array + { + $fills = []; + $temp = $tableCellProperties->getAttributeNs($fontNs, 'background-color'); + if (Preg::isMatch('/^#[a-f0-9]{6}$/i', $temp)) { + $fills['fillType'] = Fill::FILL_SOLID; + $fills['startColor'] = ['rgb' => substr($temp, 1)]; + } elseif ($temp === 'transparent') { + $fills['fillType'] = Fill::FILL_NONE; + } + + return $fills; + } + + private const MAP_VERTICAL = [ + 'top' => Alignment::VERTICAL_TOP, + 'middle' => Alignment::VERTICAL_CENTER, + 'automatic' => Alignment::VERTICAL_JUSTIFY, + 'bottom' => Alignment::VERTICAL_BOTTOM, + ]; + private const MAP_HORIZONTAL = [ + 'center' => Alignment::HORIZONTAL_CENTER, + 'end' => Alignment::HORIZONTAL_RIGHT, + 'justify' => Alignment::HORIZONTAL_FILL, + 'start' => Alignment::HORIZONTAL_LEFT, + ]; + + /** @return array{ + * shrinkToFit?: bool, + * textRotation?: int, + * vertical?: string, + * wrapText?: bool, + * } + */ + protected function getAlignment1Styles(DOMElement $tableCellProperties, string $styleNs, string $fontNs): array + { + $alignment1 = []; + $temp = $tableCellProperties->getAttributeNs($styleNs, 'rotation-angle'); + if (is_numeric($temp)) { + $temp2 = (int) $temp; + if ($temp2 > 90) { + $temp2 -= 360; + } + if ($temp2 >= -90 && $temp2 <= 90) { + $alignment1['textRotation'] = (int) $temp2; + } + } + $temp = $tableCellProperties->getAttributeNs($styleNs, 'vertical-align'); + $temp2 = self::MAP_VERTICAL[$temp] ?? ''; + if ($temp2 !== '') { + $alignment1['vertical'] = $temp2; + } + $temp = $tableCellProperties->getAttributeNs($fontNs, 'wrap-option'); + if ($temp === 'wrap') { + $alignment1['wrapText'] = true; + } elseif ($temp === 'no-wrap') { + $alignment1['wrapText'] = false; + } + $temp = $tableCellProperties->getAttributeNs($styleNs, 'shrink-to-fit'); + if ($temp === 'true' || $temp === 'false') { + $alignment1['shrinkToFit'] = $temp === 'true'; + } + + return $alignment1; + } + + /** @return array{ + * horizontal?: string, + * readOrder?: int, + * indent?: int, + * } + */ + protected function getAlignment2Styles(DOMElement $paragraphProperties, string $styleNs, string $fontNs): array + { + $alignment2 = []; + $temp = $paragraphProperties->getAttributeNs($fontNs, 'text-align'); + $temp2 = self::MAP_HORIZONTAL[$temp] ?? ''; + if ($temp2 !== '') { + $alignment2['horizontal'] = $temp2; + } + $temp = $paragraphProperties->getAttributeNs($fontNs, 'margin-left') ?: $paragraphProperties->getAttributeNs($fontNs, 'margin-right'); + if (Preg::isMatch('/^\d+([.]\d+)?(cm|in|mm|pt)$/', $temp)) { + $dimension = new HelperDimension($temp); + $alignment2['indent'] = (int) round($dimension->toUnit('px') / Alignment::INDENT_UNITS_TO_PIXELS); + } + + $temp = $paragraphProperties->getAttributeNs($styleNs, 'writing-mode'); + if ($temp === 'rl-tb') { + $alignment2['readOrder'] = Alignment::READORDER_RTL; + } elseif ($temp === 'lr-tb') { + $alignment2['readOrder'] = Alignment::READORDER_LTR; + } + + return $alignment2; + } + + /** @return array{ + * locked?: string, + * hidden?: string, + * } + */ + protected function getProtectionStyles(DOMElement $tableCellProperties, string $styleNs): array + { + $protection = []; + $temp = $tableCellProperties->getAttributeNs($styleNs, 'cell-protect'); + switch ($temp) { + case 'protected formula-hidden': + $protection['locked'] = Protection::PROTECTION_PROTECTED; + $protection['hidden'] = Protection::PROTECTION_PROTECTED; + + break; + case 'formula-hidden': + $protection['locked'] = Protection::PROTECTION_UNPROTECTED; + $protection['hidden'] = Protection::PROTECTION_PROTECTED; + + break; + case 'protected': + $protection['locked'] = Protection::PROTECTION_PROTECTED; + $protection['hidden'] = Protection::PROTECTION_UNPROTECTED; + + break; + case 'none': + $protection['locked'] = Protection::PROTECTION_UNPROTECTED; + $protection['hidden'] = Protection::PROTECTION_UNPROTECTED; + + break; + } + + return $protection; + } + + private const MAP_BORDER_STYLE = [ // default BORDER_THIN + 'none' => Border::BORDER_NONE, + 'hidden' => Border::BORDER_NONE, + 'dotted' => Border::BORDER_DOTTED, + 'dash-dot' => Border::BORDER_DASHDOT, + 'dash-dot-dot' => Border::BORDER_DASHDOTDOT, + 'dashed' => Border::BORDER_DASHED, + 'double' => Border::BORDER_DOUBLE, + ]; + + private const MAP_BORDER_MEDIUM = [ + Border::BORDER_THIN => Border::BORDER_MEDIUM, + Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT, + Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT, + Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED, + ]; + + private const MAP_BORDER_THICK = [ + Border::BORDER_THIN => Border::BORDER_THICK, + Border::BORDER_DASHDOT => Border::BORDER_MEDIUMDASHDOT, + Border::BORDER_DASHDOTDOT => Border::BORDER_MEDIUMDASHDOTDOT, + Border::BORDER_DASHED => Border::BORDER_MEDIUMDASHED, + ]; + + /** @return array{ + * bottom?: array{borderStyle:string, color:array{rgb: string}}, + * top?: array{borderStyle:string, color:array{rgb: string}}, + * left?: array{borderStyle:string, color:array{rgb: string}}, + * right?: array{borderStyle:string, color:array{rgb: string}}, + * diagonal?: array{borderStyle:string, color:array{rgb: string}}, + * diagonalDirection?: int, + * } + */ + protected function getBorderStyles(DOMElement $tableCellProperties, string $fontNs, string $styleNs): array + { + $borders = []; + $temp = $tableCellProperties->getAttributeNs($fontNs, 'border'); + $diagonalIndex = Borders::DIAGONAL_NONE; + foreach (['bottom', 'left', 'right', 'top', 'diagonal-tl-br', 'diagonal-bl-tr'] as $direction) { + if ($direction === 'diagonal-tl-br' || $direction === 'diagonal-bl-tr') { + $directionIndex = 'diagonal'; + $temp = $tableCellProperties->getAttributeNs($styleNs, $direction); + } else { + $directionIndex = $direction; + $temp = $tableCellProperties->getAttributeNs($fontNs, "border-$direction"); + } + if (Preg::isMatch('/^(\d+(?:[.]\d+)?)pt\s+([-\w]+)\s+#([0-9a-fA-F]{6})$/', $temp, $matches)) { + $style = self::MAP_BORDER_STYLE[$matches[2]] ?? Border::BORDER_THIN; + $width = (float) $matches[1]; + if ($width >= 2.5) { + $style = self::MAP_BORDER_THICK[$style] ?? $style; + } elseif ($width >= 1.75) { + $style = self::MAP_BORDER_MEDIUM[$style] ?? $style; + } + $color = $matches[3]; + $borders[$directionIndex] = ['borderStyle' => $style, 'color' => ['rgb' => $matches[3]]]; + if ($direction === 'diagonal-tl-br') { + $diagonalIndex = Borders::DIAGONAL_DOWN; + } elseif ($direction === 'diagonal-bl-tr') { + $diagonalIndex = ($diagonalIndex === Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_UP : Borders::DIAGONAL_BOTH; + } + } + } + if ($diagonalIndex !== Borders::DIAGONAL_NONE) { + $borders['diagonalDirection'] = $diagonalIndex; + } + + return $borders; + } + + protected function processSomeNumberFormats(?DOMElement $automaticStyle0, string $numberNs, string $styleNs): void + { + $automaticStyles = ($automaticStyle0 === null) ? [] : $automaticStyle0->getElementsByTagNameNS($numberNs, 'number-style'); + foreach ($automaticStyles as $automaticStyle) { + $this->processNumberNumber($automaticStyle, $numberNs, $styleNs); + } + } + + protected function processNumberNumber(DOMElement $automaticStyle, string $numberNs, string $styleNs): void + { + $styleName = $automaticStyle->getAttributeNS($styleNs, 'name'); + foreach ($automaticStyle->getElementsByTagNameNS($numberNs, 'number') as $numberNumber) { + $decimalPlaces = $numberNumber->getAttributeNs($numberNs, 'decimal-places'); + $minIntegerDigits = (int) $numberNumber->getAttributeNs($numberNs, 'min-integer-digits'); + if ($decimalPlaces === '0' && $minIntegerDigits > 1) { + $this->numberFormats[$styleName] = str_repeat('0', $minIntegerDigits); + } + } + } + + private static function checkRowsRepeated(int $rowID, int $rowRepeats): void + { + if ($rowRepeats < 1 || $rowID + $rowRepeats - 1 > AddressRange::MAX_ROW) { + throw new Exception("Invalid number-rows-repeated $rowRepeats following row $rowID"); + } + } + + private static function checkColumnsRepeated(string $colID, int $colRepeats): void + { + $colIndex = Coordinate::columnIndexFromString($colID); + if ($colRepeats < 1 || $colIndex + $colRepeats - 1 > AddressRange::MAX_COLUMN_INT) { + throw new Exception("Invalid number-columns-repeated $colRepeats following column $colID"); + } + } + + private static function checkColumnsRepeatedInt(int $colIndex, int $colRepeats): void + { + // We don't have column string at this point, + // and colIndex is actually 1 less than it should be. + if ($colRepeats < 1 || $colIndex + $colRepeats > AddressRange::MAX_COLUMN_INT) { + throw new Exception("Invalid number-columns-repeated $colRepeats following column index $colIndex"); + } + } + + private function loadDom(string $file, ZipArchive $zip): DOMDocument + { + $dom = new DOMDocument('1.01', 'UTF-8'); + $orig = false; + + try { + $orig = libxml_use_internal_errors(true); + $result = $dom->loadXML( + $this->getSecurityScannerOrThrow() + ->scan($zip->getFromName($file)) + ); + if ($result === false) { + $fatal = false; + foreach (libxml_get_errors() as $err) { + if ($err->level === LIBXML_ERR_FATAL) { + throw new Exception($err->message); + } + } + } + } finally { + libxml_clear_errors(); + libxml_use_internal_errors($orig); + } + + return $dom; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php index e4da44f..5d238a8 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Security/XmlScanner.php @@ -56,6 +56,11 @@ class XmlScanner throw new Reader\Exception('UTF-7 encoding not permitted'); } if (substr($xml, 0, Reader\Csv::UTF8_BOM_LEN) === Reader\Csv::UTF8_BOM) { + if (preg_match(self::ENCODING_PATTERN, $xml, $matches) === 1) { + if (strtolower($matches[2]) !== 'utf-8') { + throw new Reader\Exception("BOM says UTF-8 but encoding says {$matches[2]}"); + } + } $xml = substr($xml, Reader\Csv::UTF8_BOM_LEN); } @@ -64,7 +69,7 @@ class XmlScanner private function findCharSet(string $xml): string { - if (substr($xml, 0, 4) === "\x4c\x6f\xa7\x94") { + if (str_starts_with($xml, "\x4c\x6f\xa7\x94")) { throw new Reader\Exception('EBCDIC encoding not permitted'); } $encoding = Reader\Csv::guessEncodingBom('', $xml); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Slk.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Slk.php index 73d3738..f0e8189 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Slk.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Slk.php @@ -134,7 +134,7 @@ class Slk extends BaseReader $worksheetInfo[0]['lastColumnIndex'] = $columnIndex; $worksheetInfo[0]['totalRows'] = $rowIndex; - $worksheetInfo[0]['lastColumnLetter'] = Coordinate::stringFromColumnIndex($worksheetInfo[0]['lastColumnIndex'] + 1); + $worksheetInfo[0]['lastColumnLetter'] = Coordinate::stringFromColumnIndex($worksheetInfo[0]['lastColumnIndex'] + 1, true); $worksheetInfo[0]['totalColumns'] = $worksheetInfo[0]['lastColumnIndex'] + 1; $worksheetInfo[0]['sheetState'] = Worksheet::SHEETSTATE_VISIBLE; @@ -371,7 +371,7 @@ class Slk extends BaseReader if (array_key_exists($char, self::STYLE_SETTINGS_FONT)) { $styleData['font'][self::STYLE_SETTINGS_FONT[$char]] = true; } elseif (array_key_exists($char, self::STYLE_SETTINGS_BORDER)) { - $styleData['borders'][self::STYLE_SETTINGS_BORDER[$char]]['borderStyle'] = Border::BORDER_THIN; //* @phpstan-ignore-line + $styleData['borders'][self::STYLE_SETTINGS_BORDER[$char]]['borderStyle'] = Border::BORDER_THIN; //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how to fix this) } elseif ($char == 'S') { $styleData['fill']['fillType'] = Fill::FILL_PATTERN_GRAY125; } elseif ($char == 'M') { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls.php index 09a2b77..9c9397d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Cell\DataValidation; @@ -97,6 +99,12 @@ class Xls extends XlsBase */ protected Worksheet $phpSheet; + /** + * Cached sheet title for the current sheet being parsed. + * Avoids repeated getTitle() calls in per-cell read filter checks. + */ + protected string $phpSheetTitle = ''; + /** * BIFF version. */ @@ -154,7 +162,7 @@ class Xls extends XlsBase /** * Defined names. * - * @var array{isBuiltInName: int, name: string, formula: string, scope: int} + * @var array */ protected array $definedname; @@ -378,7 +386,7 @@ class Xls extends XlsBase // get excel data, $ole->read($filename); // Get workbook data: workbook stream + sheet streams - $this->data = $ole->getStream($ole->wrkbook); // @phpstan-ignore-line + $this->data = $ole->getStream($ole->wrkbook) ?? ''; // Get summary information data $this->summaryInformation = $ole->getStream($ole->summaryInformation); // Get additional document summary information data @@ -709,7 +717,7 @@ class Xls extends XlsBase ]; } else { $extension = false; - if ($cellAddress == '$B$65536') { + if ($cellAddress === '$B$' . AddressRange::MAX_ROW_XLS) { // If the address row is -1 and the column is 0, (which translates as $B$65536) then this is a continuation // note from the previous cell annotation. We're not yet handling this, so annotations longer than the // max 2048 bytes will probably throw a wobbly. @@ -1942,7 +1950,7 @@ class Xls extends XlsBase $nm = self::getInt4d($recordData, 4); $pos += 4; - // look up limit position + // look up limit position (last splice offset where pos fits) foreach ($spliceOffsets as $spliceOffset) { // it can happen that the string is empty, therefore we need // <= and not just < @@ -1990,7 +1998,7 @@ class Xls extends XlsBase // expected byte length of character array if not split $len = ($isCompressed) ? $numChars : $numChars * 2; - // look up limit position - Check it again to be sure that no error occurs when parsing SST structure + // look up limit position - find the first splice offset at or beyond current pos $limitpos = null; foreach ($spliceOffsets as $spliceOffset) { // it can happen that the string is empty, therefore we need @@ -2023,7 +2031,7 @@ class Xls extends XlsBase // keep reading the characters while ($charsLeft > 0) { - // look up next limit position, in case the string span more than one continue record + // look up next limit position, in case the string spans more than one continue record foreach ($spliceOffsets as $spliceOffset) { if ($pos < $spliceOffset) { $limitpos = $spliceOffset; @@ -2055,25 +2063,19 @@ class Xls extends XlsBase $retstr .= substr($recordData, $pos, $len); $charsLeft -= $len / 2; $isCompressed = false; - } elseif (!$isCompressed && ($option == 0)) { + } elseif (!$isCompressed /*&& ($option == 0)*/) { // 1st fragment uncompressed // this fragment compressed - $len = min($charsLeft, $limitpos - $pos); - for ($j = 0; $j < $len; ++$j) { - $retstr .= $recordData[$pos + $j] - . chr(0); - } + $len = (int) min($charsLeft, $limitpos - $pos); + // Pad each byte with a null byte to expand to UTF-16LE + $retstr .= chunk_split(substr($recordData, $pos, $len), 1, "\x00"); $charsLeft -= $len; $isCompressed = false; } else { // 1st fragment compressed // this fragment uncompressed - $newstr = ''; - $jMax = strlen($retstr); - for ($j = 0; $j < $jMax; ++$j) { - $newstr .= $retstr[$j] . chr(0); - } - $retstr = $newstr; + // Pad existing compressed string bytes with null bytes + $retstr = chunk_split($retstr, 1, "\x00"); /** @var int */ $len = min($charsLeft * 2, $limitpos - $pos); $retstr .= substr($recordData, $pos, $len); @@ -2608,7 +2610,7 @@ class Xls extends XlsBase // offset: 10; size: 2; not used for ($i = $firstColumnIndex + 1; $i <= $lastColumnIndex + 1; ++$i) { - if ($lastColumnIndex == 255 || $lastColumnIndex == 256) { + if ($lastColumnIndex == AddressRange::MAX_COLUMN_INT_XLS - 1 || $lastColumnIndex == AddressRange::MAX_COLUMN_INT) { $this->phpSheet->getDefaultColumnDimension()->setWidth($width / 256); break; @@ -2724,9 +2726,10 @@ class Xls extends XlsBase // offset: 2; size: 2; index to column $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -2734,10 +2737,10 @@ class Xls extends XlsBase $rknum = self::getInt4d($recordData, 6); $numValue = self::getIEEE754($rknum); - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($cellCoordinate); if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { // add style information - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); } // add cell @@ -2768,22 +2771,28 @@ class Xls extends XlsBase // offset: 2; size: 2; index to column $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); - $cell = null; // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); // offset: 6; size: 4; index to SST record $index = self::getInt4d($recordData, 6); + // cache SST entry locally to avoid repeated array lookups + $sstValue = $this->sst[$index]['value']; + $fmtRuns = $this->sst[$index]['fmtRuns']; + // add cell - if (($fmtRuns = $this->sst[$index]['fmtRuns']) && !$this->readDataOnly) { + if ($fmtRuns && !$this->readDataOnly) { // then we should treat as rich text $richText = new RichText(); $charPos = 0; - $sstCount = count($this->sst[$index]['fmtRuns']); + $sstCount = count($fmtRuns); + $sstValueLength = StringHelper::countCharacters($sstValue); + $lastFontIndex = count($this->objFonts) - 1; for ($i = 0; $i <= $sstCount; ++$i) { /** @var mixed[][] $fmtRuns */ if (isset($fmtRuns[$i])) { @@ -2791,10 +2800,10 @@ class Xls extends XlsBase $temp = $fmtRuns[$i]; $temp = $temp['charPos']; /** @var int $charPos */ - $text = StringHelper::substring($this->sst[$index]['value'], $charPos, $temp - $charPos); + $text = StringHelper::substring($sstValue, $charPos, $temp - $charPos); $charPos = $temp; } else { - $text = StringHelper::substring($this->sst[$index]['value'], $charPos, StringHelper::countCharacters($this->sst[$index]['value'])); + $text = StringHelper::substring($sstValue, $charPos, $sstValueLength); } if (StringHelper::countCharacters($text) > 0) { @@ -2813,8 +2822,8 @@ class Xls extends XlsBase $temp = $fmtRuns[$i - 1]['fontIndex']; $fontIndex = $temp - 1; } - if (array_key_exists($fontIndex, $this->objFonts) === false) { - $fontIndex = count($this->objFonts) - 1; + if ($fontIndex > $lastFontIndex) { + $fontIndex = $lastFontIndex; } $textRun->setFont(clone $this->objFonts[$fontIndex]); } @@ -2822,20 +2831,21 @@ class Xls extends XlsBase } } if ($this->readEmptyCells || trim($richText->getPlainText()) !== '') { - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($cellCoordinate); + if (isset($this->mapCellXfIndex[$xfIndex])) { + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); + } $cell->setValueExplicit($richText, DataType::TYPE_STRING); } } else { - if ($this->readEmptyCells || trim($this->sst[$index]['value']) !== '') { - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); - $cell->setValueExplicit($this->sst[$index]['value'], DataType::TYPE_STRING); + if ($this->readEmptyCells || trim($sstValue) !== '') { + $cell = $this->phpSheet->getCell($cellCoordinate); + if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); + } + $cell->setValueExplicit($sstValue, DataType::TYPE_STRING); } } - - if (!$this->readDataOnly && $cell !== null && isset($this->mapCellXfIndex[$xfIndex])) { - // add style information - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); - } } } @@ -2868,20 +2878,21 @@ class Xls extends XlsBase // offset within record data $offset = 4; + $rowIndex = $row + 1; for ($i = 1; $i <= $columns; ++$i) { $columnString = Coordinate::stringFromColumnIndex($colFirst + $i); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $rowIndex, $this->phpSheetTitle)) { // offset: var; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, $offset); // offset: var; size: 4; RK value $numValue = self::getIEEE754(self::getInt4d($recordData, $offset + 2)); - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($columnString . $rowIndex); if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { // add style - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); } // add cell value @@ -2914,18 +2925,19 @@ class Xls extends XlsBase // offset: 2; size 2; index to column $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { // offset 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); $numValue = self::extractNumber(substr($recordData, 6, 8)); - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($cellCoordinate); if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { // add cell style - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); } // add cell value @@ -2955,6 +2967,7 @@ class Xls extends XlsBase // offset: 2; size: 2; col index $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); // offset: 20: size: variable; formula structure $formulaStructure = substr($recordData, 20); @@ -2982,10 +2995,10 @@ class Xls extends XlsBase } // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { if ($isPartOfSharedFormula) { // formula is added to this cell after the sheet has been read - $this->sharedFormulaParts[$columnString . ($row + 1)] = $this->baseCell; + $this->sharedFormulaParts[$cellCoordinate] = $this->baseCell; } // offset: 16: size: 4; not used @@ -2994,7 +3007,9 @@ class Xls extends XlsBase $xfIndex = self::getUInt2d($recordData, 4); // offset: 6; size: 8; result of the formula - if ((ord($recordData[6]) == 0) && (ord($recordData[12]) == 255) && (ord($recordData[13]) == 255)) { + $resultType = ord($recordData[6]); + $isSpecialResult = (ord($recordData[12]) == 255) && (ord($recordData[13]) == 255); + if (($resultType == 0) && $isSpecialResult) { // String formula. Result follows in appended STRING record $dataType = DataType::TYPE_STRING; @@ -3006,27 +3021,15 @@ class Xls extends XlsBase // read STRING record $value = $this->readString(); - } elseif ( - (ord($recordData[6]) == 1) - && (ord($recordData[12]) == 255) - && (ord($recordData[13]) == 255) - ) { + } elseif (($resultType == 1) && $isSpecialResult) { // Boolean formula. Result is in +2; 0=false, 1=true $dataType = DataType::TYPE_BOOL; $value = (bool) ord($recordData[8]); - } elseif ( - (ord($recordData[6]) == 2) - && (ord($recordData[12]) == 255) - && (ord($recordData[13]) == 255) - ) { + } elseif (($resultType == 2) && $isSpecialResult) { // Error formula. Error code is in +2 $dataType = DataType::TYPE_ERROR; $value = Xls\ErrorCode::lookup(ord($recordData[8])); - } elseif ( - (ord($recordData[6]) == 3) - && (ord($recordData[12]) == 255) - && (ord($recordData[13]) == 255) - ) { + } elseif (($resultType == 3) && $isSpecialResult) { // Formula result is a null string $dataType = DataType::TYPE_NULL; $value = ''; @@ -3036,10 +3039,12 @@ class Xls extends XlsBase $value = self::extractNumber(substr($recordData, 6, 8)); } - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($cellCoordinate); if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { - // add cell style - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + // add cell style; skipping the collection update is safe here + // because every path below ends in setCalculatedValue(), which + // performs the update + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); } // store the formula @@ -3146,9 +3151,10 @@ class Xls extends XlsBase // offset: 2; size: 2; column index $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { // offset: 4; size: 2; index to XF record $xfIndex = self::getUInt2d($recordData, 4); @@ -3158,7 +3164,12 @@ class Xls extends XlsBase // offset: 7; size: 1; 0=boolean; 1=error $isError = ord($recordData[7]); - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); + $cell = $this->phpSheet->getCell($cellCoordinate); + if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { + // add cell style; a value write does not follow on every path, + // so use the updating setter to guarantee persistence + $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + } switch ($isError) { case 0: // boolean $value = (bool) $boolErr; @@ -3175,11 +3186,6 @@ class Xls extends XlsBase break; } - - if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { - // add cell style - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); - } } } @@ -3208,14 +3214,17 @@ class Xls extends XlsBase // offset: 4; size: 2 x nc; list of indexes to XF records // add style information if (!$this->readDataOnly && $this->readEmptyCells) { + $rowIndex = $row + 1; for ($i = 0; $i < $length / 2 - 3; ++$i) { $columnString = Coordinate::stringFromColumnIndex($fc + $i + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $rowIndex, $this->phpSheetTitle)) { $xfIndex = self::getUInt2d($recordData, 4 + 2 * $i); if (isset($this->mapCellXfIndex[$xfIndex])) { - $this->phpSheet->getCell($columnString . ($row + 1))->setXfIndex($this->mapCellXfIndex[$xfIndex]); + // blank cells never receive a value write, so use the + // updating setter to guarantee persistence + $this->phpSheet->getCell($columnString . $rowIndex)->setXfIndex($this->mapCellXfIndex[$xfIndex]); } } } @@ -3248,9 +3257,10 @@ class Xls extends XlsBase // offset: 2; size: 2; index to column $column = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($column + 1); + $cellCoordinate = $columnString . ($row + 1); // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $row + 1, $this->phpSheetTitle)) { // offset: 4; size: 2; XF index $xfIndex = self::getUInt2d($recordData, 4); @@ -3265,13 +3275,12 @@ class Xls extends XlsBase } /** @var string $value */ if ($this->readEmptyCells || trim($value) !== '') { - $cell = $this->phpSheet->getCell($columnString . ($row + 1)); - $cell->setValueExplicit($value, DataType::TYPE_STRING); - + $cell = $this->phpSheet->getCell($cellCoordinate); if (!$this->readDataOnly && isset($this->mapCellXfIndex[$xfIndex])) { // add cell style - $cell->setXfIndex($this->mapCellXfIndex[$xfIndex]); + $cell->setXfIndexNoUpdate($this->mapCellXfIndex[$xfIndex]); } + $cell->setValueExplicit($value, DataType::TYPE_STRING); } } } @@ -3294,14 +3303,17 @@ class Xls extends XlsBase $col = self::getUInt2d($recordData, 2); $columnString = Coordinate::stringFromColumnIndex($col + 1); + $rowIndex = $row + 1; + // Read cell? - if ($this->getReadFilter()->readCell($columnString, $row + 1, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($columnString, $rowIndex, $this->phpSheetTitle)) { // offset: 4; size: 2; XF index $xfIndex = self::getUInt2d($recordData, 4); - // add style information + // add style information; blank cells never receive a value write, + // so use the updating setter to guarantee persistence if (!$this->readDataOnly && $this->readEmptyCells && isset($this->mapCellXfIndex[$xfIndex])) { - $this->phpSheet->getCell($columnString . ($row + 1))->setXfIndex($this->mapCellXfIndex[$xfIndex]); + $this->phpSheet->getCell($columnString . $rowIndex)->setXfIndex($this->mapCellXfIndex[$xfIndex]); } } } @@ -3537,6 +3549,17 @@ class Xls extends XlsBase } } + private const REGEX_WHOLE_COLUMN = '/^([A-Z]+1\:[A-Z]+)' + . '(' . AddressRange::MAX_ROW_XLS_OLD . '|' . AddressRange::MAX_ROW_XLS . ')' + . '$/'; + private const REGEX_WHOLE_COLUMN_REPLACE = '${1}' . AddressRange::MAX_ROW; + private const REGEX_WHOLE_ROW = '/^(A\d+\:)' + . AddressRange::MAX_COLUMN_XLS + . '(\d+)$/'; + private const REGEX_WHOLE_ROW_REPLACE = '${1}' + . AddressRange::MAX_COLUMN + . '${2}'; + /** * Read SELECTION record. There is one such record for each pane in the sheet. */ @@ -3569,19 +3592,14 @@ class Xls extends XlsBase $selectedCells = $cellRangeAddressList['cellRangeAddresses'][0]; - // first row '1' + last row '16384' indicates that full column is selected (apparently also in BIFF8!) - if (preg_match('/^([A-Z]+1\:[A-Z]+)16384$/', $selectedCells)) { - $selectedCells = (string) preg_replace('/^([A-Z]+1\:[A-Z]+)16384$/', '${1}1048576', $selectedCells); - } - - // first row '1' + last row '65536' indicates that full column is selected - if (preg_match('/^([A-Z]+1\:[A-Z]+)65536$/', $selectedCells)) { - $selectedCells = (string) preg_replace('/^([A-Z]+1\:[A-Z]+)65536$/', '${1}1048576', $selectedCells); + // first row '1' + last row '16384' or '65536' indicates that full column is selected (apparently also in BIFF8!) + if (Preg::isMatch(self::REGEX_WHOLE_COLUMN, $selectedCells)) { + $selectedCells = Preg::replace(self::REGEX_WHOLE_COLUMN, self::REGEX_WHOLE_COLUMN_REPLACE, $selectedCells); } // first column 'A' + last column 'IV' indicates that full row is selected - if (preg_match('/^(A\d+\:)IV(\d+)$/', $selectedCells)) { - $selectedCells = (string) preg_replace('/^(A\d+\:)IV(\d+)$/', '${1}XFD${2}', $selectedCells); + if (Preg::isMatch(self::REGEX_WHOLE_ROW, $selectedCells)) { + $selectedCells = Preg::replace(self::REGEX_WHOLE_ROW, self::REGEX_WHOLE_ROW_REPLACE, $selectedCells); } $this->phpSheet->setSelectedCells($selectedCells); @@ -3597,7 +3615,7 @@ class Xls extends XlsBase StringHelper::stringIncrement($rangeBoundaries[1][0]); for ($row = $rangeBoundaries[0][1]; $row <= $rangeBoundaries[1][1]; ++$row) { for ($column = $rangeBoundaries[0][0]; $column != $rangeBoundaries[1][0]; StringHelper::stringIncrement($column)) { - if ($this->getReadFilter()->readCell($column, $row, $this->phpSheet->getTitle())) { + if ($this->readFilter->readCell($column, $row, $this->phpSheetTitle)) { $includeCellRange = true; break 2; @@ -4633,7 +4651,7 @@ class Xls extends XlsBase $definedNameIndex = self::getUInt2d($formulaData, 1) - 1; // offset: 2; size: 2; not used /** @var string[] */ - $data = $this->definedname[$definedNameIndex]['name'] ?? ''; //* @phpstan-ignore-line + $data = $this->definedname[$definedNameIndex]['name'] ?? ''; break; case 0x24: // single cell reference e.g. A5 @@ -4800,6 +4818,7 @@ class Xls extends XlsBase // todo: check if we have identified the whole set of special characters // it seems that the following characters are not accepted for sheet names // and we may assume that they are not present: []*/:\? + // 'u' qualifier makes it risky to use Preg::isMatch here if (preg_match("/[ !\"@#£$%&{()}<>=+'|^,;-]/u", $sheetRange)) { $sheetRange = "'$sheetRange'"; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/Biff8.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/Biff8.php index 2de9fb8..6bb8472 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/Biff8.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/Biff8.php @@ -241,7 +241,7 @@ class Biff8 extends Xls */ protected static function readBIFF8CellRangeAddress(string $subData): string { - // todo: if cell range is just a single cell, should this funciton + // todo: if cell range is just a single cell, should this function // not just return e.g. 'A1' and not 'A1:A1' ? // offset: 0; size: 2; index to first row (0... 65535) (or offset (-32768... 32767)) @@ -297,7 +297,7 @@ class Biff8 extends Xls [$baseCol, $baseRow] = Coordinate::indexesFromString($baseCell); $baseCol = $baseCol - 1; - // TODO: if cell range is just a single cell, should this funciton + // TODO: if cell range is just a single cell, should this function // not just return e.g. 'A1' and not 'A1:A1' ? // offset: 0; size: 2; first row diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/DataValidationHelper.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/DataValidationHelper.php index b5a9454..55d94af 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/DataValidationHelper.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/DataValidationHelper.php @@ -6,7 +6,6 @@ use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\DataValidation; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Reader\Xls; -use PhpOffice\PhpSpreadsheet\Writer\Xls\Worksheet as XlsWorksheet; class DataValidationHelper extends Xls { @@ -180,8 +179,8 @@ class DataValidationHelper extends Xls $cellRangeAddresses = $cellRangeAddressList['cellRangeAddresses']; $maxRow = (string) AddressRange::MAX_ROW; $maxCol = AddressRange::MAX_COLUMN; - $maxXlsRow = (string) XlsWorksheet::MAX_XLS_ROW; - $maxXlsColumnString = (string) XlsWorksheet::MAX_XLS_COLUMN_STRING; + $maxXlsRow = (string) AddressRange::MAX_ROW_XLS; + $maxXlsColumnString = AddressRange::MAX_COLUMN_XLS; foreach ($cellRangeAddresses as $cellRange) { $cellRange = preg_replace( diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/ListFunctions.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/ListFunctions.php index 9856989..2bc472f 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/ListFunctions.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/ListFunctions.php @@ -157,7 +157,7 @@ class ListFunctions extends Xls } } - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true); $tmpInfo['totalColumns'] = $tmpInfo['lastColumnIndex'] + 1; $worksheetInfo[] = $tmpInfo; @@ -246,7 +246,7 @@ class ListFunctions extends Xls $tmpInfo['dimensionsMaxR'] = $data['rwMac']; $tmpInfo['dimensionsMinC'] = $data['colMic']; $tmpInfo['dimensionsMaxC'] = $data['colMac']; - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['dimensionsMaxC']); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['dimensionsMaxC'], true); } } $xls->readDefault(); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php index 5c27b0c..10a2e5e 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/LoadSpreadsheet.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Xls; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\NamedRange; @@ -34,10 +35,10 @@ class LoadSpreadsheet extends Xls $xls->spreadsheet->removeCellXfByIndex(0); // remove the default style } - // Read the summary information stream (containing meta data) + // Read the summary information stream (containing metadata) $xls->readSummaryInformation(); - // Read the Additional document summary information stream (containing application-specific meta data) + // Read the Additional document summary information stream (containing application-specific metadata) $xls->readDocumentSummaryInformation(); // total byte size of Excel data (workbook global substream + sheet substreams) @@ -52,7 +53,7 @@ class LoadSpreadsheet extends Xls $xls->sheets = []; $xls->externalBooks = []; $xls->ref = []; - $xls->definedname = []; //* @phpstan-ignore-line + $xls->definedname = []; $xls->sst = []; $xls->drawingGroupData = ''; $xls->xfIndex = 0; @@ -174,6 +175,7 @@ class LoadSpreadsheet extends Xls // name in line with the formula, not the reverse $xls->phpSheet->setTitle($sheet['name'], false, false); $xls->phpSheet->setSheetState($sheet['sheetState']); + $xls->phpSheetTitle = $sheet['name']; $xls->pos = $sheet['offset']; @@ -450,10 +452,8 @@ class LoadSpreadsheet extends Xls } // calculate the width and height of the shape - /** @var int $startRow */ - [$startColumn, $startRow] = Coordinate::coordinateFromString($spContainer->getStartCoordinates()); - /** @var int $endRow */ - [$endColumn, $endRow] = Coordinate::coordinateFromString($spContainer->getEndCoordinates()); + [, $startRow, $startColumn] = Coordinate::indexesFromString($spContainer->getStartCoordinates()); + [, $endRow, $endColumn] = Coordinate::indexesFromString($spContainer->getEndCoordinates()); $startOffsetX = $spContainer->getStartOffsetX(); $startOffsetY = $spContainer->getStartOffsetY(); @@ -476,7 +476,7 @@ class LoadSpreadsheet extends Xls if (isset($xls->textObjects[$obj['idObjID']])) { $textObject = $xls->textObjects[$obj['idObjID']]; - $xls->cellNotes[$obj['idObjID']]['objTextData'] = $textObject; //* @phpstan-ignore-line + $xls->cellNotes[$obj['idObjID']]['objTextData'] = $textObject; //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know to fix this) } } @@ -490,7 +490,7 @@ class LoadSpreadsheet extends Xls // If there is no BSE Index, we will fail here and other fields are not read. // Fix by checking here. // TODO: Why is there no BSE Index? Is this a new Office Version? Password protected field? - // More likely : a uncompatible picture + // More likely: an incompatible picture if (!$BSEindex) { continue 2; } @@ -547,10 +547,9 @@ class LoadSpreadsheet extends Xls // treat SHAREDFMLA records if ($xls->version == self::XLS_BIFF8) { foreach ($xls->sharedFormulaParts as $cell => $baseCell) { - /** @var int $row */ - [$column, $row] = Coordinate::coordinateFromString($cell); + [, $row, $column] = Coordinate::indexesFromString($cell); /** @var string $baseCell */ - if ($xls->getReadFilter()->readCell($column, $row, $xls->phpSheet->getTitle())) { + if ($xls->readFilter->readCell($column, $row, $xls->phpSheet->getTitle())) { /** @var string */ $temp = $xls->sharedFormulas[$baseCell]; $formula = $xls->getFormulaFromStructure($temp, $cell); @@ -654,7 +653,7 @@ class LoadSpreadsheet extends Xls if ($firstColumn == 'A' && $lastColumn == 'IV') { // then we have repeating rows $docSheet->getPageSetup()->setRowsToRepeatAtTop([$firstRow, $lastRow]); - } elseif ($firstRow == 1 && $lastRow == 65536) { + } elseif ($firstRow === 1 && $lastRow === AddressRange::MAX_ROW_XLS) { // then we have repeating columns $docSheet->getPageSetup()->setColumnsToRepeatAtLeft([$firstColumn, $lastColumn]); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/RC4.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/RC4.php index 663f367..a880eee 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/RC4.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xls/RC4.php @@ -12,7 +12,7 @@ class RC4 protected int $j = 0; /** - * RC4 stream decryption/encryption constrcutor. + * RC4 stream decryption/encryption constructor. * * @param string $key Encryption key/passphrase */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/XlsBase.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/XlsBase.php index eedd185..454f0f2 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/XlsBase.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/XlsBase.php @@ -392,7 +392,7 @@ class XlsBase extends BaseReader { self::confirmPos($data, $pos + 1); - return unpack('s', $data[$pos] . $data[$pos + 1])[1]; // @phpstan-ignore-line + return unpack('s', $data[$pos] . $data[$pos + 1])[1]; // @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how this statement works), return.type (ditto) } /** diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php index 829f81f..b1de05a 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx.php @@ -2,6 +2,8 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; +use InvalidArgumentException; use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -17,10 +19,12 @@ use PhpOffice\PhpSpreadsheet\Reader\Xlsx\DataValidations; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Hyperlinks; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\PageSetup; +use PhpOffice\PhpSpreadsheet\Reader\Xlsx\PivotTableReader; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Properties as PropertyReader; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\SharedFormula; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\SheetViewOptions; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\SheetViews; +use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Sparklines; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Styles; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\TableReader; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Theme; @@ -31,7 +35,9 @@ use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\Drawing; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Shared\Font; +use PhpOffice\PhpSpreadsheet\Shared\OLE; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; +use PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Color; use PhpOffice\PhpSpreadsheet\Style\Font as StyleFont; @@ -52,16 +58,37 @@ class Xlsx extends BaseReader /** * ReferenceHelper instance. */ - private ReferenceHelper $referenceHelper; + protected ReferenceHelper $referenceHelper; - private ZipArchive $zip; + protected ZipArchive $zip; private Styles $styleReader; /** @var SharedFormula[] */ - private array $sharedFormulae = []; + protected array $sharedFormulae = []; - private bool $parseHuge = false; + protected bool $parseHuge = false; + + private string $encryptionPassword = ''; + + private int $maxEncryptionSpinCount = AgileEncryption::MAX_SPIN_COUNT; + + public function setEncryptionPassword(string $encryptionPassword): self + { + $this->encryptionPassword = $encryptionPassword; + + return $this; + } + + public function setMaxEncryptionSpinCount(int $maxEncryptionSpinCount): self + { + if ($maxEncryptionSpinCount < 0 || $maxEncryptionSpinCount > AgileEncryption::MAX_SPIN_COUNT) { + throw new InvalidArgumentException('Maximum encryption spin count must be between 0 and ' . AgileEncryption::MAX_SPIN_COUNT . '.'); + } + $this->maxEncryptionSpinCount = $maxEncryptionSpinCount; + + return $this; + } /** * Allow use of LIBXML_PARSEHUGE. @@ -90,7 +117,7 @@ class Xlsx extends BaseReader public function canRead(string $filename): bool { if (!File::testFileNoThrow($filename, self::INITIAL_FILE)) { - return false; + return $this->hasEncryptedPackage($filename); } $result = false; @@ -106,6 +133,75 @@ class Xlsx extends BaseReader return $result; } + public function load(string $filename, int $flags = 0): Spreadsheet + { + $temporaryFilename = $this->decryptToTemporaryFile($filename); + if ($temporaryFilename === null) { + return parent::load($filename, $flags); + } + + try { + return parent::load($temporaryFilename, $flags); + } finally { + @unlink($temporaryFilename); + } + } + + private function hasEncryptedPackage(string $filename): bool + { + try { + $ole = new OLE(); + $ole->read($filename); + + return $ole->hasDataByName('EncryptionInfo') && $ole->hasDataByName('EncryptedPackage'); + } catch (Throwable) { + return false; + } + } + + private function decryptToTemporaryFile(string $filename): ?string + { + if (File::testFileNoThrow($filename, self::INITIAL_FILE)) { + return null; + } + + try { + $ole = new OLE(); + $ole->read($filename); + $encryptionInfo = $ole->getDataByName('EncryptionInfo'); + } catch (Throwable) { + return null; + } + + $temporaryFilename = File::temporaryFilename(); + $encryptedPackageFilename = File::temporaryFilename(); + $encryptedPackage = fopen($encryptedPackageFilename, 'wb'); + if ($encryptedPackage === false) { + @unlink($temporaryFilename); + @unlink($encryptedPackageFilename); + + throw new Exception('Could not create decrypted XLSX package.'); + } + + try { + $ole->copyDataByName('EncryptedPackage', $encryptedPackage); + fclose($encryptedPackage); + $encryptedPackage = null; + AgileEncryption::decryptFile(AgileEncryption::parse($encryptionInfo, $this->maxEncryptionSpinCount), $encryptedPackageFilename, $temporaryFilename, $this->encryptionPassword); + } catch (Throwable $e) { + if ($encryptedPackage !== null) { + fclose($encryptedPackage); + } + @unlink($temporaryFilename); + + throw $e; + } finally { + @unlink($encryptedPackageFilename); + } + + return $temporaryFilename; + } + public static function testSimpleXml(mixed $value): SimpleXMLElement { return ($value instanceof SimpleXMLElement) ? $value : new SimpleXMLElement(''); @@ -179,6 +275,21 @@ class Xlsx extends BaseReader * @return string[] */ public function listWorksheetNames(string $filename): array + { + $temporaryFilename = $this->decryptToTemporaryFile($filename); + if ($temporaryFilename === null) { + return $this->listWorksheetNamesFromFile($filename); + } + + try { + return $this->listWorksheetNamesFromFile($temporaryFilename); + } finally { + @unlink($temporaryFilename); + } + } + + /** @return string[] */ + private function listWorksheetNamesFromFile(string $filename): array { File::assertFile($filename, self::INITIAL_FILE); @@ -216,6 +327,23 @@ class Xlsx extends BaseReader * @return array */ public function listWorksheetInfo(string $filename): array + { + $temporaryFilename = $this->decryptToTemporaryFile($filename); + if ($temporaryFilename === null) { + return $this->listWorksheetInfoFromFile($filename); + } + + try { + return $this->listWorksheetInfoFromFile($temporaryFilename); + } finally { + @unlink($temporaryFilename); + } + } + + /** + * @return array + */ + private function listWorksheetInfoFromFile(string $filename): array { File::assertFile($filename, self::INITIAL_FILE); @@ -279,22 +407,33 @@ class Xlsx extends BaseReader $xml->setParserProperty(2, true); $currCells = 0; + $currRow = 0; while ($xml->read()) { if ($xml->localName == 'row' && $xml->nodeType == XMLReader::ELEMENT && $xml->namespaceURI === $mainNS) { $row = (int) $xml->getAttribute('r'); - $tmpInfo['totalRows'] = $row; + if ($this->readEmptyCells) { + $tmpInfo['totalRows'] = $row; + } else { + $currRow = $row; + } $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells); $currCells = 0; } elseif ($xml->localName == 'c' && $xml->nodeType == XMLReader::ELEMENT && $xml->namespaceURI === $mainNS) { - $cell = $xml->getAttribute('r'); - $currCells = $cell ? max($currCells, Coordinate::indexesFromString($cell)[0]) : ($currCells + 1); + if ($this->readEmptyCells || !$xml->isEmptyElement) { + if ($currRow !== 0) { + $tmpInfo['totalRows'] = $currRow; + $currRow = 0; + } + $cell = $xml->getAttribute('r'); + $currCells = $cell ? max($currCells, Coordinate::indexesFromString($cell)[0]) : ($currCells + 1); + } } } $tmpInfo['totalColumns'] = max($tmpInfo['totalColumns'], $currCells); $xml->close(); $tmpInfo['lastColumnIndex'] = $tmpInfo['totalColumns'] - 1; - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true); $worksheetInfo[] = $tmpInfo; } @@ -307,7 +446,7 @@ class Xlsx extends BaseReader return $worksheetInfo; } - private static function castToBoolean(SimpleXMLElement $c): bool + protected static function castToBoolean(SimpleXMLElement $c): bool { $value = isset($c->v) ? (string) $c->v : null; if ($value == '0') { @@ -319,12 +458,12 @@ class Xlsx extends BaseReader return (bool) $c->v; } - private static function castToError(?SimpleXMLElement $c): ?string + protected static function castToError(?SimpleXMLElement $c): ?string { return isset($c, $c->v) ? (string) $c->v : null; } - private static function castToString(?SimpleXMLElement $c): ?string + protected static function castToString(?SimpleXMLElement $c): ?string { return isset($c, $c->v) ? (string) $c->v : null; } @@ -334,7 +473,7 @@ class Xlsx extends BaseReader return str_replace(['_xlfn.', '_xlws.'], '', $formula); } - private function castToFormula(?SimpleXMLElement $c, string $r, string &$cellDataType, mixed &$value, mixed &$calculatedValue, string $castBaseType, bool $updateSharedCells = true): void + protected function castToFormula(?SimpleXMLElement $c, string $r, string &$cellDataType, mixed &$value, mixed &$calculatedValue, string $castBaseType, bool $updateSharedCells = true): void { if ($c === null) { return; @@ -386,7 +525,7 @@ class Xlsx extends BaseReader return $contents !== false; } - private function getFromZipArchive(ZipArchive $archive, string $fileName = ''): string + protected function getFromZipArchive(ZipArchive $archive, string $fileName = ''): string { // Root-relative paths if (str_contains($fileName, '//')) { @@ -394,7 +533,7 @@ class Xlsx extends BaseReader } // Relative paths generated by dirname($filename) when $filename // has no path (i.e.files in root of the zip archive) - $fileName = (string) preg_replace('/^\.\//', '', $fileName); + $fileName = Preg::replace('/^\.\//', '', $fileName); $fileName = File::realpath($fileName); // Sadly, some 3rd party xlsx generators don't use consistent case for filenaming @@ -497,12 +636,12 @@ class Xlsx extends BaseReader $majorFonts = []; $minorFonts = []; $fontScheme = $xmlTheme->themeElements->fontScheme->children($drawingNS); - $majorLatin = self::getAttributes($fontScheme->majorFont->latin)['typeface'] ?? ''; - $majorEastAsian = self::getAttributes($fontScheme->majorFont->ea)['typeface'] ?? ''; - $majorComplexScript = self::getAttributes($fontScheme->majorFont->cs)['typeface'] ?? ''; - $minorLatin = self::getAttributes($fontScheme->minorFont->latin)['typeface'] ?? ''; - $minorEastAsian = self::getAttributes($fontScheme->minorFont->ea)['typeface'] ?? ''; - $minorComplexScript = self::getAttributes($fontScheme->minorFont->cs)['typeface'] ?? ''; + $majorLatin = (string) (self::getAttributes($fontScheme->majorFont->latin)['typeface'] ?? ''); + $majorEastAsian = (string) (self::getAttributes($fontScheme->majorFont->ea)['typeface'] ?? ''); + $majorComplexScript = (string) (self::getAttributes($fontScheme->majorFont->cs)['typeface'] ?? ''); + $minorLatin = (string) (self::getAttributes($fontScheme->minorFont->latin)['typeface'] ?? ''); + $minorEastAsian = (string) (self::getAttributes($fontScheme->minorFont->ea)['typeface'] ?? ''); + $minorComplexScript = (string) (self::getAttributes($fontScheme->minorFont->cs)['typeface'] ?? ''); foreach ($fontScheme->majorFont->font as $xmlFont) { $fontAttributes = self::getAttributes($xmlFont); @@ -567,6 +706,7 @@ class Xlsx extends BaseReader $relsWorkbook->registerXPathNamespace('rel', Namespaces::RELATIONSHIPS); $worksheets = []; + $pivotCacheRels = []; $macros = $customUI = null; foreach ($relsWorkbook->Relationship as $elex) { $ele = self::getAttributes($elex); @@ -581,6 +721,10 @@ class Xlsx extends BaseReader $worksheets[(string) $ele['Id']] = $ele['Target']; } + break; + case Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION: + $pivotCacheRels[(string) $ele['Id']] = File::realpath("$dir/" . (string) $ele['Target']); + break; // a vbaProject ? (: some macros) case Namespaces::VBA: @@ -678,6 +822,16 @@ class Xlsx extends BaseReader $objStyle = new Style(); $this->styleReader ->readStyle($objStyle, $style); + if (isset($xfTag->extLst)) { + foreach ($xfTag->extLst->ext as $extTag) { + $attributes = $extTag->attributes(); + if (isset($attributes['uri'])) { + if ((string) $attributes['uri'] === Namespaces::STYLE_CHECKBOX_URI) { + $objStyle->setCheckBox(true); + } + } + } + } foreach ($this->styleReader->getFontCharsets() as $fontName => $charset) { $excel->addFontCharset($fontName, $charset); } @@ -784,6 +938,19 @@ class Xlsx extends BaseReader $charts = $chartDetails = []; + // Add richData (contains relation of in-cell images) + $richData = []; + $relationsFileName = $dir . '/richData/_rels/richValueRel.xml.rels'; + if ($zip->locateName($relationsFileName)) { + $relsWorksheet = $this->loadZip($relationsFileName, Namespaces::RELATIONSHIPS); + foreach ($relsWorksheet->Relationship as $elex) { + $ele = self::getAttributes($elex); + if ($ele['Type'] == Namespaces::IMAGE) { + $richData['image'][(string) $ele['Id']] = (string) $ele['Target']; + } + } + } + $sheetCreated = false; if ($xmlWorkbookNS->sheets) { foreach ($xmlWorkbookNS->sheets->sheet as $eleSheet) { @@ -855,167 +1022,26 @@ class Xlsx extends BaseReader $sheetViewOptions->load($this->readDataOnly, $this->styleReader); (new ColumnAndRowAttributes($docSheet, $xmlSheetNS)) - ->load($this->getReadFilter(), $this->readDataOnly, $this->ignoreRowsWithNoCells); + ->load($this->readFilter, $this->readDataOnly, $this->ignoreRowsWithNoCells); } $holdSelectedCells = $docSheet->getSelectedCells(); - if ($xmlSheetNS && $xmlSheetNS->sheetData && $xmlSheetNS->sheetData->row) { - $cIndex = 1; // Cell Start from 1 - foreach ($xmlSheetNS->sheetData->row as $row) { - $rowIndex = 1; - foreach ($row->c as $c) { - $cAttr = self::getAttributes($c); - $r = (string) $cAttr['r']; - if ($r == '') { - $r = Coordinate::stringFromColumnIndex($rowIndex) . $cIndex; - } - $cellDataType = (string) $cAttr['t']; - $originalCellDataTypeNumeric = $cellDataType === ''; - $value = null; - $calculatedValue = null; + /** @var array $styles */ + $this->loadSheetData( + $xmlSheetNS, + $filename, + $dir, + $richData, + $docSheet, + $sharedStrings, + $styles, + [ + // this array can be expanded with additional entries to make it easier to extend + 'mainNS' => $mainNS, + 'fileWorksheetPath' => "$dir/$fileWorksheet", + ], + ); - // Read cell? - $coordinates = Coordinate::coordinateFromString($r); - - if (!$this->getReadFilter()->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { - // Normally, just testing for the f attribute should identify this cell as containing a formula - // that we need to read, even though it is outside of the filter range, in case it is a shared formula. - // But in some cases, this attribute isn't set; so we need to delve a level deeper and look at - // whether or not the cell has a child formula element that is shared. - if (isset($cAttr->f) || (isset($c->f, $c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')) { - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError', false); - } - ++$rowIndex; - - continue; - } - - // Read cell! - $useFormula = isset($c->f) - && ((string) $c->f !== '' || (isset($c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')); - switch ($cellDataType) { - case DataType::TYPE_STRING: - if ((string) $c->v != '') { - $value = $sharedStrings[(int) ($c->v)]; - - if ($value instanceof RichText) { - $value = clone $value; - } - } else { - $value = ''; - } - - break; - case DataType::TYPE_BOOL: - if (!$useFormula) { - if (isset($c->v)) { - $value = self::castToBoolean($c); - } else { - $value = null; - $cellDataType = DataType::TYPE_NULL; - } - } else { - // Formula - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToBoolean'); - self::storeFormulaAttributes($c->f, $docSheet, $r); - } - - break; - case DataType::TYPE_STRING2: - if ($useFormula) { - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); - self::storeFormulaAttributes($c->f, $docSheet, $r); - } else { - $value = self::castToString($c); - } - - break; - case DataType::TYPE_INLINE: - if ($useFormula) { - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); - self::storeFormulaAttributes($c->f, $docSheet, $r); - } else { - $value = $this->parseRichText($c->is); - } - - break; - case DataType::TYPE_ERROR: - if (!$useFormula) { - $value = self::castToError($c); - } else { - // Formula - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); - $eattr = $c->attributes(); - if (isset($eattr['vm'])) { - if ($calculatedValue === ExcelError::VALUE()) { - $calculatedValue = ExcelError::SPILL(); - } - } - } - - break; - default: - if (!$useFormula) { - $value = self::castToString($c); - if (is_numeric($value)) { - $value += 0; - $cellDataType = DataType::TYPE_NUMERIC; - } - } else { - // Formula - $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); - if (is_numeric($calculatedValue)) { - $calculatedValue += 0; - } - self::storeFormulaAttributes($c->f, $docSheet, $r); - } - - break; - } - - // read empty cells or the cells are not empty - if ($this->readEmptyCells || ($value !== null && $value !== '')) { - // Rich text? - if ($value instanceof RichText && $this->readDataOnly) { - $value = $value->getPlainText(); - } - - $cell = $docSheet->getCell($r); - // Assign value - if ($cellDataType != '') { - // it is possible, that datatype is numeric but with an empty string, which result in an error - if ($cellDataType === DataType::TYPE_NUMERIC && ($value === '' || $value === null)) { - $cellDataType = DataType::TYPE_NULL; - } - if ($cellDataType !== DataType::TYPE_NULL) { - $cell->setValueExplicit($value, $cellDataType); - } - } else { - $cell->setValue($value); - } - if ($calculatedValue !== null) { - $cell->setCalculatedValue($calculatedValue, $originalCellDataTypeNumeric); - } - - // Style information? - if (!$this->readDataOnly) { - $cAttrS = (int) ($cAttr['s'] ?? 0); - // no style index means 0, it seems - $cAttrS = isset($styles[$cAttrS]) ? $cAttrS : 0; - $cell->setXfIndex($cAttrS); - // issue 3495 - if ($cellDataType === DataType::TYPE_FORMULA && $styles[$cAttrS]->quotePrefix === true) { //* @phpstan-ignore-line - $holdSelected = $docSheet->getSelectedCells(); - $cell->getStyle()->setQuotePrefix(false); - $docSheet->setSelectedCells($holdSelected); - } - } - } - ++$rowIndex; - } - ++$cIndex; - } - } $docSheet->setSelectedCells($holdSelectedCells); if (!$this->readDataOnly && $xmlSheetNS && $xmlSheetNS->ignoredErrors) { foreach ($xmlSheetNS->ignoredErrors->ignoredError as $ignoredError) { @@ -1042,6 +1068,10 @@ class Xlsx extends BaseReader $this->readTables($xmlSheetNS, $docSheet, $dir, $fileWorksheet, $zip, $mainNS, $tableStyles, $dxfs); + if ($this->readDataOnly === false) { + $this->readPivotTables($docSheet, $dir, $fileWorksheet, $zip, $unparsedLoadedData); + } + if ($xmlSheetNS && $xmlSheetNS->mergeCells && $xmlSheetNS->mergeCells->mergeCell && !$this->readDataOnly) { foreach ($xmlSheetNS->mergeCells->mergeCell as $mergeCellx) { $mergeCell = $mergeCellx->attributes(); @@ -1091,6 +1121,10 @@ class Xlsx extends BaseReader (new DataValidations($docSheet, $xmlSheet))->load(); } + if ($xmlSheet && !$this->readDataOnly) { + (new Sparklines($docSheet, $xmlSheet))->load(); + } + // unparsed sheet AlternateContent if ($xmlSheet && !$this->readDataOnly) { $mc = $xmlSheet->children(Namespaces::COMPATIBILITY); @@ -1223,9 +1257,9 @@ class Xlsx extends BaseReader $textboxDirection = ''; $textboxPath = $shape->xpath('.//v:textbox'); $textbox = (string) ($textboxPath[0]['style'] ?? ''); - if (preg_match('/rtl/i', $textbox) === 1) { + if (Preg::isMatch('/rtl/i', $textbox)) { $textboxDirection = Comment::TEXTBOX_DIRECTION_RTL; - } elseif (preg_match('/ltr/i', $textbox) === 1) { + } elseif (Preg::isMatch('/ltr/i', $textbox)) { $textboxDirection = Comment::TEXTBOX_DIRECTION_LTR; } if (is_array($clientData) && !empty($clientData)) { @@ -1245,7 +1279,7 @@ class Xlsx extends BaseReader } $temp = $clientData->xpath('.//x:TextHAlign'); if (!empty($temp)) { - $textHAlign = strtolower($temp[0]); + $textHAlign = strtolower((string) $temp[0]); } } } @@ -1486,6 +1520,29 @@ class Xlsx extends BaseReader $xmlDrawing = $this->loadZipNoNamespace($fileDrawing, ''); $xmlDrawingChildren = $xmlDrawing->children(Namespaces::SPREADSHEET_DRAWING); + // Store drawing XML for pass-through if enabled + if ($this->enableDrawingPassThrough) { + $unparsedDrawings[$drawingRelId] = $xmlDrawing->asXML(); + // Mark that pass-through is enabled for this sheet + $sheetCodeName = $docSheet->getCodeName(); + if (!isset($unparsedLoadedData['sheets']) || !is_array($unparsedLoadedData['sheets'])) { + $unparsedLoadedData['sheets'] = []; + } + if (!isset($unparsedLoadedData['sheets'][$sheetCodeName]) || !is_array($unparsedLoadedData['sheets'][$sheetCodeName])) { + $unparsedLoadedData['sheets'][$sheetCodeName] = []; + } + /** @var array $sheetUnparsedData */ + $sheetUnparsedData = &$unparsedLoadedData['sheets'][$sheetCodeName]; + $sheetUnparsedData['drawingPassThroughEnabled'] = true; + // Store original drawing relationships for pass-through + if ($relsDrawing) { + $sheetUnparsedData['drawingRelationships'] = $relsDrawing->asXML(); + } + // Store original media files paths and source file for pass-through + $sheetUnparsedData['drawingMediaFiles'] = $images; + $sheetUnparsedData['drawingSourceFile'] = File::realpath($filename); + } + if ($xmlDrawingChildren->oneCellAnchor) { foreach ($xmlDrawingChildren->oneCellAnchor as $oneCellAnchor) { $oneCellAnchor = self::testSimpleXml($oneCellAnchor); @@ -1521,7 +1578,7 @@ class Xlsx extends BaseReader ); if (isset($images[$linkImageKey])) { $url = str_replace('xl/drawings/', '', $images[$linkImageKey]); - $objDrawing->setPath($url, false, allowExternal: $this->allowExternalImages); + $objDrawing->setPath($url, false, allowExternal: $this->allowExternalImages, isWhitelisted: $this->isWhitelisted); } if ($objDrawing->getPath() === '') { continue; @@ -1625,7 +1682,7 @@ class Xlsx extends BaseReader ); if (isset($images[$linkImageKey])) { $url = str_replace('xl/drawings/', '', $images[$linkImageKey]); - $objDrawing->setPath($url, false, allowExternal: $this->allowExternalImages); + $objDrawing->setPath($url, false, allowExternal: $this->allowExternalImages, isWhitelisted: $this->isWhitelisted); } if ($objDrawing->getPath() === '') { continue; @@ -1803,9 +1860,9 @@ class Xlsx extends BaseReader $range = str_replace('$', '', $range); // check for repeating columns, e g. 'A:A' or 'A:D' - if (preg_match('/!?([A-Z]+)\:([A-Z]+)$/', $range, $matches)) { + if (Preg::isMatch('/!?([A-Z]+)\:([A-Z]+)$/', $range, $matches)) { $docSheet->getPageSetup()->setColumnsToRepeatAtLeft([$matches[1], $matches[2]]); - } elseif (preg_match('/!?(\d+)\:(\d+)$/', $range, $matches)) { + } elseif (Preg::isMatch('/!?(\d+)\:(\d+)$/', $range, $matches)) { // check for repeating rows, e.g. '1:1' or '1:5' $docSheet->getPageSetup()->setRowsToRepeatAtTop([(int) $matches[1], (int) $matches[2]]); } @@ -1813,7 +1870,7 @@ class Xlsx extends BaseReader break; case '_xlnm.Print_Area': - $rangeSets = preg_split("/('?(?:.*?)'?(?:![A-Z0-9]+:[A-Z0-9]+)),?/", $extractedRange, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE) ?: []; + $rangeSets = Preg::split("/('?(?:.*?)'?(?:![A-Z0-9]+:[A-Z0-9]+)),?/", $extractedRange, -1, PREG_SPLIT_NO_EMPTY | PREG_SPLIT_DELIM_CAPTURE) ?: []; $newRangeSets = []; foreach ($rangeSets as $rangeSet) { [, $rangeSet] = Worksheet::extractSheetTitle($rangeSet, true); @@ -1886,14 +1943,12 @@ class Xlsx extends BaseReader if (str_contains((string) $definedName, '!')) { // Modify range, and extract the first worksheet reference // Need to split on a comma or a space if not in quotes, and extract the first part. - $definedNameValueParts = preg_split("/[ ,](?=([^']*'[^']*')*[^']*$)/miuU", $extractedRange); - if (is_array($definedNameValueParts)) { - // Extract sheet name - [$extractedSheetName] = Worksheet::extractSheetTitle((string) $definedNameValueParts[0], true, true); + $definedNameValueParts = Preg::split("/[ ,](?=([^']*'[^']*')*[^']*$)/miuU", $extractedRange); + // Extract sheet name + [$extractedSheetName] = Worksheet::extractSheetTitle((string) $definedNameValueParts[0], true, true); - // Locate sheet - $locatedSheet = $excel->getSheetByName("$extractedSheetName"); - } + // Locate sheet + $locatedSheet = $excel->getSheetByName("$extractedSheetName"); } if ($locatedSheet === null && !DefinedName::testIfFormula($extractedRange)) { @@ -1903,6 +1958,22 @@ class Xlsx extends BaseReader } } } + + // Preserve the workbook registry (cacheId + // -> cache definition part) so pivot tables survive a + // load/save round-trip. + if (!$this->readDataOnly && $xmlWorkbook->pivotCaches && $xmlWorkbook->pivotCaches->pivotCache) { + foreach ($xmlWorkbook->pivotCaches->pivotCache as $pivotCache) { + $pivotCacheAttributes = self::getAttributes($pivotCache); + $relId = (string) self::getAttributes($pivotCache, Namespaces::SCHEMA_OFFICE_DOCUMENT)['id']; + if (isset($pivotCacheRels[$relId])) { + $unparsedLoadedData['workbookPivotCaches'][] = [ + 'cacheId' => (string) $pivotCacheAttributes['cacheId'], + 'cacheDefinitionPath' => $pivotCacheRels[$relId], + ]; + } + } + } } if ($this->createBlankSheetIfNoneRead && !$sheetCreated) { $excel->createSheet(); @@ -1963,6 +2034,9 @@ class Xlsx extends BaseReader // unparsed case 'application/vnd.ms-excel.controlproperties+xml': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml': + case 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml': $unparsedLoadedData['override_content_types'][(string) $contentType['PartName']] = (string) $contentType['ContentType']; break; @@ -1978,7 +2052,206 @@ class Xlsx extends BaseReader return $excel; } - private function parseRichText(?SimpleXMLElement $is): RichText + /** + * @param string[][] $richData + * @param Worksheet $docSheet the worksheet to populate + * @param array $sharedStrings shared string table + * @param object[] $styles style objects array + * @param mixed[] $extraParameters maybe make it a little easier to extend + */ + protected function loadSheetData( + ?SimpleXMLElement $xmlSheetNS, + string $filename, + string $dir, + array $richData, + Worksheet $docSheet, + array $sharedStrings, + array $styles, + array $extraParameters = [], + ): void { + if (!($xmlSheetNS && $xmlSheetNS->sheetData && $xmlSheetNS->sheetData->row)) { + return; // @codeCoverageIgnore + } + + $cIndex = 1; // Cell Start from 1 + foreach ($xmlSheetNS->sheetData->row as $row) { + $rowIndex = 1; + foreach ($row->c as $c) { + $cAttr = self::getAttributes($c); + $r = (string) $cAttr['r']; + if ($r == '') { + $r = Coordinate::stringFromColumnIndex($rowIndex) . $cIndex; + } + $cellDataType = (string) $cAttr['t']; + $originalCellDataTypeNumeric = $cellDataType === ''; + $value = null; + $calculatedValue = null; + + // Read cell? + $coordinates = Coordinate::coordinateFromString($r); + + if (!$this->readFilter->readCell($coordinates[0], (int) $coordinates[1], $docSheet->getTitle())) { + // Normally, just testing for the f attribute should identify this cell as containing a formula + // that we need to read, even though it is outside of the filter range, in case it is a shared formula. + // But in some cases, this attribute isn't set; so we need to delve a level deeper and look at + // whether or not the cell has a child formula element that is shared. + if (isset($cAttr->f) || (isset($c->f, $c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')) { + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError', false); + } + ++$rowIndex; + + continue; + } + + // Read cell! + $useFormula = isset($c->f) + && ((string) $c->f !== '' || (isset($c->f->attributes()['t']) && strtolower((string) $c->f->attributes()['t']) === 'shared')); + switch ($cellDataType) { + case DataType::TYPE_STRING: + if ((string) $c->v != '') { + $value = $sharedStrings[(int) ($c->v)]; + + if ($value instanceof RichText) { + $value = clone $value; + } + } else { + $value = ''; + } + + break; + case DataType::TYPE_BOOL: + if (!$useFormula) { + if (isset($c->v)) { + $value = self::castToBoolean($c); + } else { + $value = null; + $cellDataType = DataType::TYPE_NULL; + } + } else { + // Formula + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToBoolean'); + self::storeFormulaAttributes($c->f, $docSheet, $r); + } + + break; + case DataType::TYPE_STRING2: + if ($useFormula) { + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); + self::storeFormulaAttributes($c->f, $docSheet, $r); + } else { + $value = self::castToString($c); + } + + break; + case DataType::TYPE_INLINE: + if ($useFormula) { + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); + self::storeFormulaAttributes($c->f, $docSheet, $r); + } else { + $value = $this->parseRichText($c->is); + } + + break; + case DataType::TYPE_ERROR: + if (isset($cAttr->vm, $richData['image']['rId' . $cAttr->vm]) && !$useFormula) { + $imagePath = $dir . '/' . str_replace('../', '', $richData['image']['rId' . $cAttr->vm]); + $objDrawing = new \PhpOffice\PhpSpreadsheet\Worksheet\Drawing(); + $objDrawing->setPath( + 'zip://' . File::realpath($filename) . '#' . $imagePath, + false, + $this->zip + ); + + $objDrawing->setCoordinates($r); + $objDrawing->setResizeProportional(false); + $objDrawing->setInCell(true); + $objDrawing->setWorksheet($docSheet); + + $value = $objDrawing; + $cellDataType = DataType::TYPE_DRAWING_IN_CELL; + $c->t = DataType::TYPE_ERROR; + + break; + } + + if (!$useFormula) { + $value = self::castToError($c); + } else { + // Formula + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToError'); + $eattr = $c->attributes(); + if (isset($eattr['vm'])) { + if ($calculatedValue === ExcelError::VALUE()) { + $calculatedValue = ExcelError::SPILL(); + } + } + } + + break; + default: + if (!$useFormula) { + $value = self::castToString($c); + if (is_numeric($value)) { + $value += 0; + $cellDataType = DataType::TYPE_NUMERIC; + } + } else { + // Formula + $this->castToFormula($c, $r, $cellDataType, $value, $calculatedValue, 'castToString'); + if (is_numeric($calculatedValue)) { + $calculatedValue += 0; + } + self::storeFormulaAttributes($c->f, $docSheet, $r); + } + + break; + } + + // read empty cells or the cells are not empty + if ($this->readEmptyCells || ($value !== null && $value !== '')) { + // Rich text? + if ($value instanceof RichText && $this->readDataOnly) { + $value = $value->getPlainText(); + } + + $cell = $docSheet->getCell($r); + // Assign value + if ($cellDataType != '') { + // it is possible, that datatype is numeric but with an empty string, which result in an error + if ($cellDataType === DataType::TYPE_NUMERIC && ($value === '' || $value === null)) { + $cellDataType = DataType::TYPE_NULL; + } + if ($cellDataType !== DataType::TYPE_NULL) { + $cell->setValueExplicit($value, $cellDataType); + } + } else { + $cell->setValue($value); + } + if ($calculatedValue !== null) { + $cell->setCalculatedValue($calculatedValue, $originalCellDataTypeNumeric); + } + + // Style information? + if (!$this->readDataOnly) { + $cAttrS = (int) ($cAttr['s'] ?? 0); + // no style index means 0, it seems + $cAttrS = isset($styles[$cAttrS]) ? $cAttrS : 0; + $cell->setXfIndex($cAttrS); + // issue 3495 + if ($cellDataType === DataType::TYPE_FORMULA && $styles[$cAttrS]->quotePrefix === true) { //* @phpstan-ignore property.notFound (quotePrefix does exist) + $holdSelected = $docSheet->getSelectedCells(); + $cell->getStyle()->setQuotePrefix(false); + $docSheet->setSelectedCells($holdSelected); + } + } + } + ++$rowIndex; + } + ++$cIndex; + } + } + + protected function parseRichText(?SimpleXMLElement $is): RichText { $value = new RichText(); @@ -2133,7 +2406,7 @@ class Xlsx extends BaseReader $base = (string) $base; $add = (string) $add; - return (string) preg_replace('~[^/]+/\.\./~', '', dirname($base) . "/$add"); + return Preg::replace('~[^/]+/\.\./~', '', dirname($base) . "/$add"); } /** @return mixed[] */ @@ -2148,18 +2421,18 @@ class Xlsx extends BaseReader if (str_contains($item[1], 'px')) { $item[1] = str_replace('px', '', $item[1]); - } - if (str_contains($item[1], 'pt')) { + } elseif (str_contains($item[1], 'pt')) { $item[1] = str_replace('pt', '', $item[1]); - $item[1] = (string) Font::fontSizeToPixels((int) $item[1]); - } - if (str_contains($item[1], 'in')) { + $item[1] = Font::fontSizeToPixels((float) $item[1]); + } elseif (str_contains($item[1], 'in')) { $item[1] = str_replace('in', '', $item[1]); - $item[1] = (string) Font::inchSizeToPixels((int) $item[1]); - } - if (str_contains($item[1], 'cm')) { + $item[1] = (int) Font::inchSizeToPixels((float) $item[1]); + } elseif (str_contains($item[1], 'cm')) { $item[1] = str_replace('cm', '', $item[1]); - $item[1] = (string) Font::centimeterSizeToPixels((int) $item[1]); + $item[1] = (int) Font::centimeterSizeToPixels((float) $item[1]); + } elseif (str_contains($item[1], 'mm')) { + $item[1] = str_replace('mm', '', $item[1]); + $item[1] = (int) Font::centimeterSizeToPixels((float) $item[1] / 10); } $style[$item[0]] = $item[1]; @@ -2193,8 +2466,13 @@ class Xlsx extends BaseReader $hlinkId = (string) self::getAttributes($hlinkClick, Namespaces::SCHEMA_OFFICE_DOCUMENT)['id']; $hyperlink = new Hyperlink( - $hyperlinks[$hlinkId], - self::getArrayItemString(self::getAttributes($cellAnchor->pic->nvPicPr->cNvPr), 'name') + Preg::replace('/^#/', 'sheet://', $hyperlinks[$hlinkId]), + self::getArrayItemString( + self::getAttributes( + $cellAnchor->pic->nvPicPr->cNvPr + ), + 'name' + ) ); $objDrawing->setHyperlink($hyperlink); } @@ -2284,7 +2562,7 @@ class Xlsx extends BaseReader { $returnValue = null; $protectKey = $protection[$key]; - if (!empty($protectKey)) { + if (isset($protectKey)) { $protectKey = (string) $protectKey; $returnValue = $protectKey !== 'false' && (bool) $protectKey; } @@ -2373,7 +2651,7 @@ class Xlsx extends BaseReader case Namespaces::PURL_OFFICE_DOCUMENT: $basename = basename((string) $rel['Target']); $xmlNamespaceBase = dirname($type); - if (preg_match('/workbook.*\.xml/', $basename)) { + if (Preg::isMatch('/workbook.*\.xml/', $basename)) { $workbookBasename = $basename; } @@ -2430,7 +2708,7 @@ class Xlsx extends BaseReader $attrs = $rel->attributes() ?? []; $rid = (string) ($attrs['Id'] ?? ''); $target = (string) ($attrs['Target'] ?? ''); - if ($rid === $id && substr($target, 0, 2) === '..') { + if ($rid === $id && str_starts_with($target, '..')) { $target = 'xl' . substr($target, 2); $content = $this->getFromZipArchive($this->zip, $target); $docSheet->setBackgroundImage($content); @@ -2501,6 +2779,166 @@ class Xlsx extends BaseReader } } + /** + * Discover the pivot table parts referenced by a worksheet, parse them into + * the read-only PivotTable object model, and preserve every associated raw + * XML part (pivot table, cache definition, cache records and their rels) in + * the unparsed loaded data so they can be written back unchanged. + * + * @param mixed[] $unparsedLoadedData + */ + private function readPivotTables( + Worksheet $docSheet, + string $dir, + string $fileWorksheet, + ZipArchive $zip, + array &$unparsedLoadedData + ): void { + $relationsFileName = dirname("$dir/$fileWorksheet") . '/_rels/' . basename($fileWorksheet) . '.rels'; + if ($zip->locateName($relationsFileName) === false) { + return; + } + + $relsWorksheet = $this->loadZip($relationsFileName, Namespaces::RELATIONSHIPS); + foreach ($relsWorksheet->Relationship as $relationship) { + $relAttributes = self::getAttributes($relationship, ''); + if ((string) $relAttributes['Type'] !== Namespaces::RELATIONSHIPS_PIVOT_TABLE) { + continue; + } + + $relTarget = (string) $relAttributes['Target']; + $pivotTablePath = File::realpath(dirname("$dir/$fileWorksheet") . '/' . $relTarget); + if (!$this->fileExistsInArchive($this->zip, $pivotTablePath)) { + continue; + } + + $pivotTableXml = $this->loadZip($pivotTablePath, Namespaces::MAIN); + $cacheDefinitionXml = $this->readPivotCacheDefinition($pivotTablePath, $zip, $unparsedLoadedData); + + (new PivotTableReader($docSheet, $pivotTableXml, $cacheDefinitionXml))->load(); + + // Preserve the raw pivot table part (and its rels) for write-back. + $sheetCodeName = $docSheet->getCodeName(); + if (!isset($unparsedLoadedData['sheets']) || !is_array($unparsedLoadedData['sheets'])) { + $unparsedLoadedData['sheets'] = []; + } + if (!isset($unparsedLoadedData['sheets'][$sheetCodeName]) || !is_array($unparsedLoadedData['sheets'][$sheetCodeName])) { + $unparsedLoadedData['sheets'][$sheetCodeName] = []; + } + /** @var array $sheetUnparsedData */ + $sheetUnparsedData = &$unparsedLoadedData['sheets'][$sheetCodeName]; + if (!isset($sheetUnparsedData['pivotTables']) || !is_array($sheetUnparsedData['pivotTables'])) { + $sheetUnparsedData['pivotTables'] = []; + } + /** @var array> $sheetPivotTables */ + $sheetPivotTables = &$sheetUnparsedData['pivotTables']; + $sheetPivotTables[] = [ + 'relFilePath' => $relTarget, + 'path' => $pivotTablePath, + 'content' => $this->getSecurityScannerOrThrow()->scan($this->getFromZipArchive($this->zip, $pivotTablePath)), + ]; + unset($sheetPivotTables, $sheetUnparsedData); + $this->preserveRawPart( + dirname($pivotTablePath) . '/_rels/' . basename($pivotTablePath) . '.rels', + $unparsedLoadedData + ); + } + } + + /** + * Follow a pivot table part's relationships to load its cache definition + * part, preserving the cache definition, its records and all of their rels + * as raw parts. Returns the parsed cache definition XML, or null. + * + * @param mixed[] $unparsedLoadedData + */ + private function readPivotCacheDefinition(string $pivotTablePath, ZipArchive $zip, array &$unparsedLoadedData): ?SimpleXMLElement + { + $relsFileName = dirname($pivotTablePath) . '/_rels/' . basename($pivotTablePath) . '.rels'; + if ($zip->locateName($relsFileName) === false) { + return null; + } + + $rels = $this->loadZip($relsFileName, Namespaces::RELATIONSHIPS); + foreach ($rels->Relationship as $relationship) { + $relAttributes = self::getAttributes($relationship, ''); + if ((string) $relAttributes['Type'] === Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION) { + $cachePath = File::realpath( + dirname($pivotTablePath) . '/' . (string) $relAttributes['Target'] + ); + if (!$this->fileExistsInArchive($this->zip, $cachePath)) { + return null; + } + + $cacheDefinitionXml = $this->loadZip($cachePath, Namespaces::MAIN); + $this->preservePivotCache($cachePath, $unparsedLoadedData); + + return $cacheDefinitionXml; + } + } + + return null; + } + + /** + * Preserve a pivot cache definition (keyed by its zip path so a workbook + * relationship can be recreated), along with its rels and any parts they + * reference (typically the cache records). + * + * @param mixed[] $unparsedLoadedData + */ + private function preservePivotCache(string $cachePath, array &$unparsedLoadedData): void + { + if (!isset($unparsedLoadedData['pivotCacheDefinitions']) || !is_array($unparsedLoadedData['pivotCacheDefinitions'])) { + $unparsedLoadedData['pivotCacheDefinitions'] = []; + } + /** @var array> $cacheDefinitions */ + $cacheDefinitions = &$unparsedLoadedData['pivotCacheDefinitions']; + if (!isset($cacheDefinitions[$cachePath])) { + $cacheDefinitions[$cachePath] = [ + 'path' => $cachePath, + 'content' => $this->getSecurityScannerOrThrow()->scan($this->getFromZipArchive($this->zip, $cachePath)), + ]; + unset($cacheDefinitions); + + $relsFileName = dirname($cachePath) . '/_rels/' . basename($cachePath) . '.rels'; + if ($this->zip->locateName($relsFileName) !== false) { + $this->preserveRawPart($relsFileName, $unparsedLoadedData); + + $rels = $this->loadZip($relsFileName, Namespaces::RELATIONSHIPS); + foreach ($rels->Relationship as $relationship) { + $relAttributes = self::getAttributes($relationship, ''); + $target = File::realpath(dirname($cachePath) . '/' . (string) $relAttributes['Target']); + if ($this->fileExistsInArchive($this->zip, $target)) { + $this->preserveRawPart($target, $unparsedLoadedData); + } + } + } + } + } + + /** + * Store a single part verbatim (keyed by its zip path) so the writer can + * re-add it to the archive without modification. + * + * @param mixed[] $unparsedLoadedData + */ + private function preserveRawPart(string $path, array &$unparsedLoadedData): void + { + if ($this->zip->locateName($path) === false) { + return; + } + if (!isset($unparsedLoadedData['pivotCacheParts']) || !is_array($unparsedLoadedData['pivotCacheParts'])) { + $unparsedLoadedData['pivotCacheParts'] = []; + } + /** @var array $pivotCacheParts */ + $pivotCacheParts = &$unparsedLoadedData['pivotCacheParts']; + $pivotCacheParts[$path] = $this->getSecurityScannerOrThrow()->scan( + $this->getFromZipArchive($this->zip, $path) + ); + unset($pivotCacheParts); + } + /** @return mixed[] */ private static function extractStyles(?SimpleXMLElement $sxml, string $node1, string $node2): array { @@ -2542,16 +2980,18 @@ class Xlsx extends BaseReader $formulaRange = (string) ($attributes['formulaRange'] ?? ''); $twoDigitTextYear = (string) ($attributes['twoDigitTextYear'] ?? ''); $evalError = (string) ($attributes['evalError'] ?? ''); + $attributes2 = self::getAttributes($xml, Namespaces::MISLEADING_FORMAT); + $misleadingFormat = (string) ($attributes2['misleadingFormat'] ?? ''); if (!empty($sqref)) { $explodedSqref = explode(' ', $sqref); $pattern1 = '/^([A-Z]{1,3})([0-9]{1,7})(:([A-Z]{1,3})([0-9]{1,7}))?$/'; foreach ($explodedSqref as $sqref1) { - if (preg_match($pattern1, $sqref1, $matches) === 1) { + if (Preg::isMatch($pattern1, $sqref1, $matches)) { $firstRow = $matches[2]; $firstCol = $matches[1]; - if (array_key_exists(3, $matches)) { - $lastCol = $matches[4]; - $lastRow = $matches[5]; + if ($matches[3] !== null) { + $lastCol = (string) $matches[4]; + $lastRow = (string) $matches[5]; } else { $lastCol = $firstCol; $lastRow = $firstRow; @@ -2563,19 +3003,34 @@ class Xlsx extends BaseReader continue; } if ($numberStoredAsText === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setNumberStoredAsText(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setNumberStoredAsText(true); } if ($formula === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormula(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setFormula(true); } if ($formulaRange === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setFormulaRange(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setFormulaRange(true); } if ($twoDigitTextYear === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setTwoDigitTextYear(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setTwoDigitTextYear(true); } if ($evalError === '1') { - $sheet->getCell("$col$row")->getIgnoredErrors()->setEvalError(true); + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setEvalError(true); + } + if ($misleadingFormat === '1') { + $sheet->getCell("$col$row") + ->getIgnoredErrors() + ->setMisleadingFormat(true); } } } @@ -2584,7 +3039,7 @@ class Xlsx extends BaseReader } } - private static function storeFormulaAttributes(SimpleXMLElement $f, Worksheet $docSheet, string $r): void + protected static function storeFormulaAttributes(SimpleXMLElement $f, Worksheet $docSheet, string $r): void { $formulaAttributes = []; $attributes = $f->attributes(); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php index 88a0831..ff03923 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/AutoFilter.php @@ -25,7 +25,7 @@ class AutoFilter { // Remove all "$" in the auto filter range $attrs = $this->worksheetXml->autoFilter->attributes() ?? []; - $autoFilterRange = (string) preg_replace('/\$/', '', $attrs['ref'] ?? ''); + $autoFilterRange = (string) preg_replace('/\$/', '', (string) ($attrs['ref'] ?? '')); if (str_contains($autoFilterRange, ':')) { $this->readAutoFilter($autoFilterRange); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Chart.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Chart.php index eb79ea7..d00a7ae 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Chart.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Chart.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Chart\AxisText; use PhpOffice\PhpSpreadsheet\Chart\ChartColor; use PhpOffice\PhpSpreadsheet\Chart\DataSeries; use PhpOffice\PhpSpreadsheet\Chart\DataSeriesValues; +use PhpOffice\PhpSpreadsheet\Chart\DataTable; use PhpOffice\PhpSpreadsheet\Chart\GridLines; use PhpOffice\PhpSpreadsheet\Chart\Layout; use PhpOffice\PhpSpreadsheet\Chart\Legend; @@ -91,13 +92,26 @@ class Chart $chartFillColor = null; $gradientArray = []; $gradientLin = null; - $roundedCorners = false; + $roundedCorners = null; + $date1904 = null; + $lang = null; $gapWidth = null; $useUpBars = null; $useDownBars = null; $noBorder = false; + $pageMargins = []; + $pageSetup = []; foreach ($chartElementsC as $chartElementKey => $chartElement) { switch ($chartElementKey) { + case 'printSettings': + if (isset($chartElement->pageMargins)) { + $pageMargins = current((array) $chartElement->pageMargins->attributes()); + } + if (isset($chartElement->pageSetup)) { + $pageSetup = current((array) $chartElement->pageSetup->attributes()); + } + + break; case 'spPr': $children = $chartElementsC->spPr->children($this->aNamespace); if (isset($children->noFill)) { @@ -116,16 +130,22 @@ class Chart break; case 'roundedCorners': - /** @var bool $roundedCorners */ $roundedCorners = self::getAttributeBoolean($chartElementsC->roundedCorners, 'val'); + break; + case 'date1904': + $date1904 = self::getAttributeBoolean($chartElementsC->date1904, 'val'); + + break; + case 'lang': + $lang = self::getAttributeString($chartElementsC->lang, 'val'); + break; case 'chart': foreach ($chartElement as $chartDetailsKey => $chartDetails) { $chartDetails = Xlsx::testSimpleXml($chartDetails); switch ($chartDetailsKey) { case 'autoTitleDeleted': - /** @var bool $autoTitleDeleted */ $autoTitleDeleted = self::getAttributeBoolean($chartElementsC->chart->autoTitleDeleted, 'val'); break; @@ -137,7 +157,7 @@ class Chart break; case 'plotArea': - $plotAreaLayout = $XaxisLabel = $YaxisLabel = null; + $plotAreaLayout = $XaxisLabel = $YaxisLabel = $dataTable = null; $plotSeries = $plotAttributes = []; $catAxRead = false; $plotNoFill = false; @@ -313,7 +333,6 @@ class Chart break; case 'scatterChart': - /** @var string $scatterStyle */ $scatterStyle = self::getAttributeString($chartDetail->scatterStyle, 'val'); $plotSer = $this->chartDataSeries($chartDetail, $chartDetailKey); $plotSer->setPlotStyle($scatterStyle); @@ -330,7 +349,6 @@ class Chart break; case 'radarChart': - /** @var string $radarStyle */ $radarStyle = self::getAttributeString($chartDetail->radarStyle, 'val'); $plotSer = $this->chartDataSeries($chartDetail, $chartDetailKey); $plotSer->setPlotStyle($radarStyle); @@ -360,6 +378,10 @@ class Chart } $plotAttributes = $this->readChartAttributes($chartDetail); + break; + case 'dTable': + $dataTable = $this->readDataTable($chartDetail); + break; } } @@ -367,6 +389,11 @@ class Chart $plotAreaLayout = new Layout(); } $plotArea = new PlotArea($plotAreaLayout, $plotSeries); + if ($dataTable !== null) { + $plotArea->setDataTable( + $dataTable + ); + } $this->setChartAttributes($plotAreaLayout, $plotAttributes); if ($plotNoFill) { $plotArea->setNoFill(true); @@ -474,23 +501,18 @@ class Chart if ($chartBorderLines !== null) { $chart->setBorderLines($chartBorderLines); } - $chart->setNoBorder($noBorder); - $chart->setRoundedCorners($roundedCorners); - if (is_bool($autoTitleDeleted)) { - $chart->setAutoTitleDeleted($autoTitleDeleted); - } - if (is_int($rotX)) { - $chart->setRotX($rotX); - } - if (is_int($rotY)) { - $chart->setRotY($rotY); - } - if (is_int($rAngAx)) { - $chart->setRAngAx($rAngAx); - } - if (is_int($perspective)) { - $chart->setPerspective($perspective); - } + $chart + ->setNoBorder($noBorder) + ->setRoundedCorners($roundedCorners) + ->setDate1904($date1904) + ->setLang($lang) + ->setPageMargins($pageMargins) + ->setPageSetup($pageSetup) + ->setAutoTitleDeleted($autoTitleDeleted) + ->setRotX($rotX) + ->setRotY($rotY) + ->setRAngAx($rAngAx) + ->setPerspective($perspective); return $chart; } @@ -1206,7 +1228,10 @@ class Chart } $fontArray['bold'] = (bool) self::getAttributeBoolean($titleDetailPart->pPr->defRPr, 'b'); $fontArray['italic'] = (bool) self::getAttributeBoolean($titleDetailPart->pPr->defRPr, 'i'); - $fontArray['underscore'] = self::getAttributeString($titleDetailPart->pPr->defRPr, 'u'); + $temp = self::getAttributeString($titleDetailPart->pPr->defRPr, 'u'); + if (is_string($temp) && $temp !== '') { + $fontArray['underline'] = $temp; + } $strikethrough = self::getAttributeString($titleDetailPart->pPr->defRPr, 'strike'); if ($strikethrough !== null) { if ($strikethrough == 'noStrike') { @@ -1288,12 +1313,38 @@ class Chart $plotAttributes['labelEffects'] = $labelEffects; } } + if (isset($txpr->bodyPr)) { + $plotAttributes['bodyPr'] = current((array) $txpr->bodyPr->attributes()); + } } } return $plotAttributes; } + private function readDataTable(SimpleXMLElement $chartDetail): DataTable + { + $dataTable = new DataTable(); + $temp = self::getAttributeBoolean($chartDetail->showHorzBorder, 'val'); + if ($temp !== null) { + $dataTable->setShowHorizontalBorder($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showVertBorder, 'val'); + if ($temp !== null) { + $dataTable->setShowVerticalBorder($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showOutline, 'val'); + if ($temp !== null) { + $dataTable->setShowOutline($temp); + } + $temp = self::getAttributeBoolean($chartDetail->showKeys, 'val'); + if ($temp !== null) { + $dataTable->setShowKeys($temp); + } + + return $dataTable; + } + /** @param array $plotAttributes */ private function setChartAttributes(Layout $plotArea, array $plotAttributes): void { @@ -1332,6 +1383,11 @@ class Chart /** @var ?Font $plotAttributeValue */ $plotArea->setLabelFont($plotAttributeValue); + break; + case 'bodyPr': + /** @var mixed $plotAttributeValue */ + $plotArea->setBodyPr($plotAttributeValue); + break; } } @@ -1465,11 +1521,8 @@ class Chart if (is_numeric($lineWidthTemp)) { $lineWidth = ChartProperties::xmlToPoints($lineWidthTemp); } - /** @var string $compoundType */ $compoundType = self::getAttributeString($sppr->ln, 'cmpd'); - /** @var string $dashType */ $dashType = self::getAttributeString($sppr->ln->prstDash, 'val'); - /** @var string $capType */ $capType = self::getAttributeString($sppr->ln, 'cap'); if (isset($sppr->ln->miter)) { $joinType = ChartProperties::LINE_STYLE_JOIN_MITER; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php index 347b1b4..7af514c 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ColumnAndRowAttributes.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Reader\Xlsx; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Reader\DefaultReadFilter; use PhpOffice\PhpSpreadsheet\Reader\IReadFilter; @@ -157,7 +158,7 @@ class ColumnAndRowAttributes extends BaseParserClass for ($columnAddress = $startColumn; $columnAddress !== $endColumn; StringHelper::stringIncrement($columnAddress)) { $columnAttributes[$columnAddress] = $this->readColumnRangeAttributes($column, $readDataOnly); - if ((int) ($column['max']) == 16384) { + if ((int) ($column['max']) === AddressRange::MAX_COLUMN_INT) { break; } } @@ -209,10 +210,17 @@ class ColumnAndRowAttributes extends BaseParserClass { $rowAttributes = []; + $rowIndex = 0; foreach ($worksheetRow as $rowx) { $row = $rowx->attributes(); + ++$rowIndex; if ($row !== null && (!$ignoreRowsWithNoCells || isset($rowx->c))) { - $rowIndex = (int) $row['r']; + if (isset($row['r'])) { + $rowIndex = (int) $row['r']; + } + if ($rowIndex < 1 || $rowIndex > AddressRange::MAX_ROW) { + continue; + } if (!$readDataOnly) { if (isset($row['ht'])) { $rowAttributes[$rowIndex]['rowHeight'] = (float) $row['ht']; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php index e2cf3b2..e41806e 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/ConditionalStyles.php @@ -302,7 +302,7 @@ class ConditionalStyles //conditionalFormatValueObjects $cfvoXml = $cfRule->dataBar->cfvo; $cfvoIndex = 0; - foreach ((count($cfvoXml) > 1 ? $cfvoXml : [$cfvoXml]) as $cfvo) { //* @phpstan-ignore-line + foreach ((count($cfvoXml) > 1 ? $cfvoXml : [$cfvoXml]) as $cfvo) { //* @phpstan-ignore foreach.nonIterable (I don't know how to fix this) /** @var SimpleXMLElement $cfvo */ if ($cfvoIndex === 0) { $dataBar->setMinimumConditionalFormatValueObject(new ConditionalFormatValueObject((string) $cfvo['type'], (string) $cfvo['val'])); @@ -333,6 +333,9 @@ class ConditionalStyles $attr = $cfvoXml->attributes() ?? []; $type = (string) ($attr['type'] ?? ''); $val = $attr['val'] ?? null; + if ($val instanceof SimpleXMLElement) { + $val = (string) $val; + } if ($idx === 0) { $method = 'setMinimumConditionalFormatValueObject'; } elseif ($idx === 1 && $count === 3) { @@ -402,7 +405,7 @@ class ConditionalStyles { if (isset($cfRule->extLst)) { $ns = $cfRule->extLst->getNamespaces(true); - foreach ((count($cfRule->extLst) > 0 ? $cfRule->extLst->ext : [$cfRule->extLst->ext]) as $ext) { //* @phpstan-ignore-line + foreach ((count($cfRule->extLst) > 0 ? $cfRule->extLst->ext : [$cfRule->extLst->ext]) as $ext) { //* @phpstan-ignore foreach.nonIterable (I don't know how to fix this) /** @var SimpleXMLElement $ext */ $extId = (string) $ext->children($ns['x14'])->id; if (isset($conditionalFormattingRuleExtensions[$extId]) && (string) $ext['uri'] === '{B025F937-C7B1-47D3-B67F-A62EFF666E3E}') { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php index 2c2d4af..f9d7218 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Hyperlinks.php @@ -44,20 +44,27 @@ class Hyperlinks $attributes = Xlsx::getAttributes($hyperlink); foreach (Coordinate::extractAllCellReferencesInRange($attributes->ref) as $cellReference) { $cell = $worksheet->getCell($cellReference); + + $hyperlinkUrl = ''; if (isset($linkRel['id'])) { $hyperlinkUrl = $this->hyperlinks[(string) $linkRel['id']] ?? ''; - if (isset($attributes['location'])) { + } + if (isset($attributes['location'])) { + if ($hyperlinkUrl === '') { + $hyperlinkUrl = 'sheet://' . (string) $attributes['location']; + } else { $hyperlinkUrl .= '#' . (string) $attributes['location']; } - $cell->getHyperlink()->setUrl($hyperlinkUrl); - } elseif (isset($attributes['location'])) { - $cell->getHyperlink()->setUrl('sheet://' . (string) $attributes['location']); } - + $cell->getHyperlink()->setUrl($hyperlinkUrl); // Tooltip if (isset($attributes['tooltip'])) { $cell->getHyperlink()->setTooltip((string) $attributes['tooltip']); } + + if (isset($attributes['display'])) { + $cell->getHyperlink()->setDisplay((string) $attributes['display']); + } } } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php index 7356cd8..c414c09 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Namespaces.php @@ -88,6 +88,12 @@ class Namespaces const RELATIONSHIPS_TABLE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/table'; + const RELATIONSHIPS_PIVOT_TABLE = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotTable'; + + const RELATIONSHIPS_PIVOT_CACHE_DEFINITION = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheDefinition'; + + const RELATIONSHIPS_PIVOT_CACHE_RECORDS = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/pivotCacheRecords'; + const SPREADSHEETML_AC = 'http://schemas.microsoft.com/office/spreadsheetml/2009/9/ac'; const DC_ELEMENTS = 'http://purl.org/dc/elements/1.1/'; @@ -121,4 +127,20 @@ class Namespaces const DYNAMIC_ARRAY = 'http://schemas.microsoft.com/office/spreadsheetml/2017/dynamicarray'; const DYNAMIC_ARRAY_RICHDATA = 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata'; + + const RELATIONSHIPS_RICH_VALUE_TYPES = 'http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueTypes'; + + const RELATIONSHIPS_RICH_VALUE_STRUCTURE = 'http://schemas.microsoft.com/office/2017/06/relationships/rdRichValueStructure'; + + const RELATIONSHIPS_RICH_VALUE = 'http://schemas.microsoft.com/office/2017/06/relationships/rdRichValue'; + + const RELATIONSHIPS_RICH_VALUE_REL = 'http://schemas.microsoft.com/office/2022/10/relationships/richValueRel'; + + const FEATURE_PROPERTY_BAG = 'http://schemas.microsoft.com/office/spreadsheetml/2022/featurepropertybag'; + const RELATIONSHIPS_FEATURE_PROPERTY_BAG = 'http://schemas.microsoft.com/office/2022/11/relationships/FeaturePropertyBag'; + const STYLE_CHECKBOX_URI = '{C7286773-470A-42A8-94C5-96B5CB345126}'; + + const SPARKLINE_URI = '{05C60535-1F16-4fd2-B633-F4F36F0B64E0}'; + + const MISLEADING_FORMAT = 'http://schemas.microsoft.com/office/spreadsheetml/2018/08/main'; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/PivotTableReader.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/PivotTableReader.php new file mode 100644 index 0000000..d3e2e3c --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/PivotTableReader.php @@ -0,0 +1,141 @@ +worksheet = $worksheet; + $this->pivotTableXml = $pivotTableXml; + $this->cacheDefinitionXml = $cacheDefinitionXml; + } + + /** + * Parse the pivot table definition and add it to the worksheet. + */ + public function load(): void + { + $attributes = $this->pivotTableXml->attributes(); + + $pivotTable = new PivotTable((string) ($attributes['name'] ?? '')); + + if (isset($this->pivotTableXml->location)) { + $locationAttributes = $this->pivotTableXml->location->attributes(); + $pivotTable->setLocation((string) ($locationAttributes['ref'] ?? '')); + } + + $cacheDefinition = $this->readCacheDefinition( + isset($attributes['cacheId']) ? (int) $attributes['cacheId'] : null + ); + $pivotTable->setCacheDefinition($cacheDefinition); + + if (isset($this->pivotTableXml->pivotFields)) { + $this->readFields($pivotTable, $cacheDefinition); + } + + $this->worksheet->addPivotTable($pivotTable); + } + + /** + * Build the cache definition (source range + field names) if we have it. + */ + private function readCacheDefinition(?int $cacheId): PivotCacheDefinition + { + $cacheDefinition = new PivotCacheDefinition($cacheId); + + if ($this->cacheDefinitionXml !== null) { + $source = $this->cacheDefinitionXml->cacheSource; + if (isset($source->worksheetSource)) { + $sourceAttributes = $source->worksheetSource->attributes(); + if (isset($sourceAttributes['sheet'])) { + $cacheDefinition->setSourceWorksheet((string) $sourceAttributes['sheet']); + } + if (isset($sourceAttributes['ref'])) { + $cacheDefinition->setSourceRange((string) $sourceAttributes['ref']); + } + } + + if (isset($this->cacheDefinitionXml->cacheFields)) { + foreach ($this->cacheDefinitionXml->cacheFields->cacheField as $cacheField) { + $fieldAttributes = $cacheField->attributes(); + $cacheDefinition->addCacheField((string) ($fieldAttributes['name'] ?? '')); + } + } + } + + return $cacheDefinition; + } + + /** + * Read the pivotFields and the axis/data placement sections into fields. + */ + private function readFields(PivotTable $pivotTable, PivotCacheDefinition $cacheDefinition): void + { + $index = 0; + /** @var PivotField[] $fields */ + $fields = []; + foreach ($this->pivotTableXml->pivotFields->pivotField as $pivotFieldXml) { + $field = new PivotField($index, (string) ($cacheDefinition->getCacheFieldName($index) ?? '')); + + $fieldAttributes = $pivotFieldXml->attributes(); + if (isset($fieldAttributes['axis'])) { + $field->setAxis((string) $fieldAttributes['axis']); + } + + $fields[$index] = $field; + $pivotTable->addField($field); + ++$index; + } + + $this->markDataFields($fields); + } + + /** + * Flag the fields referenced by as value fields, and record + * their aggregation function. + * + * @param PivotField[] $fields keyed by field index + */ + private function markDataFields(array $fields): void + { + if (isset($this->pivotTableXml->dataFields)) { + foreach ($this->pivotTableXml->dataFields->dataField as $dataFieldXml) { + $dataAttributes = $dataFieldXml->attributes(); + if (isset($dataAttributes['fld'])) { + $fieldIndex = (int) $dataAttributes['fld']; + if (isset($fields[$fieldIndex])) { + $fields[$fieldIndex]->setDataField(true); + // "sum" is the default subtotal when the attribute is absent. + $fields[$fieldIndex]->setSubtotal( + isset($dataAttributes['subtotal']) ? (string) $dataAttributes['subtotal'] : 'sum' + ); + } + } + } + } + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Sparklines.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Sparklines.php new file mode 100644 index 0000000..04d1c3b --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Sparklines.php @@ -0,0 +1,163 @@ +worksheet = $workSheet; + $this->worksheetXml = $worksheetXml; + } + + public function load(): void + { + if (!isset($this->worksheetXml->extLst->ext)) { + return; + } + + foreach ($this->worksheetXml->extLst->ext as $ext) { + $extAttrs = $ext->attributes() ?? []; + if ((string) ($extAttrs['uri'] ?? '') !== Namespaces::SPARKLINE_URI) { + continue; + } + + $groups = $ext->children(Namespaces::DATA_VALIDATIONS1)->sparklineGroups; + if (!isset($groups)) { + continue; + } + + $groupList = $groups->children(Namespaces::DATA_VALIDATIONS1)->sparklineGroup ?? null; + if ($groupList === null) { + continue; + } + + foreach ($groupList as $groupXml) { + $group = $this->readGroup($groupXml); + if ($group !== null) { + $this->worksheet->addSparklineGroup($group); + } + } + } + } + + private function readGroup(SimpleXMLElement $groupXml): ?SparklineGroup + { + $group = new SparklineGroup(); + $attributes = $groupXml->attributes() ?? []; + + $typeValue = isset($attributes['type']) ? (string) $attributes['type'] : SparklineType::Line->value; + $group->setType(SparklineType::tryFrom($typeValue) ?? SparklineType::Line); + + if (isset($attributes['lineWeight'])) { + $group->setLineWeight((float) $attributes['lineWeight']); + } + if (isset($attributes['displayEmptyCellsAs'])) { + $group->setDisplayEmptyCellsAs((string) $attributes['displayEmptyCellsAs']); + } + if (isset($attributes['minAxisType'])) { + $group->setMinAxisType((string) $attributes['minAxisType']); + } + if (isset($attributes['maxAxisType'])) { + $group->setMaxAxisType((string) $attributes['maxAxisType']); + } + if (isset($attributes['manualMin'])) { + $group->setManualMin((float) $attributes['manualMin']); + } + if (isset($attributes['manualMax'])) { + $group->setManualMax((float) $attributes['manualMax']); + } + + $group->setDisplayMarkers(self::boolAttr($attributes, 'markers')); + $group->setDisplayHigh(self::boolAttr($attributes, 'high')); + $group->setDisplayLow(self::boolAttr($attributes, 'low')); + $group->setDisplayFirst(self::boolAttr($attributes, 'first')); + $group->setDisplayLast(self::boolAttr($attributes, 'last')); + $group->setDisplayNegative(self::boolAttr($attributes, 'negative')); + $group->setDisplayXAxis(self::boolAttr($attributes, 'displayXAxis')); + $group->setDisplayHidden(self::boolAttr($attributes, 'displayHidden')); + $group->setRightToLeft(self::boolAttr($attributes, 'rightToLeft')); + + $this->readColors($group, $groupXml); + $this->readSparklines($group, $groupXml); + + // A group with no sparklines carries no useful information. + return $group->getSparklines() === [] ? null : $group; + } + + private function readColors(SparklineGroup $group, SimpleXMLElement $groupXml): void + { + $children = $groupXml->children(Namespaces::DATA_VALIDATIONS1); + $group->setColorSeries(self::color($children->colorSeries)); + $group->setColorNegative(self::color($children->colorNegative)); + $group->setColorAxis(self::color($children->colorAxis)); + $group->setColorMarkers(self::color($children->colorMarkers)); + $group->setColorFirst(self::color($children->colorFirst)); + $group->setColorLast(self::color($children->colorLast)); + $group->setColorHigh(self::color($children->colorHigh)); + $group->setColorLow(self::color($children->colorLow)); + } + + private function readSparklines(SparklineGroup $group, SimpleXMLElement $groupXml): void + { + $sparklinesXml = $groupXml->children(Namespaces::DATA_VALIDATIONS1)->sparklines; + if (!isset($sparklinesXml)) { + return; + } + + $sparklineList = $sparklinesXml->children(Namespaces::DATA_VALIDATIONS1)->sparkline ?? null; + if ($sparklineList === null) { + return; + } + + foreach ($sparklineList as $sparklineXml) { + $xm = $sparklineXml->children(Namespaces::DATA_VALIDATIONS2); + $dataRange = isset($xm->f) ? (string) $xm->f : ''; + $location = isset($xm->sqref) ? (string) $xm->sqref : ''; + if ($location === '') { + continue; + } + $group->addSparkline(new Sparkline($location, $dataRange)); + } + } + + /** + * @param iterable|SimpleXMLElement $attributes + */ + private static function boolAttr(mixed $attributes, string $name): bool + { + if (!($attributes instanceof SimpleXMLElement)) { + return false; + } + $attrs = $attributes; + + return isset($attrs[$name]) && self::boolean((string) $attrs[$name]); + } + + /** + * Extract an ARGB colour from an `x14:color*` element's `rgb` attribute. + */ + private static function color(?SimpleXMLElement $colorXml): ?string + { + if ($colorXml === null || !isset($colorXml[0])) { + return null; + } + $attrs = $colorXml->attributes() ?? []; + + return isset($attrs['rgb']) ? (string) $attrs['rgb'] : null; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Styles.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Styles.php index 54e26e7..1ef1459 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Styles.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/Styles.php @@ -184,8 +184,8 @@ class Styles extends BaseParserClass } $fillStyle->setRotation((float) ($attr['degree'])); $gradientFill->registerXPathNamespace('sml', Namespaces::MAIN); - $fillStyle->getStartColor()->setARGB($this->readColor(self::getArrayItem($gradientFill->xpath('sml:stop[@position=0]'))->color)); //* @phpstan-ignore-line - $fillStyle->getEndColor()->setARGB($this->readColor(self::getArrayItem($gradientFill->xpath('sml:stop[@position=1]'))->color)); //* @phpstan-ignore-line + $fillStyle->getStartColor()->setARGB($this->readColor(self::getArrayItem($gradientFill->xpath('sml:stop[@position=0]'))->color)); //* @phpstan-ignore property.nonObject (xpath may return null) + $fillStyle->getEndColor()->setARGB($this->readColor(self::getArrayItem($gradientFill->xpath('sml:stop[@position=1]'))->color)); //* @phpstan-ignore property.nonObject (xpath may return null) } elseif ($fillStyleXml->patternFill) { $defaultFillStyle = ($fillStyle->getFillType() !== null) ? Fill::FILL_NONE : ''; $fgFound = false; @@ -549,10 +549,10 @@ class Styles extends BaseParserClass /** * Get array item. * - * @param false|mixed[] $array (usually array, in theory can be false) + * @param null|false|SimpleXMLElement[] $array (usually array, in theory can be false) */ private static function getArrayItem(mixed $array): ?SimpleXMLElement { - return is_array($array) ? ($array[0] ?? null) : null; // @phpstan-ignore-line + return is_array($array) ? ($array[0] ?? null) : null; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/TableReader.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/TableReader.php index 121697c..5c4556e 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/TableReader.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/TableReader.php @@ -34,7 +34,7 @@ class TableReader { $this->tableAttributes = $this->tableXml->attributes() ?? []; // Remove all "$" in the table range - $tableRange = (string) preg_replace('/\$/', '', $this->tableAttributes['ref'] ?? ''); + $tableRange = (string) preg_replace('/\$/', '', (string) ($this->tableAttributes['ref'] ?? '')); if (str_contains($tableRange, ':')) { $this->readTable($tableRange, $tableStyles, $dxfs); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/WorkbookView.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/WorkbookView.php index 429efb0..10f26e9 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/WorkbookView.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xlsx/WorkbookView.php @@ -56,7 +56,7 @@ class WorkbookView /** * Convert an 'xsd:boolean' XML value to a PHP boolean value. * A valid 'xsd:boolean' XML value can be one of the following - * four values: 'true', 'false', '1', '0'. It is case sensitive. + * four values: 'true', 'false', '1', '0'. It is case-sensitive. * * Note that just doing '(bool) $xsdBoolean' is not safe, * since '(bool) "false"' returns true. diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml.php index 08aa7f4..9e5bd99 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml.php @@ -2,9 +2,11 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; use DateTime; use DateTimeZone; use PhpOffice\PhpSpreadsheet\Cell\AddressHelper; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\DefinedName; @@ -52,9 +54,14 @@ class Xml extends BaseReader public static function unentity(string $contents): string { - $contents = preg_replace('/&(amp|lt|gt|quot|apos);/', "\u{fffe}\u{feff}\$1;", trim($contents)) ?? $contents; + // fffe is invalid, replace with replacement char + $contents = str_replace("\u{fffe}", "\u{fffd}", $contents); + // use positive lookahead to "protect" valid xml entities + $contents = Preg::replace('/&(?=(?:amp|lt|gt|quot|apos|#[0-9]+|#x[0-9a-fA-F]+);)/', "\u{fffe}", trim($contents)); + // now decode remaining html entities $contents = html_entity_decode($contents, ENT_NOQUOTES | ENT_SUBSTITUTE | ENT_HTML401, 'UTF-8'); - $contents = str_replace("\u{fffe}\u{feff}", '&', $contents); + // Escape remaining ampersands, restore those which were replaced with fffe + $contents = str_replace(['&', "\u{fffe}"], ['&', '&'], $contents); return $contents; } @@ -93,6 +100,7 @@ class Xml extends BaseReader ]; // Open file + File::assertFile($filename); $data = (string) file_get_contents($filename); $data = $this->getSecurityScannerOrThrow()->scan($data); @@ -235,7 +243,7 @@ class Xml extends BaseReader } } - $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1); + $tmpInfo['lastColumnLetter'] = Coordinate::stringFromColumnIndex($tmpInfo['lastColumnIndex'] + 1, true); $tmpInfo['totalColumns'] = $tmpInfo['lastColumnIndex'] + 1; $tmpInfo['sheetState'] = Worksheet::SHEETSTATE_VISIBLE; @@ -351,7 +359,7 @@ class Xml extends BaseReader } } - $columnID = 'A'; + $columnIndex = $oldColumnIndex = 0; if (isset($worksheet->Table->Column)) { foreach ($worksheet->Table->Column as $columnData) { $columnData_ss = self::getAttributes($columnData, self::NAMESPACES_SS); @@ -363,8 +371,12 @@ class Xml extends BaseReader } } if (isset($columnData_ss['Index'])) { - $columnID = Coordinate::stringFromColumnIndex((int) $columnData_ss['Index']); + $columnIndex = (int) $columnData_ss['Index']; + } elseif ($columnIndex === $oldColumnIndex) { + ++$columnIndex; } + $oldColumnIndex = $columnIndex; + $columnID = Coordinate::stringFromColumnIndex($columnIndex); $columnWidth = null; if (isset($columnData_ss['Width'])) { $columnWidth = $columnData_ss['Width']; @@ -374,49 +386,59 @@ class Xml extends BaseReader $columnVisible = ((string) $columnData_ss['Hidden']) !== '1'; } while ($colspan >= 0) { - /** @var string $columnID */ if (isset($columnWidth)) { - $spreadsheet->getActiveSheet()->getColumnDimension($columnID)->setWidth($columnWidth / 5.4); + $spreadsheet->getActiveSheet() + ->getColumnDimension($columnID) + ->setWidth($columnWidth / 5.4); } if (isset($columnVisible)) { - $spreadsheet->getActiveSheet()->getColumnDimension($columnID)->setVisible($columnVisible); + $spreadsheet->getActiveSheet() + ->getColumnDimension($columnID) + ->setVisible($columnVisible); } + ++$columnIndex; StringHelper::stringIncrement($columnID); --$colspan; } } } - $rowID = 1; + $rowID = 0; if (isset($worksheet->Table->Row)) { $additionalMergedCells = 0; foreach ($worksheet->Table->Row as $rowData) { + ++$rowID; $rowHasData = false; $row_ss = self::getAttributes($rowData, self::NAMESPACES_SS); if (isset($row_ss['Index'])) { $rowID = (int) $row_ss['Index']; } + if ($rowID < 1 || $rowID > AddressRange::MAX_ROW) { + continue; + } if (isset($row_ss['Hidden'])) { $rowVisible = ((string) $row_ss['Hidden']) !== '1'; $spreadsheet->getActiveSheet()->getRowDimension($rowID)->setVisible($rowVisible); } - $columnID = 'A'; + $columnIndex = $oldColumnIndex = 0; foreach ($rowData->Cell as $cell) { $arrayRef = ''; $cell_ss = self::getAttributes($cell, self::NAMESPACES_SS); if (isset($cell_ss['Index'])) { - $columnID = Coordinate::stringFromColumnIndex((int) $cell_ss['Index']); + $columnIndex = (int) $cell_ss['Index']; + } elseif ($columnIndex === $oldColumnIndex) { + ++$columnIndex; } + $oldColumnIndex = $columnIndex; + $columnID = Coordinate::stringFromColumnIndex($columnIndex); $cellRange = $columnID . $rowID; if (isset($cell_ss['ArrayRange'])) { $arrayRange = (string) $cell_ss['ArrayRange']; - $arrayRef = AddressHelper::convertFormulaToA1($arrayRange, $rowID, Coordinate::columnIndexFromString($columnID)); + $arrayRef = AddressHelper::convertFormulaToA1($arrayRange, $rowID, $columnIndex); } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { - StringHelper::stringIncrement($columnID); - + if (!$this->readFilter->readCell($columnID, $rowID, $worksheetName)) { continue; } @@ -428,7 +450,7 @@ class Xml extends BaseReader $columnTo = $columnID; if (isset($cell_ss['MergeAcross'])) { $additionalMergedCells += (int) $cell_ss['MergeAcross']; - $columnTo = Coordinate::stringFromColumnIndex((int) (Coordinate::columnIndexFromString($columnID) + $cell_ss['MergeAcross'])); + $columnTo = Coordinate::stringFromColumnIndex($columnIndex + (int) $cell_ss['MergeAcross']); } $rowTo = $rowID; if (isset($cell_ss['MergeDown'])) { @@ -506,11 +528,21 @@ class Xml extends BaseReader $originalType = $type; if ($hasCalculatedValue) { $type = DataType::TYPE_FORMULA; - $columnNumber = Coordinate::columnIndexFromString($columnID); - $cellDataFormula = AddressHelper::convertFormulaToA1($cellDataFormula, $rowID, $columnNumber); + $cellDataFormula = AddressHelper::convertFormulaToA1($cellDataFormula, $rowID, $columnIndex); } - $spreadsheet->getActiveSheet()->getCell($columnID . $rowID)->setValueExplicit((($hasCalculatedValue) ? $cellDataFormula : $cellValue), $type); + $hyperlink = null; + if ($spreadsheet->getActiveSheet()->hyperlinkExists($columnID . $rowID)) { + $hyperlink = $spreadsheet->getActiveSheet()->getHyperlink($columnID . $rowID); + } + $spreadsheet->getActiveSheet() + ->getCell($columnID . $rowID) + ->setValueExplicit( + $hasCalculatedValue ? $cellDataFormula : $cellValue, + $type + ); + $spreadsheet->getActiveSheet() + ->setHyperlink($columnID . $rowID, $hyperlink); if ($hasCalculatedValue) { $spreadsheet->getActiveSheet()->getCell($columnID . $rowID)->setCalculatedValue($cellValue, $originalType === DataType::TYPE_NUMERIC); } @@ -528,9 +560,9 @@ class Xml extends BaseReader ->applyFromArray($this->styles[$style]); } } - StringHelper::stringIncrement($columnID); + ++$columnIndex; while ($additionalMergedCells > 0) { - StringHelper::stringIncrement($columnID); + ++$columnIndex; --$additionalMergedCells; } } @@ -541,8 +573,6 @@ class Xml extends BaseReader $spreadsheet->getActiveSheet()->getRowDimension($rowID)->setRowHeight((float) $rowHeight); } } - - ++$rowID; } } @@ -627,7 +657,7 @@ class Xml extends BaseReader } $rangeCalculated = false; if (isset($xmlX->WorksheetOptions->Panes->Pane->RangeSelection)) { - if (1 === preg_match('/^R(\d+)C(\d+):R(\d+)C(\d+)$/', (string) $xmlX->WorksheetOptions->Panes->Pane->RangeSelection, $selectionMatches)) { + if (Preg::isMatch('/^R(\d+)C(\d+):R(\d+)C(\d+)$/', (string) $xmlX->WorksheetOptions->Panes->Pane->RangeSelection, $selectionMatches)) { $selectedCell = Coordinate::stringFromColumnIndex((int) $selectionMatches[2]) . $selectionMatches[1] . ':' diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/PageSettings.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/PageSettings.php index 2434e86..e74f045 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/PageSettings.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/PageSettings.php @@ -10,16 +10,27 @@ use stdClass; class PageSettings { - /** @var (object{orientation: string, scale: ?int, printOrder: ?string, - * paperSize: int, - * horizontalCentered: bool, verticalCentered: bool, leftMargin: float, rightMargin: float, topMargin: float, - * bottomMargin: float, headerMargin: float, footerMargin: float}&stdClass) */ + /** @var (object{ + * orientation: string, + * scale: ?int, + * printOrder: ?string, + * paperSize: int, + * horizontalCentered: bool, + * verticalCentered: bool, + * leftMargin: float, + * rightMargin: float, + * topMargin: float, + * bottomMargin: float, + * headerMargin: float, + * footerMargin: float, + * } &stdClass) + */ private stdClass $printSettings; public function __construct(SimpleXMLElement $xmlX) { $printSettings = $this->pageSetup($xmlX, $this->getPrintDefaults()); - $this->printSettings = $this->printSetup($xmlX, $printSettings); //* @phpstan-ignore-line + $this->printSettings = $this->printSetup($xmlX, $printSettings); //* @phpstan-ignore assign.propertyType (I don't know what Phpstan wants) } public function loadPageSettings(Spreadsheet $spreadsheet): void diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Border.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Border.php index 8a5407d..e2a980f 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Border.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Border.php @@ -59,58 +59,48 @@ class Border extends StyleBase { $style = []; - $diagonalDirection = ''; - $borderPosition = ''; + $diagonalDirection = Borders::DIAGONAL_NONE; foreach ($styleData->Border as $borderStyle) { $borderAttributes = self::getAttributes($borderStyle, $namespaces['ss']); + /** @var array{color?: array{rgb: string}, borderStyle: string} */ $thisBorder = []; $styleType = (string) $borderAttributes->Weight; $styleType .= strtolower((string) $borderAttributes->LineStyle); $thisBorder['borderStyle'] = self::BORDER_MAPPINGS['borderStyle'][$styleType] ?? BorderStyle::BORDER_NONE; - foreach ($borderAttributes as $borderStyleKey => $borderStyleValuex) { - $borderStyleValue = (string) $borderStyleValuex; - switch ($borderStyleKey) { - case 'Position': - /** @var string $diagonalDirection */ - [$borderPosition, $diagonalDirection] - = $this->parsePosition($borderStyleValue, $diagonalDirection); - - break; - case 'Color': - $borderColour = substr($borderStyleValue, 1); - $thisBorder['color']['rgb'] = $borderColour; - - break; - } + $color = (string) ($borderAttributes['Color'] ?? ''); + if ($color !== '') { + $thisBorder['color']['rgb'] = substr($color, 1); } - - /** @var int|string $borderPosition */ - if ($borderPosition) { - $style['borders'][$borderPosition] = $thisBorder; - } elseif ($diagonalDirection) { - $style['borders']['diagonalDirection'] = $diagonalDirection; - $style['borders']['diagonal'] = $thisBorder; + $position = (string) ($borderAttributes['Position'] ?? ''); + if ($position !== '') { + [$borderPosition, $diagonalDirection] = $this->parsePosition($position, $diagonalDirection); + if ($borderPosition) { + $style['borders'][$borderPosition] = $thisBorder; + } elseif ($diagonalDirection !== Borders::DIAGONAL_NONE) { + $style['borders']['diagonalDirection'] = $diagonalDirection; + $style['borders']['diagonal'] = $thisBorder; + } } } return $style; } - /** @return mixed[] */ - protected function parsePosition(string $borderStyleValue, string $diagonalDirection): array + /** @return array{0: string, 1: int} */ + protected function parsePosition(string $borderStyleValue, int $diagonalDirection): array { - // TODO diagonalDirection seems to return int not string $borderStyleValue = strtolower($borderStyleValue); + $borderPosition = ''; if (in_array($borderStyleValue, self::BORDER_POSITIONS)) { $borderPosition = $borderStyleValue; } elseif ($borderStyleValue === 'diagonalleft') { - $diagonalDirection = $diagonalDirection ? Borders::DIAGONAL_BOTH : Borders::DIAGONAL_DOWN; + $diagonalDirection = ($diagonalDirection !== Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_BOTH : Borders::DIAGONAL_DOWN; } elseif ($borderStyleValue === 'diagonalright') { - $diagonalDirection = $diagonalDirection ? Borders::DIAGONAL_BOTH : Borders::DIAGONAL_UP; + $diagonalDirection = ($diagonalDirection !== Borders::DIAGONAL_NONE) ? Borders::DIAGONAL_BOTH : Borders::DIAGONAL_UP; } - return [$borderPosition ?? null, $diagonalDirection]; + return [$borderPosition, $diagonalDirection]; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Fill.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Fill.php index 8dc7232..39a9f80 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Fill.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Reader/Xml/Style/Fill.php @@ -30,9 +30,24 @@ class Fill extends StyleBase ], ]; - /** @return mixed[] */ + /** @return array{ + * fill?: array{ + * endColor?: array{rgb:string}, + * startColor?: array{rgb:string}, + * fillType?: string, + * } + * } + */ public function parseStyle(SimpleXMLElement $styleAttributes): array { + /** @var array{ + * fill?: array{ + * endColor?: array{rgb:string}, + * startColor?: array{rgb:string}, + * fillType?: string, + * } + * } + */ $style = []; foreach ($styleAttributes as $styleAttributeKey => $styleAttributeValuex) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/ReferenceHelper.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/ReferenceHelper.php index be65398..03b76c7 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/ReferenceHelper.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/ReferenceHelper.php @@ -330,15 +330,37 @@ class ReferenceHelper protected function adjustProtectedCells(Worksheet $worksheet, int $numberOfColumns, int $numberOfRows): void { $aProtectedCells = $worksheet->getProtectedCellRanges(); - ($numberOfColumns > 0 || $numberOfRows > 0) - ? uksort($aProtectedCells, [self::class, 'cellReverseSort']) - : uksort($aProtectedCells, [self::class, 'cellSort']); - foreach ($aProtectedCells as $cellAddress => $protectedRange) { - $newReference = $this->updateCellReference($cellAddress); - if ($cellAddress !== $newReference) { - $worksheet->unprotectCells($cellAddress); - if ($newReference) { - $worksheet->protectCells($newReference, $protectedRange->getPassword(), true); + /** @var CellReferenceHelper */ + $cellReferenceHelper = $this->cellReferenceHelper; + if ($numberOfRows >= 0 && $numberOfColumns >= 0) { + foreach ($aProtectedCells as $key2 => $value) { + $ranges = $value->allRanges(); + $newKey = $separator = ''; + foreach ($ranges as $key => $range) { + $oldKey = $range[0] . (array_key_exists(1, $range) ? (':' . $range[1]) : ''); + $newKey .= $separator . $this->updateCellReference($oldKey); + $separator = ' '; + } + if ($key2 !== $newKey) { + $worksheet->unprotectCells($key2); + $worksheet->protectCells($newKey, $value->getPassword(), true, $value->getName(), $value->getSecurityDescriptor()); + } + } + } else { + foreach ($aProtectedCells as $key2 => $value) { + $range = str_replace([' ', ',', "\0"], ["\0", ' ', ','], $key2); + $extracted = Coordinate::extractAllCellReferencesInRange($range); + $outArray = []; + foreach ($extracted as $cellAddress) { + if (!$cellReferenceHelper->cellAddressInDeleteRange($cellAddress)) { + $outArray[$this->updateCellReference($cellAddress)] = 'x'; + } + } + $outArray2 = Coordinate::mergeRangesInCollection($outArray); + $newKey = implode(' ', array_keys($outArray2)); + if ($key2 !== $newKey) { + $worksheet->unprotectCells($key2); + $worksheet->protectCells($newKey, $value->getPassword(), true, $value->getName(), $value->getSecurityDescriptor()); } } } @@ -472,32 +494,56 @@ class ReferenceHelper continue; } - // New coordinate - $newCoordinate = Coordinate::stringFromColumnIndex($cellIndex + $numberOfColumns) . ($cell->getRow() + $numberOfRows); - // Should the cell be updated? Move value and cellXf index from one cell to another. if (($cellIndex >= $beforeColumn) && ($cell->getRow() >= $beforeRow)) { - // Update cell styles - $worksheet->getCell($newCoordinate)->setXfIndex($cell->getXfIndex()); - - // Insert this cell at its new location - if ($cell->getDataType() === DataType::TYPE_FORMULA) { - // Formula should be adjusted + // New coordinate + $newColumn = $cellIndex + $numberOfColumns; + $newRow = $cell->getRow() + $numberOfRows; + if ($newColumn > 0 && $newRow > 0 && $newColumn <= AddressRange::MAX_COLUMN_INT && $newRow <= AddressRange::MAX_ROW) { + $newCoordinate = Coordinate::stringFromColumnIndex($newColumn) . $newRow; + // Update cell styles $worksheet->getCell($newCoordinate) - ->setValue($this->updateFormulaReferences($cell->getValueString(), $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle(), true)); - } else { - // Cell value should not be adjusted - $worksheet->getCell($newCoordinate)->setValueExplicit($cell->getValue(), $cell->getDataType()); + ->setXfIndex($cell->getXfIndex()); + + // Insert this cell at its new location + if ($cell->getDataType() === DataType::TYPE_FORMULA) { + // Formula should be adjusted + $worksheet->getCell($newCoordinate) + ->setValue( + $this->updateFormulaReferences( + $cell->getValueString(), + $beforeCellAddress, + $numberOfColumns, + $numberOfRows, + $worksheet->getTitle(), + true + ) + ); + } else { + // Cell value should not be adjusted + $worksheet->getCell($newCoordinate) + ->setValueExplicit($cell->getValue(), $cell->getDataType()); + } } // Clear the original cell - $worksheet->getCellCollection()->delete($coordinate); + $worksheet->getCellCollection() + ->delete($coordinate); } else { /* We don't need to update styles for rows/columns before our insertion position, but we do still need to adjust any formulae in those cells */ if ($cell->getDataType() === DataType::TYPE_FORMULA) { // Formula should be adjusted - $cell->setValue($this->updateFormulaReferences($cell->getValueString(), $beforeCellAddress, $numberOfColumns, $numberOfRows, $worksheet->getTitle(), true)); + $cell->setValue( + $this->updateFormulaReferences( + $cell->getValueString(), + $beforeCellAddress, + $numberOfColumns, + $numberOfRows, + $worksheet->getTitle(), + true + ) + ); } } } @@ -1146,7 +1192,7 @@ class ReferenceHelper private function clearStripCell(Worksheet $worksheet, string $coordinate): void { $worksheet->removeConditionalStyles($coordinate); - $worksheet->setHyperlink($coordinate); + $worksheet->setHyperlink($coordinate, null, false); $worksheet->setDataValidation($coordinate); $worksheet->removeComment($coordinate); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Settings.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Settings.php index b4c49d4..7759d92 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Settings.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Settings.php @@ -5,8 +5,6 @@ namespace PhpOffice\PhpSpreadsheet; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Chart\Renderer\IRenderer; use PhpOffice\PhpSpreadsheet\Collection\Memory; -use Psr\Http\Client\ClientInterface; -use Psr\Http\Message\RequestFactoryInterface; use Psr\SimpleCache\CacheInterface; use ReflectionClass; @@ -25,12 +23,9 @@ class Settings */ private static ?CacheInterface $cache = null; - /** - * The HTTP client implementation to be used for network request. - */ - private static ?ClientInterface $httpClient = null; + private static mixed $httpClient = null; - private static ?RequestFactoryInterface $requestFactory = null; + private static mixed $requestFactory = null; /** * Set the locale code to use for formula translations and any special formatting. @@ -57,8 +52,7 @@ class Settings */ public static function setChartRenderer(string $rendererClassName): void { - // We want phpstan to validate caller, but still need this test - if (!is_a($rendererClassName, IRenderer::class, true)) { //* @phpstan-ignore-line + if (!is_a($rendererClassName, IRenderer::class, true)) { throw new Exception('Chart renderer must implement ' . IRenderer::class); } @@ -113,8 +107,12 @@ class Settings /** * Set the HTTP client implementation to be used for network request. + * + * @deprecated 5.4.0 No replacement. + * + * @codeCoverageIgnore */ - public static function setHttpClient(ClientInterface $httpClient, RequestFactoryInterface $requestFactory): void + public static function setHttpClient(mixed $httpClient, mixed $requestFactory): void { self::$httpClient = $httpClient; self::$requestFactory = $requestFactory; @@ -122,6 +120,10 @@ class Settings /** * Unset the HTTP client configuration. + * + * @deprecated 5.4.0 No replacement. + * + * @codeCoverageIgnore */ public static function unsetHttpClient(): void { @@ -131,25 +133,25 @@ class Settings /** * Get the HTTP client implementation to be used for network request. + * + * @deprecated 5.4.0 No replacement. + * + * @codeCoverageIgnore */ - public static function getHttpClient(): ClientInterface + public static function getHttpClient(): mixed { - if (!self::$httpClient || !self::$requestFactory) { - throw new Exception('HTTP client must be configured via Settings::setHttpClient() to be able to use WEBSERVICE function.'); - } - return self::$httpClient; } /** * Get the HTTP request factory. + * + * @deprecated 5.4.0 No replacement. + * + * @codeCoverageIgnore */ - public static function getRequestFactory(): RequestFactoryInterface + public static function getRequestFactory(): mixed { - if (!self::$httpClient || !self::$requestFactory) { - throw new Exception('HTTP client must be configured via Settings::setHttpClient() to be able to use WEBSERVICE function.'); - } - return self::$requestFactory; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php index de6529c..540e96c 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/CodePage.php @@ -78,7 +78,7 @@ class CodePage * Convert Microsoft Code Page Identifier to Code Page Name which iconv * and mbstring understands. * - * @param int $codePage Microsoft Code Page Indentifier + * @param int $codePage Microsoft Code Page Identifier * * @return string Code Page Name */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php index 3dc1696..fbb48c9 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php @@ -11,6 +11,7 @@ use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Exception; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use Throwable; class Date { @@ -159,7 +160,7 @@ class Date * serialized timestamp. * See https://en.wikipedia.org/wiki/ISO_8601 for details of the ISO-8601 standard format. */ - public static function convertIsoDate(mixed $value): float|int + public static function convertIsoDate(mixed $value, ?int $calendar = null): float|int { if (!is_string($value)) { throw new Exception('Non-string value supplied for Iso Date conversion'); @@ -172,7 +173,7 @@ class Date throw new Exception("Invalid string $value supplied for datatype Date"); } - $newValue = self::dateTimeToExcel($date); + $newValue = self::dateTimeToExcel($date, $calendar); if (preg_match('/^\s*\d?\d:\d\d(:\d\d([.]\d+)?)?\s*(am|pm)?\s*$/i', $value) == 1) { $newValue = fmod($newValue, 1.0); @@ -189,18 +190,21 @@ class Date * if you don't want to treat it as a UTC value * Use the default (UTC) unless you absolutely need a conversion * + * @throws \Exception + * * @return DateTime PHP date/time object */ - public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null): DateTime + public static function excelToDateTimeObject(float|int $excelTimestamp, null|DateTimeZone|string $timeZone = null, ?int $calendar = null): DateTime { + $calendar ??= self::$excelCalendar; $timeZone = ($timeZone === null) ? self::getDefaultTimezone() : self::validateTimeZone($timeZone); if (Functions::getCompatibilityMode() == Functions::COMPATIBILITY_EXCEL) { - if ($excelTimestamp < 1 && self::$excelCalendar === self::CALENDAR_WINDOWS_1900) { + if ($excelTimestamp < 1 && $calendar === self::CALENDAR_WINDOWS_1900) { // Unix timestamp base date $baseDate = new DateTime('1970-01-01', $timeZone); } else { // MS Excel calendar base dates - if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) { + if ($calendar == self::CALENDAR_WINDOWS_1900) { // Allow adjustment for 1900 Leap Year in MS Excel $baseDate = ($excelTimestamp < 60) ? new DateTime('1899-12-31', $timeZone) : new DateTime('1899-12-30', $timeZone); } else { @@ -213,10 +217,10 @@ class Date if (is_int($excelTimestamp)) { if ($excelTimestamp >= 0) { - return $baseDate->modify("+ $excelTimestamp days"); + return self::safeModify($baseDate, "+ $excelTimestamp days"); } - return $baseDate->modify("$excelTimestamp days"); + return self::safeModify($baseDate, "$excelTimestamp days"); } $days = floor($excelTimestamp); $partDay = $excelTimestamp - $days; @@ -233,7 +237,7 @@ class Date } $interval = $days . ' days'; - return $baseDate->modify($interval) + return self::safeModify($baseDate, $interval) ->setTime($hours, $minutes, $seconds, $microseconds); } @@ -249,9 +253,9 @@ class Date * * @return int Unix timetamp for this date/time */ - public static function excelToTimestamp($excelTimestamp, $timeZone = null): int + public static function excelToTimestamp($excelTimestamp, $timeZone = null, ?int $calendar = null): int { - $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone); + $dto = self::excelToDateTimeObject($excelTimestamp, $timeZone, $calendar); self::roundMicroseconds($dto); return (int) $dto->format('U'); @@ -266,14 +270,16 @@ class Date * @return false|float Excel date/time value * or boolean FALSE on failure */ - public static function PHPToExcel(mixed $dateValue) + public static function PHPToExcel(mixed $dateValue, ?int $calendar = null) { if ((is_object($dateValue)) && ($dateValue instanceof DateTimeInterface)) { - return self::dateTimeToExcel($dateValue); - } elseif (is_numeric($dateValue)) { - return self::timestampToExcel($dateValue); - } elseif (is_string($dateValue)) { - return self::stringToExcel($dateValue); + return self::dateTimeToExcel($dateValue, $calendar); + } + if (is_numeric($dateValue)) { + return self::timestampToExcel($dateValue, $calendar); + } + if (is_string($dateValue)) { + return self::stringToExcel($dateValue, $calendar); } return false; @@ -286,7 +292,7 @@ class Date * * @return float MS Excel serialized date/time value */ - public static function dateTimeToExcel(DateTimeInterface $dateValue): float + public static function dateTimeToExcel(DateTimeInterface $dateValue, ?int $calendar = null): float { $seconds = (float) sprintf('%d.%06d', $dateValue->format('s'), $dateValue->format('u')); @@ -296,7 +302,8 @@ class Date (int) $dateValue->format('d'), (int) $dateValue->format('H'), (int) $dateValue->format('i'), - $seconds + $seconds, + $calendar ); } @@ -309,13 +316,13 @@ class Date * * @return false|float MS Excel serialized date/time value */ - public static function timestampToExcel($unixTimestamp): bool|float + public static function timestampToExcel($unixTimestamp, ?int $calendar = null): bool|float { if (!is_numeric($unixTimestamp)) { return false; } - return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp)); + return self::dateTimeToExcel(new DateTime('@' . $unixTimestamp), $calendar); } /** @@ -323,9 +330,10 @@ class Date * * @return float Excel date/time value */ - public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0): float + public static function formattedPHPToExcel(int $year, int $month, int $day, int $hours = 0, int $minutes = 0, float|int $seconds = 0, ?int $calendar = null): float { - if (self::$excelCalendar == self::CALENDAR_WINDOWS_1900) { + $calendar ??= self::$excelCalendar; + if ($calendar === self::CALENDAR_WINDOWS_1900) { // // 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 @@ -376,15 +384,18 @@ class Date $cell->getCalculatedValue() ); } - $result = is_numeric($value) - && self::isDateTimeFormat( + if (is_numeric($value)) { + $result = self::isDateTimeFormat( $worksheet->getStyle( $cell->getCoordinate() )->getNumberFormat(), $dateWithoutTimeOkay ); - } catch (Exception) { - // Result is already false, so no need to actually do anything here + /** @var float|int $value */ + self::excelToDateTimeObject($value); + } + } catch (Throwable) { + $result = false; } $worksheet->setSelectedCells($selected); $spreadsheet->setActiveSheetIndex($index); @@ -435,7 +446,7 @@ class Date } $possibleFormatCharacters = $dateWithoutTimeOkay ? self::POSSIBLE_DATETIME_FORMAT_CHARACTERS : self::POSSIBLE_TIME_FORMAT_CHARACTERS; // Try checking for any of the date formatting characters that don't appear within square braces - if (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) { + if (preg_match('/(^|])[^\[]*[' . $possibleFormatCharacters . ']/i', $excelFormatCode)) { // We might also have a format mask containing quoted strings... // we don't want to test for any of our characters within the quoted blocks if (str_contains($excelFormatCode, '"')) { @@ -445,7 +456,7 @@ class Date $segMatcher = $segMatcher === false; if ( $segMatcher - && (preg_match('/(^|\])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal)) + && (preg_match('/(^|])[^\[]*[' . $possibleFormatCharacters . ']/i', $subVal)) ) { return true; } @@ -468,16 +479,16 @@ class Date * * @return false|float Excel date/time serial value */ - public static function stringToExcel(string $dateValue): bool|float + public static function stringToExcel(string $dateValue, ?int $calendar = null): bool|float { if (strlen($dateValue) < 2) { return false; } - if (!preg_match('/^(\d{1,4}[ \.\/\-][A-Z]{3,9}([ \.\/\-]\d{1,4})?|[A-Z]{3,9}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?|\d{1,4}[ \.\/\-]\d{1,4}([ \.\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2}([.]\d+)?)?)?$/iu', $dateValue)) { + if (!preg_match('/^(\d{1,4}[ .\/\-][A-Z]{3,9}([ .\/\-]\d{1,4})?|[A-Z]{3,9}[ .\/\-]\d{1,4}([ .\/\-]\d{1,4})?|\d{1,4}[ .\/\-]\d{1,4}([ .\/\-]\d{1,4})?)( \d{1,2}:\d{1,2}(:\d{1,2}([.]\d+)?)?)?$/iu', $dateValue)) { return false; } - $dateValueNew = DateTimeExcel\DateValue::fromString($dateValue); + $dateValueNew = DateTimeExcel\DateValue::fromString2($dateValue, $calendar); if (!is_float($dateValueNew)) { return false; @@ -555,7 +566,82 @@ class Date $rounded = (int) round($microseconds, -6); $modify = $rounded - $microseconds; if ($modify !== 0) { - $dti->modify(($modify > 0 ? '+' : '') . $modify . ' microseconds'); + self::safeModify($dti, ($modify > 0 ? '+' : '') . $modify . ' microseconds'); + } + } + + /** + * Safely modifies a DateTime object using a specified modification string. + * + * Prior to PHP 8.3, DateTime::modify() would return false on failure but would also + * emit an E_WARNING. Starting with PHP 8.3, DateTime::modify() throws a DateMalformedStringException + * on failure instead of returning false or emitting warnings. + * + * This method provides consistent exception-based error handling across PHP versions: + * - For PHP 8.3+: Uses the native exception-throwing behavior of modify() + * - For PHP < 8.3: Converts warnings to exceptions using a custom error handler + * + * This ensures that calling code can rely on exception handling for all date modification + * failures regardless of the PHP version in use. + * + * @param DateTime $dateTime The DateTime object to be modified. + * @param string $modifier A modification string, such as '+1 day', '-2 hours', 'last Monday', etc. + * + * @throws PhpSpreadsheetException If an error occurs during the date modification process. + * + * @return DateTime The modified DateTime object. + * + * @codeCoverageIgnore + */ + protected static function safeModify(DateTime $dateTime, string $modifier): DateTime + { + /** + * Starting with PHP 8.3, DateTime::modify() throws a DateMalformedStringException on failure instead of + * returning false or emitting warnings, so we don't need the error handler overhead and can just consume the + * DateTime.modify() method. + */ + if (PHP_VERSION_ID >= 80300) { + return $dateTime->modify($modifier); + } + + set_error_handler( + /** + * A selective error handler meant to capture E_WARNING such the following: + * + * > Warning: DateTime::modify(): Failed to parse time string (+ 3172011706730017 days) + * > at position 0 (+): Unexpected character in + * > […]/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Date.php on line 220 + * + * @param int $severity The severity level of the error. + * @param string $message The error message to process. + * + * @throws PhpSpreadsheetException If the severity is E_WARNING and the message matches the specified + * condition. + * + * @return bool Returns false if the error is not of type E_WARNING or if the message does not match + * the specified condition. Throws PhpSpreadsheetException when conditions are met. + */ + static function (int $severity, string $message): bool { + if ($severity !== E_WARNING) { + return false; + } + if (!str_starts_with($message, 'DateTime::modify()')) { + return false; + } + + throw new PhpSpreadsheetException($message); + } + ); + + try { + $result = $dateTime->modify($modifier); + if ($result === false) { + throw new PhpSpreadsheetException('Failed to modify date with interval: ' . $modifier); + } + + return $result; + } finally { + restore_error_handler(); } } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php index 4eef791..f21cfaa 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Drawing.php @@ -49,11 +49,12 @@ class Drawing // Font name and size $name = $defaultFont->getName(); $size = $defaultFont->getSize(); + $sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size"; - if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$size])) { + if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) { // Exact width can be determined - return $pixelValue * Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['width'] - / Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['px']; + return $pixelValue * Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['width'] + / Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['px']; } // We don't have data for this particular font and size, use approximation by @@ -75,11 +76,12 @@ class Drawing // Font name and size $name = $defaultFont->getName(); $size = $defaultFont->getSize(); + $sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size"; - if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$size])) { + if (isset(Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) { // Exact width can be determined - $colWidth = $cellWidth * Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['px'] - / Font::DEFAULT_COLUMN_WIDTHS[$name][$size]['width']; + $colWidth = $cellWidth * Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['px'] + / Font::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['width']; } else { // We don't have data for this particular font and size, use approximation by // extrapolating from Calibri 11 @@ -114,11 +116,7 @@ class Drawing */ public static function pointsToPixels($pointValue): int { - if ($pointValue != 0) { - return (int) ceil($pointValue / 0.75); - } - - return 0; + return (int) ceil($pointValue / 0.75); } /** diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php index 45a2738..6567375 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DgContainer/SpgrContainer/SpContainer.php @@ -165,11 +165,7 @@ class SpContainer */ public function getOPT(int $property): mixed { - if (isset($this->OPT[$property])) { - return $this->OPT[$property]; - } - - return null; + return $this->OPT[$property] ?? null; } /** diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php index e5d831f..f7053dd 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Escher/DggContainer.php @@ -34,7 +34,7 @@ class DggContainer private array $OPT = []; /** - * Array of identifier clusters containg information about the maximum shape identifiers. + * Array of identifier clusters containing information about the maximum shape identifiers. * * @var mixed[] */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php index 022c1bb..da5a36b 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/File.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Shared; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Exception; use PhpOffice\PhpSpreadsheet\Reader\Exception as ReaderException; use ZipArchive; @@ -130,12 +131,28 @@ class File public static function temporaryFilename(): string { - $filename = tempnam(self::sysGetTempDir(), 'phpspreadsheet'); - if ($filename === false) { - throw new Exception('Could not create temporary file'); - } + return tempnam(self::sysGetTempDir(), 'phpspreadsheet') ?: throw new Exception('Could not create temporary file'); + } - return $filename; + /** + * Blocks phar:// and similar RCE-bearing wrappers. + * Note that many protocols, including http and zip, will already + * return false for is_file. + * A whitelist of protocols may be added if needed in future. + * data: is intentionally allowed (see #4823); callers needing strict + * on-disk-only semantics must validate $filename themselves. + */ + public static function prohibitWrappers(string $filename): void + { + if ( + Preg::IsMatch('~^phar://~i', $filename) + || (Preg::isMatch('/^([\w.\s\x00-\x1f]+):/', $filename) && !Preg::isMatch('/^([\w.]+):/', $filename)) + || Preg::isMatch('~^[\w.]+://.*phar:~is', $filename) + ) { + throw new Exception( + "Disallowed stream wrapper used for {$filename}" + ); + } } /** @@ -143,12 +160,9 @@ class File */ public static function assertFile(string $filename, string $zipMember = ''): void { - if (!is_file($filename)) { - throw new ReaderException('File "' . $filename . '" does not exist.'); - } - - if (!is_readable($filename)) { - throw new ReaderException('Could not open "' . $filename . '" for reading.'); + self::prohibitWrappers($filename); + if (!is_file($filename) || !is_readable($filename)) { + throw new ReaderException('File "' . $filename . '" does not exist or is not readable.'); } if ($zipMember !== '') { @@ -165,13 +179,12 @@ class File /** * Same as assertFile, except return true/false and don't throw Exception. + * Will nevertheless throw if filename uses invalid protocol, e.g. phar. */ public static function testFileNoThrow(string $filename, ?string $zipMember = null): bool { - if (!is_file($filename)) { - return false; - } - if (!is_readable($filename)) { + self::prohibitWrappers($filename); + if (!is_file($filename) || !is_readable($filename)) { return false; } if ($zipMember === null) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php index 27490e3..3b0afbd 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Font.php @@ -631,11 +631,13 @@ class Font */ public static function getDefaultColumnWidthByFont(FontStyle $font, bool $returnAsPixels = false): float|int { - if (isset(self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()])) { + $size = $font->getSize(); + $sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size"; + if (isset(self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex])) { // Exact width can be determined $columnWidth = $returnAsPixels - ? self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['px'] - : self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$font->getSize()]['width']; + ? self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex]['px'] + : self::DEFAULT_COLUMN_WIDTHS[$font->getName()][$sizex]['width']; } else { // We don't have data for this particular font and size, use approximation by // extrapolating from Calibri 11 @@ -665,8 +667,9 @@ class Font { $name = $font->getName(); $size = $font->getSize(); - if (isset(self::DEFAULT_COLUMN_WIDTHS[$name][$size])) { - $rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][$size]['height']; + $sizex = ($size !== null && $size == (int) $size) ? ((int) $size) : "$size"; + if (isset(self::DEFAULT_COLUMN_WIDTHS[$name][$sizex])) { + $rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][$sizex]['height']; } elseif ($name === 'Arial' || $name === 'Verdana') { $rowHeight = self::DEFAULT_COLUMN_WIDTHS[$name][10]['height'] * $size / 10.0; } else { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE.php index 09a3ace..61c1270 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE.php @@ -221,22 +221,22 @@ class OLE // Store current instance in global array, so that it can be accessed // in OLE_ChainedBlockStream::stream_open(). // Object is removed from self::$instances in OLE_Stream::close(). - $GLOBALS['_OLE_INSTANCES'][] = $this; //* @phpstan-ignore-line - $keys = array_keys($GLOBALS['_OLE_INSTANCES']); //* @phpstan-ignore-line + $GLOBALS['_OLE_INSTANCES'][] = $this; //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how to fix this) + $keys = array_keys($GLOBALS['_OLE_INSTANCES']); //* @phpstan-ignore argument.type (I don't know how to fix this) $instanceId = end($keys); $path = 'ole-chainedblockstream://oleInstanceId=' . $instanceId; if ($blockIdOrPps instanceof OLE\PPS) { $path .= '&blockId=' . $blockIdOrPps->startBlock; $path .= '&size=' . $blockIdOrPps->Size; + if ($blockIdOrPps instanceof Root) { + $path .= '&isRoot=1'; + } } else { $path .= '&blockId=' . $blockIdOrPps; } - $resource = fopen($path, 'rb'); - if ($resource === false) { - throw new Exception("Unable to open stream $path"); - } + $resource = fopen($path, 'rb') ?: throw new Exception("Unable to open stream $path"); return $resource; } @@ -475,6 +475,86 @@ class OLE return 0; } + /** + * Get the complete contents of a named file stream. + * + * @throws ReaderException if no file stream has the requested name + */ + public function getDataByName(string $name): string + { + foreach ($this->_list as $index => $pps) { + if ($pps->Type === self::OLE_PPS_TYPE_FILE && $pps->Name === $name) { + return $this->getData($index, 0, $pps->Size); + } + } + + throw new ReaderException("OLE stream '$name' was not found."); + } + + /** + * Check whether a named file stream exists without reading its contents. + */ + public function hasDataByName(string $name): bool + { + foreach ($this->_list as $pps) { + if ($pps->Type === self::OLE_PPS_TYPE_FILE && $pps->Name === $name) { + return true; + } + } + + return false; + } + + /** + * Copy a named file stream to an open output handle without loading a + * large stream into memory. + * + * @param resource $output + * + * @throws ReaderException if no file stream has the requested name + */ + public function copyDataByName(string $name, $output): void + { + foreach ($this->_list as $pps) { + if ($pps->Type !== self::OLE_PPS_TYPE_FILE || $pps->Name !== $name) { + continue; + } + if ($pps->Size < $this->bigBlockThreshold) { + $data = $this->getData($pps->No, 0, $pps->Size); + if (fwrite($output, $data) !== strlen($data)) { + throw new ReaderException("Could not write OLE stream '$name'."); + } + + return; + } + + $remaining = $pps->Size; + $blockId = $pps->startBlock; + $blocks = []; + while ($remaining > 0) { + if (!is_int($blockId) || $blockId === -2 || isset($blocks[$blockId], $this->bbat[$blockId])) { + throw new ReaderException("Invalid OLE stream '$name'."); + } + $blocks[$blockId] = true; + fseek($this->_file_handle, $this->getBlockOffset($blockId)); + $length = min($remaining, $this->bigBlockSize); + $data = fread($this->_file_handle, $length); + if ($data === false || strlen($data) !== $length || fwrite($output, $data) !== $length) { + throw new ReaderException("Could not copy OLE stream '$name'."); + } + $remaining -= $length; + $blockId = $this->bbat[$blockId] ?? null; + if (!is_int($blockId)) { + throw new ReaderException("Invalid OLE stream '$name'."); + } + } + + return; + } + + throw new ReaderException("OLE stream '$name' was not found."); + } + /** * Utility function to transform ASCII text to Unicode. * @@ -509,7 +589,7 @@ class OLE } $dateTime = Date::dateTimeFromTimestamp("$date"); - // days from 1-1-1601 until the beggining of UNIX era + // days from 1-1-1601 until the beginning of UNIX era $days = 134774; // calculate seconds $big_date = $days * 24 * 3600 + (float) $dateTime->format('U'); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php index 76f1be7..21cc896 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/ChainedBlockStream.php @@ -58,42 +58,66 @@ class ChainedBlockStream // 25 is length of "ole-chainedblockstream://" parse_str(substr($path, 25), $this->params); - if (!isset($this->params['oleInstanceId'], $this->params['blockId'], $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']])) { //* @phpstan-ignore-line + if (!isset($this->params['oleInstanceId'], $this->params['blockId'], $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']])) { //* @phpstan-ignore offsetAccess.nonOffsetAccessible (I don't know how to fix this) if ($options & STREAM_REPORT_ERRORS) { trigger_error('OLE stream not found', E_USER_WARNING); } return false; } - $this->ole = $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']]; //* @phpstan-ignore-line - if (!($this->ole instanceof OLE)) { //* @phpstan-ignore-line + $this->ole = $GLOBALS['_OLE_INSTANCES'][$this->params['oleInstanceId']]; //* @phpstan-ignore assign.propertyType (I don't know how to fix this) + if (!($this->ole instanceof OLE)) { //* @phpstan-ignore instanceof.alwaysTrue (I don't know how to fix this) throw new Exception('class is not OLE'); } - $blockId = $this->params['blockId']; + $blockId = (int) $this->params['blockId']; + $size = isset($this->params['size']) ? (int) $this->params['size'] : null; + $isRoot = isset($this->params['isRoot']) && $this->params['isRoot'] === '1'; $this->data = ''; - if (isset($this->params['size']) && $this->params['size'] < $this->ole->bigBlockThreshold && $blockId != $this->ole->root->startBlock) { + if ($size !== null && $size < $this->ole->bigBlockThreshold && !$isRoot) { // Block id refers to small blocks - $rootPos = $this->ole->getBlockOffset((int) $this->ole->root->startBlock); + $rootData = ''; + if ($this->ole->root->startBlock === null) { + throw new Exception('Invalid OLE root mini-stream chain.'); + } + $rootBlockId = (int) $this->ole->root->startBlock; + $rootBlocks = []; + while ($rootBlockId !== -2) { + if (isset($rootBlocks[$rootBlockId], $this->ole->bbat[$rootBlockId])) { + throw new Exception('Invalid OLE root mini-stream chain.'); + } + $rootBlocks[$rootBlockId] = true; + fseek($this->ole->_file_handle, $this->ole->getBlockOffset($rootBlockId)); + $rootData .= fread($this->ole->_file_handle, $this->ole->bigBlockSize); + $rootBlockId = self::nextBlock($this->ole->bbat, $rootBlockId, 'Invalid OLE root mini-stream chain.'); + } + + $smallBlocks = []; while ($blockId != -2) { - /** @var int $blockId */ - $pos = $rootPos + $blockId * $this->ole->bigBlockSize; - $blockId = $this->ole->sbat[$blockId]; - fseek($this->ole->_file_handle, $pos); - $this->data .= fread($this->ole->_file_handle, $this->ole->bigBlockSize); + if (isset($smallBlocks[$blockId], $this->ole->sbat[$blockId])) { + throw new Exception('Invalid OLE mini-stream chain.'); + } + $smallBlocks[$blockId] = true; + $pos = $blockId * $this->ole->smallBlockSize; + $this->data .= substr($rootData, $pos, $this->ole->smallBlockSize); + $blockId = self::nextBlock($this->ole->sbat, $blockId, 'Invalid OLE mini-stream chain.'); } } else { // Block id refers to big blocks + $bigBlocks = []; while ($blockId != -2) { - /** @var int $blockId */ + if (isset($bigBlocks[$blockId], $this->ole->bbat[$blockId])) { + throw new Exception('Invalid OLE stream chain.'); + } + $bigBlocks[$blockId] = true; $pos = $this->ole->getBlockOffset($blockId); fseek($this->ole->_file_handle, $pos); $this->data .= fread($this->ole->_file_handle, $this->ole->bigBlockSize); - $blockId = $this->ole->bbat[$blockId]; + $blockId = self::nextBlock($this->ole->bbat, $blockId, 'Invalid OLE stream chain.'); } } - if (isset($this->params['size'])) { - $this->data = substr($this->data, 0, $this->params['size']); //* @phpstan-ignore-line + if ($size !== null) { + $this->data = substr($this->data, 0, $size); } if ($options & STREAM_USE_PATH) { @@ -103,6 +127,17 @@ class ChainedBlockStream return true; } + /** @param mixed[] $allocationTable */ + private static function nextBlock(array $allocationTable, int $blockId, string $message): int + { + $nextBlockId = $allocationTable[$blockId] ?? null; + if (!is_int($nextBlockId)) { + throw new Exception($message); + } + + return $nextBlockId; + } + /** * Implements support for fclose(). */ @@ -151,6 +186,10 @@ class ChainedBlockStream /** * Implements support for fseek(). + * Note that the first condition is always true, at least in + * the unit test suite. One consequence is that Phpstan's + * correct flagging of count($this->data) below is never + * executed, and would fail should it be executed. * * @param int $offset byte offset * @param int $whence SEEK_SET, SEEK_CUR or SEEK_END @@ -161,7 +200,7 @@ class ChainedBlockStream $this->pos = $offset; } elseif ($whence == SEEK_CUR && -$offset <= $this->pos) { $this->pos += $offset; - } elseif ($whence == SEEK_END && -$offset <= count($this->data)) { // @phpstan-ignore-line + } elseif ($whence == SEEK_END && -$offset <= count($this->data)) { // @phpstan-ignore argument.type (phpstan is correct - see docBlock above) $this->pos = strlen($this->data) + $offset; } else { return false; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/PPS/Root.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/PPS/Root.php index eb03f8c..9dca506 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/PPS/Root.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLE/PPS/Root.php @@ -20,6 +20,7 @@ namespace PhpOffice\PhpSpreadsheet\Shared\OLE\PPS; // | Based on OLE::Storage_Lite by Kawai, Takanori | // +----------------------------------------------------------------------+ // +use PhpOffice\PhpSpreadsheet\Exception; use PhpOffice\PhpSpreadsheet\Shared\OLE; use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS; @@ -30,14 +31,22 @@ use PhpOffice\PhpSpreadsheet\Shared\OLE\PPS; */ class Root extends PPS { + private const BIG_BLOCK_SIZE = 512; + + private const SMALL_BLOCK_SIZE = 64; + + private const MAX_VERSION_3_STREAM_SIZE = 0x80000000; + + private const MAX_REGULAR_SECTOR_COUNT = 0xFFFFFFFB; + /** * @var resource */ private $fileHandle; - private ?int $smallBlockSize = null; + private int $smallBlockSize = self::SMALL_BLOCK_SIZE; - private ?int $bigBlockSize = null; + private int $bigBlockSize = self::BIG_BLOCK_SIZE; /** * @param null|float|int $time_1st A timestamp @@ -64,13 +73,9 @@ class Root extends PPS { $this->fileHandle = $fileHandle; - // Initial Setting for saving - $this->bigBlockSize = (int) (2 ** ( - (isset($this->bigBlockSize)) ? self::adjust2($this->bigBlockSize) : 9 - )); - $this->smallBlockSize = (int) (2 ** ( - (isset($this->smallBlockSize)) ? self::adjust2($this->smallBlockSize) : 6 - )); + // This writer implements the version-3 CFB profile only. + $this->bigBlockSize = self::BIG_BLOCK_SIZE; + $this->smallBlockSize = self::SMALL_BLOCK_SIZE; // Make an array of PPS's (for Save) $aList = []; @@ -82,6 +87,7 @@ class Root extends PPS // Make Small Data string (write SBD) $this->_data = $this->makeSmallData($aList); + $this->assertVersion3StreamSize(strlen($this->_data)); // Write BB $this->saveBigData((int) $iSBDcnt, $aList); @@ -109,6 +115,7 @@ class Root extends PPS for ($i = 0; $i < $iCount; ++$i) { if ($raList[$i]->Type == OLE::OLE_PPS_TYPE_FILE) { $raList[$i]->Size = $raList[$i]->getDataLen(); + $this->assertVersion3StreamSize($raList[$i]->Size); if ($raList[$i]->Size < OLE::OLE_DATA_SIZE_SMALL) { $iSBcnt += floor($raList[$i]->Size / $this->smallBlockSize) + (($raList[$i]->Size % $this->smallBlockSize) ? 1 : 0); @@ -118,6 +125,7 @@ class Root extends PPS } } } + $this->assertVersion3MiniStreamSize((int) $iSBcnt); $iSmallLen = $iSBcnt * $this->smallBlockSize; $iSlCnt = floor($this->bigBlockSize / OLE::OLE_LONG_INT_SIZE); $iSBDcnt = floor($iSBcnt / $iSlCnt) + (($iSBcnt % $iSlCnt) ? 1 : 0); @@ -130,18 +138,16 @@ class Root extends PPS return [$iSBDcnt, $iBBcnt, $iPPScnt]; } - /** - * Helper function for caculating a magic value for block sizes. - * - * @param int $i2 The argument - * - * @see save() - */ - private static function adjust2(int $i2): float + private function assertVersion3StreamSize(int $size): void { - $iWk = log($i2) / log(2); + if ($size > self::MAX_VERSION_3_STREAM_SIZE) { + throw new Exception('OLE version-3 streams cannot exceed 2 GiB.'); + } + } - return ($iWk > floor($iWk)) ? floor($iWk) + 1 : $iWk; + private function assertVersion3MiniStreamSize(int $smallBlockCount): void + { + $this->assertVersion3StreamSize($smallBlockCount * self::SMALL_BLOCK_SIZE); } /** @@ -168,12 +174,16 @@ class Root extends PPS ++$iAllW; $iBdCntW = floor($iAllW / $iBlCnt) + (($iAllW % $iBlCnt) ? 1 : 0); $iBdCnt = floor(($iAllW + $iBdCntW) / $iBlCnt) + ((($iAllW + $iBdCntW) % $iBlCnt) ? 1 : 0); - if ($iBdCnt <= ($iBdExL * $iBlCnt + $i1stBdL)) { + if ($iBdCnt <= ($iBdExL * ($iBlCnt - 1) + $i1stBdL)) { break; } } } + if ($iAllW + $iBdCnt > self::MAX_REGULAR_SECTOR_COUNT) { + throw new Exception('OLE version-3 output exceeds the maximum sector count.'); + } + // Save Header fwrite( $FILE, @@ -182,7 +192,7 @@ class Root extends PPS . "\x00\x00\x00\x00" . "\x00\x00\x00\x00" . "\x00\x00\x00\x00" - . pack('v', 0x3B) + . pack('v', 0x3E) . pack('v', 0x03) . pack('v', -2) . pack('v', 9) @@ -198,7 +208,7 @@ class Root extends PPS . pack('V', $iSBDcnt) ); // Extra BDList Start, Count - if ($iBdCnt < $i1stBdL) { + if ($iBdCnt <= $i1stBdL) { fwrite( $FILE, pack('V', -2) // Extra BDList Start @@ -342,7 +352,7 @@ class Root extends PPS ++$iAllW; $iBdCntW = floor($iAllW / $iBbCnt) + (($iAllW % $iBbCnt) ? 1 : 0); $iBdCnt = floor(($iAllW + $iBdCntW) / $iBbCnt) + ((($iAllW + $iBdCntW) % $iBbCnt) ? 1 : 0); - if ($iBdCnt <= ($iBdExL * $iBbCnt + $i1stBdL)) { + if ($iBdCnt <= ($iBdExL * ($iBbCnt - 1) + $i1stBdL)) { break; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLERead.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLERead.php index aa4224b..9ed4ba4 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLERead.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/OLERead.php @@ -61,6 +61,9 @@ class OLERead /** @var mixed[][] */ private array $props = []; + /** @var int[] */ + private array $possibleLoop = []; + /** * Read the file. */ @@ -137,7 +140,9 @@ class OLERead $sbdBlock = $this->sbdStartBlock; $this->smallBlockChain = ''; + $this->possibleLoop = []; while ($sbdBlock != -2) { + $this->catchLoop($sbdBlock); $pos = ($sbdBlock + 1) * self::BIG_BLOCK_SIZE; $this->smallBlockChain .= substr($this->data, $pos, 4 * $bbs); @@ -153,6 +158,14 @@ class OLERead $this->readPropertySets(); } + private function catchLoop(int $sbdBlock): void + { + if (in_array($sbdBlock, $this->possibleLoop, true)) { + throw new ReaderException('Detected loop while iterating blocks'); + } + $this->possibleLoop[] = $sbdBlock; + } + /** * Extract binary stream data. */ @@ -172,7 +185,9 @@ class OLERead /** @var int */ $block = $this->props[$stream]['startBlock']; + $this->possibleLoop = []; while ($block != -2) { + $this->catchLoop($block); $pos = $block * self::SMALL_BLOCK_SIZE; $streamData .= substr($rootdata, $pos, self::SMALL_BLOCK_SIZE); @@ -195,7 +210,9 @@ class OLERead /** @var int */ $block = $this->props[$stream]['startBlock']; + $this->possibleLoop = []; while ($block != -2) { + $this->catchLoop($block); $pos = ($block + 1) * self::BIG_BLOCK_SIZE; $streamData .= substr($this->data, $pos, self::BIG_BLOCK_SIZE); $block = self::getInt4d($this->bigBlockChain, $block * 4); @@ -215,7 +232,9 @@ class OLERead { $data = ''; + $this->possibleLoop = []; while ($block != -2) { + $this->catchLoop($block); $pos = ($block + 1) * self::BIG_BLOCK_SIZE; $data .= substr($this->data, $pos, self::BIG_BLOCK_SIZE); $block = self::getInt4d($this->bigBlockChain, $block * 4); @@ -231,7 +250,7 @@ class OLERead { $offset = 0; - // loop through entires, each entry is 128 bytes + // loop through entries, each entry is 128 bytes $entryLen = strlen($this->entry); while ($offset < $entryLen) { // entry data (128 bytes) diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php index 6398b61..fe73288 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/StringHelper.php @@ -3,6 +3,8 @@ namespace PhpOffice\PhpSpreadsheet\Shared; use Composer\Pcre\Preg; +use IntlCalendar; +use NumberFormatter; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Exception as SpreadsheetException; use Stringable; @@ -237,57 +239,76 @@ class StringHelper /** * Decimal separator. */ - private static ?string $decimalSeparator = null; + protected static ?string $decimalSeparator = null; /** * Thousands separator. */ - private static ?string $thousandsSeparator = null; + protected static ?string $thousandsSeparator = null; /** * Currency code. */ - private static ?string $currencyCode = null; + protected static ?string $currencyCode = null; /** - * Is iconv extension avalable? + * Is iconv extension available? */ - private static ?bool $isIconvEnabled = null; + protected static ?bool $isIconvEnabled = null; /** * iconv options. */ - private static string $iconvOptions = '//IGNORE//TRANSLIT'; + protected static string $iconvOptions = '//IGNORE//TRANSLIT'; + + /** @var string[] */ + protected static array $iconvOptionsArray = ['//IGNORE//TRANSLIT', '//IGNORE']; + + /** @internal */ + protected static string $iconvName = 'iconv'; + + /** @internal */ + protected static bool $iconvTest2 = false; + + /** @internal */ + protected static bool $iconvTest3 = false; /** * Get whether iconv extension is available. */ public static function getIsIconvEnabled(): bool { - if (isset(self::$isIconvEnabled)) { - return self::$isIconvEnabled; + if (isset(static::$isIconvEnabled)) { + return static::$isIconvEnabled; } // Assume no problems with iconv - self::$isIconvEnabled = true; + static::$isIconvEnabled = true; // Fail if iconv doesn't exist - if (!function_exists('iconv')) { - self::$isIconvEnabled = false; - } elseif (!@iconv('UTF-8', 'UTF-16LE', 'x')) { + if (!function_exists(static::$iconvName)) { + static::$isIconvEnabled = false; + } elseif (static::$iconvTest2 || !@iconv('UTF-8', 'UTF-16LE', 'x')) { // Sometimes iconv is not working, and e.g. iconv('UTF-8', 'UTF-16LE', 'x') just returns false, - self::$isIconvEnabled = false; - } elseif (defined('PHP_OS') && @stristr(PHP_OS, 'AIX') && defined('ICONV_IMPL') && (@strcasecmp(ICONV_IMPL, 'unknown') == 0) && defined('ICONV_VERSION') && (@strcasecmp(ICONV_VERSION, 'unknown') == 0)) { + static::$isIconvEnabled = false; + } elseif (static::$iconvTest3 || (defined('PHP_OS') && @stristr(PHP_OS, 'AIX') && defined('ICONV_IMPL') && (@strcasecmp(ICONV_IMPL, 'unknown') == 0) && defined('ICONV_VERSION') && (@strcasecmp(ICONV_VERSION, 'unknown') == 0))) { // CUSTOM: IBM AIX iconv() does not work - self::$isIconvEnabled = false; + static::$isIconvEnabled = false; } - // Deactivate iconv default options if they fail (as seen on IMB i) - if (self::$isIconvEnabled && !@iconv('UTF-8', 'UTF-16LE' . self::$iconvOptions, 'x')) { - self::$iconvOptions = ''; + // Deactivate iconv default options if they fail (as seen on IBM i-series) + if (static::$isIconvEnabled) { + static::$iconvOptions = ''; + foreach (static::$iconvOptionsArray as $option) { + if (@iconv('UTF-8', 'UTF-16LE' . $option, 'x') !== false) { + static::$iconvOptions = $option; + + break; + } + } } - return self::$isIconvEnabled; + return static::$isIconvEnabled; } /** @@ -305,7 +326,47 @@ class StringHelper */ public static function controlCharacterOOXML2PHP(string $textValue): string { - return str_replace(self::CONTROL_CHARACTERS_VALUES, self::CONTROL_CHARACTERS_KEYS, $textValue); + return Preg::replaceCallback('/_x[0-9A-F]{4}_(_xD[CDEF][0-9A-F]{2}_)?/', self::toOutChar(...), $textValue); + } + + private static function toHexVal(string $char): int + { + if ($char >= '0' && $char <= '9') { + return ord($char) - ord('0'); + } + + return ord($char) - ord('A') + 10; + } + + /** @param array $match */ + private static function toOutChar(array $match): string + { + /** @var string */ + $chars = $match[0]; + $h = ((self::toHexVal($chars[2]) << 12) + | (self::toHexVal($chars[3]) << 8) + | (self::toHexVal($chars[4]) << 4) + | (self::toHexVal($chars[5]))); + if (strlen($chars) === 7) { // no low surrogate + if ($chars[2] === 'D' && in_array($chars[3], ['8', '9', 'A', 'B', 'C', 'D', 'E', 'F'], true)) { + return '�'; + } + + return mb_chr($h, 'UTF-8'); + } + if ($chars[2] === 'D' && in_array($chars[3], ['C', 'D', 'D', 'F'], true)) { + return '�'; // Excel interprets as one substitute, not 2 + } + if ($chars[2] !== 'D' || !in_array($chars[3], ['8', '9', 'A', 'B'], true)) { + return mb_chr($h, 'UTF-8') . '�'; + } + $l = ((self::toHexVal($chars[9]) << 12) + | (self::toHexVal($chars[10]) << 8) + | (self::toHexVal($chars[11]) << 4) + | (self::toHexVal($chars[12]))); + $result = 0x10000 + ($h - 0xD800) * 0x400 + ($l - 0xDC00); + + return mb_chr($result, 'UTF-8'); } /** @@ -323,6 +384,8 @@ class StringHelper */ public static function controlCharacterPHP2OOXML(string $textValue): string { + $textValue = Preg::replace('/_(x[0-9A-F]{4}_)/', '_x005F_$1', $textValue); + return str_replace(self::CONTROL_CHARACTERS_KEYS, self::CONTROL_CHARACTERS_VALUES, $textValue); } @@ -418,10 +481,10 @@ class StringHelper * @param string $to Encoding to convert to, e.g. 'UTF-8' * @param string $from Encoding to convert from, e.g. 'UTF-16LE' */ - public static function convertEncoding(string $textValue, string $to, string $from): string + public static function convertEncoding(string $textValue, string $to, string $from, ?string $options = null): string { if (static::getIsIconvEnabled()) { - $result = iconv($from, $to . self::$iconvOptions, $textValue); + $result = iconv($from, $to . ($options ?? static::$iconvOptions), $textValue); if (false !== $result) { return $result; } @@ -561,11 +624,11 @@ class StringHelper */ public static function getDecimalSeparator(): string { - if (!isset(self::$decimalSeparator)) { - self::$decimalSeparator = self::getLocaleValue('decimal_point', 'mon_decimal_point', '.'); + if (!isset(static::$decimalSeparator)) { + static::$decimalSeparator = self::getLocaleValue('decimal_point', 'mon_decimal_point', '.'); } - return self::$decimalSeparator; + return static::$decimalSeparator; } /** @@ -576,7 +639,7 @@ class StringHelper */ public static function setDecimalSeparator(?string $separator): void { - self::$decimalSeparator = $separator; + static::$decimalSeparator = $separator; } /** @@ -585,11 +648,11 @@ class StringHelper */ public static function getThousandsSeparator(): string { - if (!isset(self::$thousandsSeparator)) { - self::$thousandsSeparator = self::getLocaleValue('thousands_sep', 'mon_thousands_sep', ','); + if (!isset(static::$thousandsSeparator)) { + static::$thousandsSeparator = self::getLocaleValue('thousands_sep', 'mon_thousands_sep', ','); } - return self::$thousandsSeparator; + return static::$thousandsSeparator; } /** @@ -600,7 +663,7 @@ class StringHelper */ public static function setThousandsSeparator(?string $separator): void { - self::$thousandsSeparator = $separator; + static::$thousandsSeparator = $separator; } /** @@ -609,11 +672,11 @@ class StringHelper */ public static function getCurrencyCode(bool $trimAlt = false): string { - if (!isset(self::$currencyCode)) { - self::$currencyCode = self::getLocaleValue('currency_symbol', 'int_curr_symbol', '$', $trimAlt); + if (!isset(static::$currencyCode)) { + static::$currencyCode = self::getLocaleValue('currency_symbol', 'int_curr_symbol', '$', $trimAlt); } - return self::$currencyCode; + return static::$currencyCode; } /** @@ -624,7 +687,7 @@ class StringHelper */ public static function setCurrencyCode(?string $currencyCode): void { - self::$currencyCode = $currencyCode; + static::$currencyCode = $currencyCode; } /** @@ -724,16 +787,96 @@ class StringHelper * Php introduced str_increment with Php8.3, * but didn't issue deprecation notices till 8.5. * + * @param-out string $str + * * @codeCoverageIgnore */ public static function stringIncrement(string &$str): string { if (function_exists('str_increment')) { - $str = str_increment($str); // @phpstan-ignore-line + /** @var non-empty-string $str */ + $str2 = str_increment($str); } else { - ++$str; // @phpstan-ignore-line + $str1 = $str; + /** + * This is an outright lie, but I don't know how else to satisfy Phpstan for Php8.5+. + * + * @var numeric-string $str1 + */ + ++$str1; + $str2 = "$str1"; } - return $str; // @phpstan-ignore-line + /** + * This is demonstrably the case. I don't know why Phpstan thinks $str2 is mixed at this point. + * + * @var string $str2 set above by str_increment or ="..." + */ + $str = $str2; + + return $str; + } + + /** @internal */ + protected static string $testClass = IntlCalendar::class; + + /** + * Set all of currencyCode, thousandsSeparator, decimalSeparator, + * and Calculation locale with a single call. + * The main point here is avoid the use of Php setlocale, + * which is not threadsafe. It uses the Intl extension instead, + * which is not a requirement for PhpSpreadsheet. + * Because of that, the function returns a bool which will + * be false if Intl is not available, or the supplied locale + * is not valid according to Intl. + */ + public static function setLocale(?string $locale): bool + { + if ($locale === null) { + self::$currencyCode = null; + self::$thousandsSeparator = null; + self::$decimalSeparator = null; + Calculation::getInstance()->setLocale('en_us'); + + return true; + } + $localeCalc = $locale; + if (Preg::isMatch('/^([a-z][a-z])_([a-z][a-z])(?:[.]utf-8)?$/i', $locale, $matches)) { + $locale = strtolower($matches[1]) . '_' . strtoupper($matches[2]); + $localeCalc = strtolower($matches[1]) . '_' . strtolower($matches[2]); + } + if (!class_exists(static::$testClass)) { + return false; + } + // NumberFormatter constructor succeeds even with + // bad locale before Php8.4, so try to validate + // the locale beforehand. + $locales = IntlCalendar::getAvailableLocales(); + if (!in_array($locale, $locales, true)) { + return false; + } + $formatter = new NumberFormatter( + $locale, + NumberFormatter::CURRENCY + ); + $currency = $formatter->getSymbol( + NumberFormatter::CURRENCY_SYMBOL + ); + $formatter = new NumberFormatter( + $locale, + NumberFormatter::DECIMAL + ); + $thousands = $formatter->getSymbol( + NumberFormatter::GROUPING_SEPARATOR_SYMBOL + ); + $decimal = $formatter->getSymbol( + NumberFormatter::DECIMAL_SEPARATOR_SYMBOL + ); + self::$currencyCode = $currency; + self::$thousandsSeparator = $thousands; + self::$decimalSeparator = $decimal; + Calculation::getInstance()->setLocale($localeCalc); + + return true; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php index 3a17205..7b2fd04 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/BestFit.php @@ -111,6 +111,16 @@ abstract class BestFit return $this->xValues; } + /** + * Return the original set of Y-Values. + * + * @return float[] Y-Values + */ + public function getYValues(): array + { + return $this->yValues; + } + /** * Return the Equation of the best-fit line. * @@ -416,7 +426,7 @@ abstract class BestFit // Define X Values if necessary if ($xValueCount === 0) { - $xValues = range(1, $yValueCount); + $xValues = range(1.0, $yValueCount); } elseif ($yValueCount !== $xValueCount) { // Ensure both arrays of points are the same size $this->error = true; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php index 8a54080..c385f7c 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/LinearBestFit.php @@ -69,7 +69,7 @@ class LinearBestFit extends BestFit parent::__construct($yValues, $xValues); if (!$this->error) { - $this->linearRegression($yValues, $xValues, (bool) $const); + $this->linearRegression($this->yValues, $this->xValues, (bool) $const); } } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php index 0fc6671..a40f43b 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Trend/PolynomialBestFit.php @@ -24,7 +24,7 @@ class PolynomialBestFit extends BestFit */ protected int $order = 0; - private bool $implemented = false; + protected bool $implemented = false; /** * Return the order of this polynomial. @@ -45,9 +45,7 @@ class PolynomialBestFit extends BestFit { $retVal = $this->getIntersect(); $slope = $this->getSlope(); - // Phpstan and Scrutinizer are both correct - getSlope returns float, not array. - // @phpstan-ignore-next-line - foreach ($slope as $key => $value) { + foreach ($slope as $key => $value) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess) /** @var float $value */ if ($value != 0.0) { /** @var int $key */ @@ -82,8 +80,7 @@ class PolynomialBestFit extends BestFit $equation = 'Y = ' . $intersect; // Phpstan and Scrutinizer are both correct - getSlope returns float, not array. - // @phpstan-ignore-next-line - foreach ($slope as $key => $value) { + foreach ($slope as $key => $value) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess) /** @var float|int $value */ if ($value != 0.0) { $equation .= ' + ' . $value . ' * X'; @@ -106,14 +103,12 @@ class PolynomialBestFit extends BestFit { if ($dp != 0) { $coefficients = []; - //* @phpstan-ignore-next-line - foreach ($this->slope as $coefficient) { + foreach ($this->slope as $coefficient) { //* @phpstan-ignore foreach.nonIterable (this whole class is a mess) /** @var float|int $coefficient */ $coefficients[] = round($coefficient, $dp); } - // @phpstan-ignore-next-line - return $coefficients; + return $coefficients; //* @phpstan-ignore return.type (this whole class is a mess) } return $this->slope; @@ -122,9 +117,7 @@ class PolynomialBestFit extends BestFit /** @return array */ public function getCoefficients(int $dp = 0): array { - // Phpstan and Scrutinizer are both correct - getSlope returns float, not array. - // @phpstan-ignore-next-line - return array_merge([$this->getIntersect($dp)], $this->getSlope($dp)); + return array_merge([$this->getIntersect($dp)], $this->getSlope($dp)); //* @phpstan-ignore return.type (this whole class is a mess), argument.type (ditto) } /** @@ -179,9 +172,7 @@ class PolynomialBestFit extends BestFit } $this->intersect = (float) array_shift($coefficients); - // Phpstan is correct - //* @phpstan-ignore-next-line - $this->slope = $coefficients; + $this->slope = $coefficients; //* @phpstan-ignore assign.propertyType (this whole class is a mess) $this->calculateGoodnessOfFit($x_sum, $y_sum, $xx_sum, $yy_sum, $xy_sum, 0, 0, 0); foreach ($this->xValues as $xKey => $xValue) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php index 17b1ee6..9cde153 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/XMLWriter.php @@ -64,7 +64,15 @@ class XMLWriter extends \XMLWriter } } - /** @param mixed[] $data */ + /** + * Unserialization is not allowed. This needs to be enforced + * in the class before Php8.6, but, with that release, + * this method is no longer needed and will not be executed. + * + * @see https://github.com/php/php-src/pull/21694 + * + * @param mixed[] $data + */ public function __unserialize(array $data): void { $this->tempFileName = ''; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xls.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xls.php index 6e34a5d..9bc6c30 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xls.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xls.php @@ -202,7 +202,7 @@ class Xls * @param int $width Width in pixels * @param int $height Height in pixels * - * @return array{startCoordinates: string, startOffsetX: int, startOffsetY: int, endCoordinates: string, endOffsetX: int, endOffsetY: int} + * @return ?array{startCoordinates: string, startOffsetX: float|int, startOffsetY: float|int, endCoordinates: string, endOffsetX: float|int, endOffsetY: float|int} */ public static function oneAnchor2twoAnchor(Worksheet $worksheet, string $coordinates, int $offsetX, int $offsetY, int $width, int $height): ?array { @@ -241,16 +241,12 @@ class Xls // Bitmap isn't allowed to start or finish in a hidden cell, i.e. a cell // with zero height or width. - if (self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_start)) == 0) { - return null; - } - if (self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_end)) == 0) { - return null; - } - if (self::sizeRow($worksheet, $row_start + 1) == 0) { - return null; - } - if (self::sizeRow($worksheet, $row_end + 1) == 0) { + if ( + self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_start)) == 0 + || self::sizeCol($worksheet, Coordinate::stringFromColumnIndex($col_end)) == 0 + || self::sizeRow($worksheet, $row_start + 1) == 0 + || self::sizeRow($worksheet, $row_end + 1) == 0 + ) { return null; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xlsx/AgileEncryption.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xlsx/AgileEncryption.php new file mode 100644 index 0000000..f41c407 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Shared/Xlsx/AgileEncryption.php @@ -0,0 +1,842 @@ +> 2; + + private const CFB_DIFAT_ENTRIES_IN_HEADER = 109; + + private const CFB_DIFAT_ENTRIES_PER_SECTOR = self::CFB_FAT_ENTRIES_PER_SECTOR - 1; + + private const CFB_DIRECTORY_ENTRIES_PER_SECTOR = self::CFB_SECTOR_SIZE >> 7; + + private const CFB_MINI_SECTORS_PER_SECTOR = self::CFB_SECTOR_SIZE >> 6; + + private const DIRECTORY_ENCRYPTED_PACKAGE = 1; + + private const DIRECTORY_DATA_SPACES = 2; + + private const DIRECTORY_VERSION = 3; + + private const DIRECTORY_DATA_SPACE_MAP = 4; + + private const DIRECTORY_DATA_SPACE_INFO = 5; + + private const DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE = 6; + + private const DIRECTORY_TRANSFORM_INFO = 7; + + private const DIRECTORY_STRONG_ENCRYPTION_TRANSFORM = 8; + + private const DIRECTORY_PRIMARY = 9; + + private const DIRECTORY_ENCRYPTION_INFO = 10; + + private const CFB_DIRECTORY_ENTRY_COUNT = self::DIRECTORY_ENCRYPTION_INFO + 1; + + private const STREAM_ENCRYPTED_PACKAGE = 'EncryptedPackage'; + + private const STORAGE_DATA_SPACES = "\x06DataSpaces"; + + private const STREAM_VERSION = 'Version'; + + private const STREAM_DATA_SPACE_MAP = 'DataSpaceMap'; + + private const STORAGE_DATA_SPACE_INFO = 'DataSpaceInfo'; + + private const STREAM_STRONG_ENCRYPTION_DATA_SPACE = 'StrongEncryptionDataSpace'; + + private const STORAGE_TRANSFORM_INFO = 'TransformInfo'; + + private const STORAGE_STRONG_ENCRYPTION_TRANSFORM = 'StrongEncryptionTransform'; + + private const STREAM_PRIMARY = "\x06Primary"; + + private const STREAM_ENCRYPTION_INFO = 'EncryptionInfo'; + + /** @var array */ + private const HASH_ALGORITHMS = [ + 'SHA1' => ['sha1', 20], + 'SHA256' => ['sha256', 32], + 'SHA384' => ['sha384', 48], + 'SHA512' => ['sha512', 64], + ]; + + /** + * @return array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} + */ + public static function parse(string $encryptionInfo, int $maxSpinCount = self::MAX_SPIN_COUNT): array + { + if (substr($encryptionInfo, 0, 8) !== "\x04\x00\x04\x00\x40\x00\x00\x00") { + throw new Exception('Unsupported XLSX encryption profile.'); + } + + $xml = @simplexml_load_string(substr($encryptionInfo, 8)); + if (!$xml instanceof SimpleXMLElement) { + throw new Exception('Malformed XLSX encryption information.'); + } + $namespaces = $xml->getDocNamespaces(true) ?: []; + if ($xml->getName() !== 'encryption' || !in_array(self::ENCRYPTION_NAMESPACE, $namespaces, true) || !in_array(self::PASSWORD_NAMESPACE, $namespaces, true)) { + throw new Exception('Unsupported XLSX encryption profile.'); + } + $xml->registerXPathNamespace('e', self::ENCRYPTION_NAMESPACE); + $xml->registerXPathNamespace('p', self::PASSWORD_NAMESPACE); + $keyDataNodes = $xml->xpath('/e:encryption/e:keyData') ?: []; + $integrityNodes = $xml->xpath('/e:encryption/e:dataIntegrity') ?: []; + $keyEncryptorNodes = $xml->xpath('/e:encryption/e:keyEncryptors/e:keyEncryptor') ?: []; + $encryptedKeyNodes = $xml->xpath('/e:encryption/e:keyEncryptors/e:keyEncryptor/p:encryptedKey') ?: []; + $keyData = $keyDataNodes[0] ?? null; + $integrity = $integrityNodes[0] ?? null; + $keyEncryptor = $keyEncryptorNodes[0] ?? null; + $encryptedKey = $encryptedKeyNodes[0] ?? null; + if (!$keyData instanceof SimpleXMLElement || !$integrity instanceof SimpleXMLElement || !$keyEncryptor instanceof SimpleXMLElement || !$encryptedKey instanceof SimpleXMLElement || count($keyDataNodes) !== 1 || count($integrityNodes) !== 1 || count($keyEncryptorNodes) !== 1 || count($encryptedKeyNodes) !== 1) { + throw new Exception('Unsupported XLSX encryption profile.'); + } + $keyBits = self::decimalAttribute($keyData, 'keyBits'); + $hashAlgorithm = (string) $keyData['hashAlgorithm']; + $hashSize = self::decimalAttribute($keyData, 'hashSize'); + $spinCount = self::decimalAttribute($encryptedKey, 'spinCount'); + $keyDataSaltSize = self::decimalAttribute($keyData, 'saltSize'); + $keyDataBlockSize = self::decimalAttribute($keyData, 'blockSize'); + $encryptedKeySaltSize = self::decimalAttribute($encryptedKey, 'saltSize'); + $encryptedKeyBlockSize = self::decimalAttribute($encryptedKey, 'blockSize'); + $encryptedKeyBits = self::decimalAttribute($encryptedKey, 'keyBits'); + $encryptedKeyHashSize = self::decimalAttribute($encryptedKey, 'hashSize'); + if ( + (string) $keyData['cipherAlgorithm'] !== 'AES' || (string) $keyData['cipherChaining'] !== 'ChainingModeCBC' + || !self::isSupportedProfile($keyBits, $hashAlgorithm, $hashSize) || $keyDataSaltSize !== 16 || $keyDataBlockSize !== 16 + || (string) $encryptedKey['cipherAlgorithm'] !== 'AES' + || (string) $encryptedKey['cipherChaining'] !== 'ChainingModeCBC' + || (string) $encryptedKey['hashAlgorithm'] !== $hashAlgorithm || $encryptedKeySaltSize !== 16 || $encryptedKeyBlockSize !== 16 || $encryptedKeyBits !== $keyBits + || $encryptedKeyHashSize !== $hashSize || $spinCount > min($maxSpinCount, self::MAX_SPIN_COUNT) + || (string) $keyEncryptor['uri'] !== self::PASSWORD_KEY_ENCRYPTOR_URI + ) { + throw new Exception('Unsupported XLSX encryption profile.'); + } + + $result = [ + 'keyDataSalt' => self::decode($keyData['saltValue']), + 'passwordSalt' => self::decode($encryptedKey['saltValue']), + 'encryptedVerifier' => self::decode($encryptedKey['encryptedVerifierHashInput']), + 'encryptedVerifierHash' => self::decode($encryptedKey['encryptedVerifierHashValue']), + 'encryptedKey' => self::decode($encryptedKey['encryptedKeyValue']), + 'encryptedHmacKey' => self::decode($integrity['encryptedHmacKey']), + 'encryptedHmacValue' => self::decode($integrity['encryptedHmacValue']), + 'spinCount' => $spinCount, + 'keyBits' => $keyBits, + 'hashAlgorithm' => $hashAlgorithm, + 'hashSize' => $hashSize, + ]; + if ( + strlen($result['keyDataSalt']) !== 16 || strlen($result['passwordSalt']) !== 16 + || strlen($result['encryptedVerifier']) !== 16 || strlen($result['encryptedVerifierHash']) !== self::paddedLength($hashSize) + || strlen($result['encryptedKey']) !== self::paddedLength(intdiv($keyBits, 8)) || strlen($result['encryptedHmacKey']) !== self::paddedLength($hashSize) + || strlen($result['encryptedHmacValue']) !== self::paddedLength($hashSize) + ) { + throw new Exception('Malformed XLSX encryption information.'); + } + + return $result; + } + + /** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */ + public static function decrypt(array $info, string $encryptedPackage, string $password): string + { + if ($password === '') { + throw new Exception('XLSX encryption password required.'); + } + if (strlen($encryptedPackage) < 8) { + throw new Exception('Malformed encrypted XLSX package.'); + } + $hash = self::passwordHash($password, $info['passwordSalt'], $info['spinCount'], $info['hashAlgorithm']); + $verifierKey = self::deriveKey($hash, self::BLOCK_KEY_VERIFIER, $info['hashAlgorithm'], $info['keyBits']); + $verifierHashKey = self::deriveKey($hash, self::BLOCK_KEY_VERIFIER_HASH, $info['hashAlgorithm'], $info['keyBits']); + $verifier = self::aesDecrypt($info['encryptedVerifier'], $verifierKey, $info['passwordSalt'], $info['keyBits']); + $expectedHash = self::aesDecrypt($info['encryptedVerifierHash'], $verifierHashKey, $info['passwordSalt'], $info['keyBits']); + if (!hash_equals(self::hash($info['hashAlgorithm'], $verifier), substr($expectedHash, 0, $info['hashSize']))) { + throw new Exception('XLSX encryption password is incorrect.'); + } + $secretKey = self::aesDecrypt($info['encryptedKey'], self::deriveKey($hash, self::BLOCK_KEY_ENCRYPTED_KEY, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']); + $secretKey = substr($secretKey, 0, intdiv($info['keyBits'], 8)); + if (strlen($secretKey) !== intdiv($info['keyBits'], 8)) { + throw new Exception('Malformed XLSX encryption information.'); + } + self::verifyIntegrity($info, $secretKey, $encryptedPackage); + $size = self::unpackSize(substr($encryptedPackage, 0, 8)); + $offset = 8; + $plain = ''; + for ($block = 0; $size > 0; ++$block) { + $length = min(self::SEGMENT_SIZE, $size); + $cipherLength = (int) (ceil($length / self::BLOCK_SIZE) * self::BLOCK_SIZE); + $cipher = substr($encryptedPackage, $offset, $cipherLength); + if (strlen($cipher) !== $cipherLength) { + throw new Exception('Malformed encrypted XLSX package.'); + } + $iv = self::iv($info['keyDataSalt'], pack('V', $block), $info['hashAlgorithm']); + $plain .= substr(self::aesDecrypt($cipher, $secretKey, $iv, $info['keyBits']), 0, $length); + $offset += $cipherLength; + $size -= $length; + } + if ($offset !== strlen($encryptedPackage)) { + throw new Exception('Malformed encrypted XLSX package.'); + } + + return $plain; + } + + /** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */ + public static function decryptFile(array $info, string $inputFilename, string $outputFilename, string $password): void + { + if ($password === '') { + throw new Exception('XLSX encryption password required.'); + } + $input = @fopen($inputFilename, 'rb'); + if ($input === false) { + throw new Exception('Could not open XLSX package for decryption.'); + } + + try { + $hash = self::passwordHash($password, $info['passwordSalt'], $info['spinCount'], $info['hashAlgorithm']); + $verifier = self::aesDecrypt($info['encryptedVerifier'], self::deriveKey($hash, self::BLOCK_KEY_VERIFIER, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']); + $expected = self::aesDecrypt($info['encryptedVerifierHash'], self::deriveKey($hash, self::BLOCK_KEY_VERIFIER_HASH, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']); + if (!hash_equals(self::hash($info['hashAlgorithm'], $verifier), substr($expected, 0, $info['hashSize']))) { + throw new Exception('XLSX encryption password is incorrect.'); + } + $secretKey = substr(self::aesDecrypt($info['encryptedKey'], self::deriveKey($hash, self::BLOCK_KEY_ENCRYPTED_KEY, $info['hashAlgorithm'], $info['keyBits']), $info['passwordSalt'], $info['keyBits']), 0, intdiv($info['keyBits'], 8)); + if (strlen($secretKey) !== intdiv($info['keyBits'], 8)) { + throw new Exception('Malformed XLSX encryption information.'); + } + self::verifyIntegrityFile($info, $secretKey, $input); + $output = @fopen($outputFilename, 'wb'); + if ($output === false) { + throw new Exception('Could not open XLSX package for decryption.'); + } + rewind($input); + + try { + $size = self::unpackSize(self::read($input, 8)); + for ($block = 0; $size > 0; ++$block) { + $length = min(self::SEGMENT_SIZE, $size); + $cipher = self::read($input, self::paddedLength($length)); + $iv = self::iv($info['keyDataSalt'], pack('V', $block), $info['hashAlgorithm']); + self::write($output, substr(self::aesDecrypt($cipher, $secretKey, $iv, $info['keyBits']), 0, $length)); + $size -= $length; + } + if (fread($input, 1) !== '') { + throw new Exception('Malformed encrypted XLSX package.'); + } + } finally { + fclose($output); + } + } finally { + fclose($input); + } + } + + /** + * Create the EncryptionInfo and EncryptedPackage streams for an Agile-encrypted XLSX. + * + * @return array{encryptionInfo: string, encryptedPackage: string} + */ + public static function encrypt(string $plainPackage, string $password, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $spinCount = 100000): array + { + if ($password === '') { + throw new Exception('XLSX encryption password required.'); + } + if (!self::isSupportedProfile($keyBits, $hashAlgorithm, self::HASH_ALGORITHMS[$hashAlgorithm][1] ?? 0) || $spinCount < 0 || $spinCount > self::MAX_SPIN_COUNT) { + throw new Exception('Unsupported XLSX encryption profile.'); + } + + $passwordSalt = random_bytes(self::BLOCK_SIZE); + $keyDataSalt = random_bytes(self::BLOCK_SIZE); + $hashSize = self::hashSize($hashAlgorithm); + $passwordHash = self::passwordHash($password, $passwordSalt, $spinCount, $hashAlgorithm); + $verifier = random_bytes(self::BLOCK_SIZE); + $secretKey = random_bytes(self::keyLength($keyBits)); + $encryptedVerifier = self::aesEncrypt($verifier, self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + $encryptedVerifierHash = self::aesEncrypt(str_pad(self::hash($hashAlgorithm, $verifier), self::paddedLength($hashSize), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER_HASH, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + $encryptedKey = self::aesEncrypt(str_pad($secretKey, self::paddedLength(strlen($secretKey)), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_ENCRYPTED_KEY, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + + $encryptedPackage = self::packSize(strlen($plainPackage)); + for ($block = 0, $offset = 0; $offset < strlen($plainPackage); ++$block, $offset += self::SEGMENT_SIZE) { + $segment = substr($plainPackage, $offset, self::SEGMENT_SIZE); + $iv = self::iv($keyDataSalt, pack('V', $block), $hashAlgorithm); + $encryptedPackage .= self::aesEncrypt(str_pad($segment, self::paddedLength(strlen($segment)), "\x00"), $secretKey, $iv, $keyBits); + } + + $hmacKey = random_bytes(self::hashSize($hashAlgorithm)); + $hmacKeyIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_KEY, $hashAlgorithm); + $hmacValueIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_VALUE, $hashAlgorithm); + $encryptedHmacKey = self::aesEncrypt(str_pad($hmacKey, self::paddedLength($hashSize), "\x00"), $secretKey, $hmacKeyIv, $keyBits); + $encryptedHmacValue = self::aesEncrypt(str_pad(hash_hmac(self::HASH_ALGORITHMS[$hashAlgorithm][0], $encryptedPackage, $hmacKey, true), self::paddedLength($hashSize), "\x00"), $secretKey, $hmacValueIv, $keyBits); + + $encryptionInfo = "\x04\x00\x04\x00\x40\x00\x00\x00" . self::encryptionInfoXml( + $keyDataSalt, + $passwordSalt, + $encryptedVerifier, + $encryptedVerifierHash, + $encryptedKey, + $encryptedHmacKey, + $encryptedHmacValue, + $keyBits, + $hashAlgorithm, + $hashSize, + $spinCount + ); + + return ['encryptionInfo' => $encryptionInfo, 'encryptedPackage' => $encryptedPackage]; + } + + /** + * Encrypt a ZIP package without loading either package into memory. + * + * @return array{encryptionInfo: string, encryptedPackageFilename: string} + */ + public static function encryptFile(string $plainPackageFilename, string $password, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $spinCount = 100000): array + { + if ($password === '') { + throw new Exception('XLSX encryption password required.'); + } + if (!self::isSupportedProfile($keyBits, $hashAlgorithm, self::HASH_ALGORITHMS[$hashAlgorithm][1] ?? 0) || $spinCount < 0 || $spinCount > self::MAX_SPIN_COUNT) { + throw new Exception('Unsupported XLSX encryption profile.'); + } + $size = @filesize($plainPackageFilename); + if ($size === false) { + throw new Exception('Could not determine XLSX package size.'); + } + $input = fopen($plainPackageFilename, 'rb'); + if ($input === false) { + throw new Exception('Could not open XLSX package for encryption.'); + } + $encryptedPackageFilename = SharedFile::temporaryFilename(); + $output = fopen($encryptedPackageFilename, 'wb'); + if ($output === false) { + fclose($input); + @unlink($encryptedPackageFilename); + + throw new Exception('Could not create encrypted XLSX package.'); + } + + try { + $passwordSalt = random_bytes(self::BLOCK_SIZE); + $keyDataSalt = random_bytes(self::BLOCK_SIZE); + $hashSize = self::hashSize($hashAlgorithm); + $passwordHash = self::passwordHash($password, $passwordSalt, $spinCount, $hashAlgorithm); + $verifier = random_bytes(self::BLOCK_SIZE); + $secretKey = random_bytes(self::keyLength($keyBits)); + $encryptedVerifier = self::aesEncrypt($verifier, self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + $encryptedVerifierHash = self::aesEncrypt(str_pad(self::hash($hashAlgorithm, $verifier), self::paddedLength($hashSize), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_VERIFIER_HASH, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + $encryptedKey = self::aesEncrypt(str_pad($secretKey, self::paddedLength(strlen($secretKey)), "\x00"), self::deriveKey($passwordHash, self::BLOCK_KEY_ENCRYPTED_KEY, $hashAlgorithm, $keyBits), $passwordSalt, $keyBits); + $hmacKey = random_bytes(self::hashSize($hashAlgorithm)); + $hmac = hash_init(self::HASH_ALGORITHMS[$hashAlgorithm][0], HASH_HMAC, $hmacKey); + $header = self::packSize($size); + self::write($output, $header); + hash_update($hmac, $header); + $remaining = $size; + for ($block = 0; $remaining > 0; ++$block) { + $length = min(self::SEGMENT_SIZE, $remaining); + $segment = fread($input, $length); + if ($segment === false || strlen($segment) !== $length) { + throw new Exception('Could not read XLSX package for encryption.'); + } + $iv = self::iv($keyDataSalt, pack('V', $block), $hashAlgorithm); + $cipher = self::aesEncrypt(str_pad($segment, self::paddedLength(strlen($segment)), "\x00"), $secretKey, $iv, $keyBits); + self::write($output, $cipher); + hash_update($hmac, $cipher); + $remaining -= $length; + } + $extra = fread($input, 1); + if ($extra === false || $extra !== '') { + throw new Exception('XLSX package changed while being encrypted.'); + } + $hmacKeyIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_KEY, $hashAlgorithm); + $hmacValueIv = self::iv($keyDataSalt, self::BLOCK_KEY_HMAC_VALUE, $hashAlgorithm); + $encryptedHmacKey = self::aesEncrypt(str_pad($hmacKey, self::paddedLength($hashSize), "\x00"), $secretKey, $hmacKeyIv, $keyBits); + $encryptedHmacValue = self::aesEncrypt(str_pad(hash_final($hmac, true), self::paddedLength($hashSize), "\x00"), $secretKey, $hmacValueIv, $keyBits); + $encryptionInfo = "\x04\x00\x04\x00\x40\x00\x00\x00" . self::encryptionInfoXml($keyDataSalt, $passwordSalt, $encryptedVerifier, $encryptedVerifierHash, $encryptedKey, $encryptedHmacKey, $encryptedHmacValue, $keyBits, $hashAlgorithm, $hashSize, $spinCount); + } catch (Throwable $e) { + @unlink($encryptedPackageFilename); + + throw $e; + } finally { + fclose($input); + fclose($output); + } + + return ['encryptionInfo' => $encryptionInfo, 'encryptedPackageFilename' => $encryptedPackageFilename]; + } + + /** + * Write an Office encrypted package CFB container using a file-backed EncryptedPackage stream. + * + * @param resource $fileHandle + */ + public static function writeContainerFromFile($fileHandle, string $encryptionInfo, string $encryptedPackageFilename): void + { + $containerFilename = SharedFile::temporaryFilename(); + $container = fopen($containerFilename, 'w+b'); + if ($container === false) { + @unlink($containerFilename); + + throw new Exception('Could not create encrypted XLSX container.'); + } + + try { + self::writeEcma376Container($container, $encryptionInfo, $encryptedPackageFilename); + rewind($container); + while (!feof($container)) { + $data = fread($container, 8192); + if ($data === false) { + throw new Exception('Could not read encrypted XLSX container.'); + } + if ($data !== '' && fwrite($fileHandle, $data) !== strlen($data)) { + throw new Exception('Could not write encrypted XLSX container.'); + } + } + } finally { + fclose($container); + @unlink($containerFilename); + } + } + + /** + * Serialize the ECMA-376 encrypted-package CFB layout. The directory tree + * is prescribed by the Data Spaces structure; sector allocation is dynamic + * so normal workbook size is not artificially constrained. + * + * @param resource $fileHandle + */ + private static function writeEcma376Container($fileHandle, string $encryptionInfo, string $encryptedPackageFilename): void + { + $packageSize = filesize($encryptedPackageFilename); + if ($packageSize === false || $packageSize < self::CFB_MINI_STREAM_CUTOFF || $packageSize > 0xFFFFFFFF) { + throw new Exception('Malformed encrypted XLSX package.'); + } + $smallStreams = [ + self::DIRECTORY_VERSION => [self::STREAM_VERSION, self::dataSpacesVersion()], + self::DIRECTORY_DATA_SPACE_MAP => [self::STREAM_DATA_SPACE_MAP, self::dataSpaceMap()], + self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE => [self::STREAM_STRONG_ENCRYPTION_DATA_SPACE, self::strongEncryptionDataSpace()], + self::DIRECTORY_PRIMARY => [self::STREAM_PRIMARY, self::primaryTransform()], + self::DIRECTORY_ENCRYPTION_INFO => [self::STREAM_ENCRYPTION_INFO, $encryptionInfo], + ]; + $miniStarts = array_fill_keys(array_keys($smallStreams), 0); + $miniFat = []; + $miniCount = 0; + foreach ($smallStreams as $id => [, $content]) { + if (strlen($content) >= self::CFB_MINI_STREAM_CUTOFF) { + throw new Exception('Malformed XLSX encryption information.'); + } + $count = (int) ceil(strlen($content) / self::CFB_MINI_SECTOR_SIZE); + $miniStarts[$id] = $miniCount; + for ($i = 0; $i < $count - 1; ++$i) { + $miniFat[$miniCount + $i] = $miniCount + $i + 1; + } + $miniFat[$miniCount + $count - 1] = 0xFFFFFFFE; + $miniCount += $count; + } + $miniFatSectors = (int) ceil($miniCount / self::CFB_FAT_ENTRIES_PER_SECTOR); + $miniFat = array_pad($miniFat, $miniFatSectors * self::CFB_FAT_ENTRIES_PER_SECTOR, 0xFFFFFFFF); + $miniDataSectors = (int) ceil($miniCount / self::CFB_MINI_SECTORS_PER_SECTOR); + $directorySectors = (int) ceil(self::CFB_DIRECTORY_ENTRY_COUNT / self::CFB_DIRECTORY_ENTRIES_PER_SECTOR); + $packageSectors = (int) ceil($packageSize / self::CFB_SECTOR_SIZE); + $contentSectors = $miniFatSectors + $directorySectors + $miniDataSectors + $packageSectors; + $fatSectors = 1; + $difatSectors = 0; + do { + $requiredFatSectors = (int) ceil(($contentSectors + $fatSectors + $difatSectors) / self::CFB_FAT_ENTRIES_PER_SECTOR); + $requiredDifatSectors = $requiredFatSectors <= self::CFB_DIFAT_ENTRIES_IN_HEADER ? 0 : (int) ceil(($requiredFatSectors - self::CFB_DIFAT_ENTRIES_IN_HEADER) / self::CFB_DIFAT_ENTRIES_PER_SECTOR); + $changed = $requiredFatSectors !== $fatSectors || $requiredDifatSectors !== $difatSectors; + $fatSectors = $requiredFatSectors; + $difatSectors = $requiredDifatSectors; + } while ($changed); + $miniFatSector = $fatSectors; + $directorySector = $miniFatSector + $miniFatSectors; + $miniDataSector = $directorySector + $directorySectors; + $packageSector = $miniDataSector + $miniDataSectors; + $difatSector = $packageSector + $packageSectors; + + self::write($fileHandle, self::cfbHeader($fatSectors, $directorySector, $miniFatSector, $miniFatSectors, $difatSector, $difatSectors)); + $fat = array_fill(0, $fatSectors * self::CFB_FAT_ENTRIES_PER_SECTOR, 0xFFFFFFFF); + for ($sector = 0; $sector < $fatSectors; ++$sector) { + $fat[$sector] = 0xFFFFFFFD; + } + self::chainFat($fat, $miniFatSector, $miniFatSectors); + self::chainFat($fat, $directorySector, $directorySectors); + self::chainFat($fat, $miniDataSector, $miniDataSectors); + self::chainFat($fat, $packageSector, $packageSectors); + for ($sector = 0; $sector < $difatSectors; ++$sector) { + $fat[$difatSector + $sector] = 0xFFFFFFFC; + } + self::write($fileHandle, self::packSectors($fat)); + self::write($fileHandle, self::packSectors($miniFat)); + + $entries = [ + // CFB sibling trees sort by name length, then case-folded UTF-16 code points. + self::directoryEntry('Root Entry', 5, 1, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_ENCRYPTION_INFO, $miniDataSector, $miniCount * self::CFB_MINI_SECTOR_SIZE), + self::directoryEntry(self::STREAM_ENCRYPTED_PACKAGE, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $packageSector, $packageSize), + self::directoryEntry(self::STORAGE_DATA_SPACES, 1, 0, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_DATA_SPACE_MAP, 0, 0), + self::directoryEntry(self::STREAM_VERSION, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_VERSION], strlen($smallStreams[self::DIRECTORY_VERSION][1])), + self::directoryEntry(self::STREAM_DATA_SPACE_MAP, 2, 1, self::DIRECTORY_VERSION, self::DIRECTORY_DATA_SPACE_INFO, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_DATA_SPACE_MAP], strlen($smallStreams[self::DIRECTORY_DATA_SPACE_MAP][1])), + self::directoryEntry(self::STORAGE_DATA_SPACE_INFO, 1, 0, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_TRANSFORM_INFO, 0, 0), + self::directoryEntry(self::STREAM_STRONG_ENCRYPTION_DATA_SPACE, 2, 0, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE], strlen($smallStreams[self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE][1])), + self::directoryEntry(self::STORAGE_TRANSFORM_INFO, 1, 1, self::DIRECTORY_STRONG_ENCRYPTION_DATA_SPACE, 0xFFFFFFFF, self::DIRECTORY_STRONG_ENCRYPTION_TRANSFORM, 0, 0), + self::directoryEntry(self::STORAGE_STRONG_ENCRYPTION_TRANSFORM, 1, 1, 0xFFFFFFFF, 0xFFFFFFFF, self::DIRECTORY_PRIMARY, 0, 0), + self::directoryEntry(self::STREAM_PRIMARY, 2, 1, 0xFFFFFFFF, 0xFFFFFFFF, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_PRIMARY], strlen($smallStreams[self::DIRECTORY_PRIMARY][1])), + self::directoryEntry(self::STREAM_ENCRYPTION_INFO, 2, 1, self::DIRECTORY_DATA_SPACES, self::DIRECTORY_ENCRYPTED_PACKAGE, 0xFFFFFFFF, $miniStarts[self::DIRECTORY_ENCRYPTION_INFO], strlen($smallStreams[self::DIRECTORY_ENCRYPTION_INFO][1])), + ]; + self::write($fileHandle, str_pad(implode('', $entries), $directorySectors * self::CFB_SECTOR_SIZE, "\x00")); + $miniData = str_repeat("\x00", $miniDataSectors * self::CFB_SECTOR_SIZE); + foreach ($smallStreams as $id => [, $content]) { + $miniData = substr_replace($miniData, $content, $miniStarts[$id] * self::CFB_MINI_SECTOR_SIZE, strlen($content)); + } + self::write($fileHandle, $miniData); + $package = fopen($encryptedPackageFilename, 'rb'); + if ($package === false) { + throw new Exception('Could not open encrypted XLSX package.'); + } + + try { + while (!feof($package)) { + $data = fread($package, 8192); + if ($data === false) { + throw new Exception('Could not read encrypted XLSX package.'); + } + if ($data !== '') { + self::write($fileHandle, $data); + } + } + } finally { + fclose($package); + } + $padding = $packageSectors * self::CFB_SECTOR_SIZE - $packageSize; + if ($padding > 0) { + self::write($fileHandle, str_repeat("\x00", $padding)); + } + for ($sector = 0; $sector < $difatSectors; ++$sector) { + $firstFatSector = self::CFB_DIFAT_ENTRIES_IN_HEADER + $sector * self::CFB_DIFAT_ENTRIES_PER_SECTOR; + $fatReferences = []; + for ($i = 0; $i < self::CFB_DIFAT_ENTRIES_PER_SECTOR; ++$i) { + $fatReferences[] = $firstFatSector + $i < $fatSectors ? $firstFatSector + $i : 0xFFFFFFFF; + } + $fatReferences[] = $sector + 1 < $difatSectors ? $difatSector + $sector + 1 : 0xFFFFFFFE; + self::write($fileHandle, self::packSectors($fatReferences)); + } + } + + /** @param int[] $fat */ + private static function chainFat(array &$fat, int $start, int $count): void + { + for ($i = 0; $i < $count - 1; ++$i) { + $fat[$start + $i] = $start + $i + 1; + } + $fat[$start + $count - 1] = 0xFFFFFFFE; + } + + /** @param int[] $sectors */ + private static function packSectors(array $sectors): string + { + return pack('V*', ...$sectors); + } + + private static function cfbHeader(int $fatSectors, int $directorySector, int $miniFatSector, int $miniFatSectors, int $difatSector, int $difatSectors): string + { + $headerDifat = []; + for ($sector = 0; $sector < self::CFB_DIFAT_ENTRIES_IN_HEADER; ++$sector) { + $headerDifat[] = $sector < $fatSectors ? $sector : 0xFFFFFFFF; + } + + return "\xD0\xCF\x11\xE0\xA1\xB1\x1A\xE1" . str_repeat("\x00", 16) + . pack('v8', 0x3E, 3, 0xFFFE, 9, 6, 0, 0, 0) + . pack('V8', 0, $fatSectors, $directorySector, 0, self::CFB_MINI_STREAM_CUTOFF, $miniFatSector, $miniFatSectors, $difatSectors === 0 ? 0xFFFFFFFE : $difatSector) + . pack('V', $difatSectors) . self::packSectors($headerDifat); + } + + private static function directoryEntry(string $name, int $type, int $color, int $left, int $right, int $child, int $start, int $size): string + { + $name = OLE::ascToUcs($name); + + return str_pad($name, 64, "\x00") . pack('vCCVVV', strlen($name) + 2, $type, $color, $left, $right, $child) + . str_repeat("\x00", 16) . pack('V', 0) . str_repeat("\x00", 16) + . pack('V2', $start, $size % 4294967296) . pack('V', intdiv($size, 4294967296)); + } + + private static function decode(mixed $value): string + { + if (!is_string($value) && !$value instanceof Stringable) { + throw new Exception('Malformed XLSX encryption information.'); + } + $result = base64_decode((string) $value, true); + if ($result === false) { + throw new Exception('Malformed XLSX encryption information.'); + } + + return $result; + } + + private static function decimalAttribute(SimpleXMLElement $element, string $name): int + { + $value = (string) $element[$name]; + $maximum = (string) PHP_INT_MAX; + if ($value === '' || !ctype_digit($value) || strlen($value) > strlen($maximum) || (strlen($value) === strlen($maximum) && $value > $maximum)) { + throw new Exception('Malformed XLSX encryption information.'); + } + + return (int) $value; + } + + private static function passwordHash(string $password, string $salt, int $spinCount, string $algorithm = 'SHA512'): string + { + $hash = self::hash($algorithm, $salt . mb_convert_encoding($password, 'UTF-16LE', 'UTF-8')); + for ($i = 0; $i < $spinCount; ++$i) { + $hash = self::hash($algorithm, pack('V', $i) . $hash); + } + + return $hash; + } + + private static function deriveKey(string $hash, string $blockKey, string $algorithm = 'SHA512', int $keyBits = 256): string + { + return substr(str_pad(self::hash($algorithm, $hash . $blockKey), intdiv($keyBits, 8), "\x36"), 0, intdiv($keyBits, 8)); + } + + private static function aesDecrypt(string $data, string $key, string $iv, int $keyBits = 256): string + { + self::assertOpenSslAvailable(); + $result = openssl_decrypt($data, 'aes-' . $keyBits . '-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv); + if ($result === false) { + throw new Exception('Malformed XLSX encrypted data.'); + } + + return $result; + } + + private static function aesEncrypt(string $data, string $key, string $iv, int $keyBits = 256): string + { + self::assertOpenSslAvailable(); + $result = openssl_encrypt($data, 'aes-' . $keyBits . '-cbc', $key, OPENSSL_RAW_DATA | OPENSSL_ZERO_PADDING, $iv); + if ($result === false) { + throw new Exception('Could not encrypt XLSX data.'); + } + + return $result; + } + + private static function isSupportedProfile(int $keyBits, string $hashAlgorithm, int $hashSize): bool + { + return in_array($keyBits, [128, 192, 256], true) + && isset(self::HASH_ALGORITHMS[$hashAlgorithm]) + && self::HASH_ALGORITHMS[$hashAlgorithm][1] === $hashSize; + } + + private static function hash(string $algorithm, string $data): string + { + return hash(self::HASH_ALGORITHMS[$algorithm][0], $data, true); + } + + /** @return positive-int */ + private static function hashSize(string $algorithm): int + { + return match ($algorithm) { + 'SHA1' => 20, + 'SHA256' => 32, + 'SHA384' => 48, + 'SHA512' => 64, + default => throw new Exception('Unsupported XLSX encryption profile.'), + }; + } + + /** @return positive-int */ + private static function keyLength(int $keyBits): int + { + return match ($keyBits) { + 128 => 16, + 192 => 24, + 256 => 32, + default => throw new Exception('Unsupported XLSX encryption profile.'), + }; + } + + private static function iv(string $salt, string $blockKey, string $algorithm): string + { + return substr(str_pad(self::hash($algorithm, $salt . $blockKey), self::BLOCK_SIZE, "\x36"), 0, self::BLOCK_SIZE); + } + + private static function paddedLength(int $length): int + { + return (int) (ceil($length / self::BLOCK_SIZE) * self::BLOCK_SIZE); + } + + private static function assertOpenSslAvailable(): void + { + if (!function_exists('openssl_encrypt') || !function_exists('openssl_decrypt')) { + throw new Exception('XLSX encryption requires the OpenSSL extension.'); + } + } + + private static function packSize(int $size): string + { + if ($size < 0) { + throw new Exception('XLSX package is too large to encrypt.'); + } + + return pack('V2', $size % 4294967296, intdiv($size, 4294967296)); + } + + private static function unpackSize(string $data): int + { + $unpackedSize = unpack('Vlow/Vhigh', $data); + if ($unpackedSize === false || !isset($unpackedSize['low'], $unpackedSize['high']) || !is_int($unpackedSize['low']) || !is_int($unpackedSize['high'])) { + throw new Exception('Malformed encrypted XLSX package.'); + } + + return self::sizeFromWords($unpackedSize['low'], $unpackedSize['high'], PHP_INT_SIZE, PHP_INT_MAX); + } + + private static function sizeFromWords(int $low, int $high, int $integerSize, int $integerMax): int + { + if ($integerSize < 8) { + if ($high !== 0 || $low > $integerMax) { + throw new Exception('Encrypted XLSX package is too large for this platform.'); + } + + return $low; + } + if ($high > 0x7FFFFFFF) { + throw new Exception('Encrypted XLSX package is too large for this platform.'); + } + + return $low + $high * 4294967296; + } + + /** @param resource $fileHandle */ + private static function write($fileHandle, string $data): void + { + if (fwrite($fileHandle, $data) !== strlen($data)) { + throw new Exception('Could not write encrypted XLSX package.'); + } + } + + /** @param resource $fileHandle */ + private static function read($fileHandle, int $length): string + { + if ($length < 1) { + throw new Exception('Malformed encrypted XLSX package.'); + } + $data = fread($fileHandle, $length); + if ($data === false || strlen($data) !== $length) { + throw new Exception('Malformed encrypted XLSX package.'); + } + + return $data; + } + + private static function encryptionInfoXml(string $keyDataSalt, string $passwordSalt, string $encryptedVerifier, string $encryptedVerifierHash, string $encryptedKey, string $encryptedHmacKey, string $encryptedHmacValue, int $keyBits = 256, string $hashAlgorithm = 'SHA512', int $hashSize = 64, int $spinCount = 100000): string + { + $base64 = static fn (string $value): string => base64_encode($value); + + return '' + . '' + . '' + . '' + . ''; + } + + private static function dataSpacesVersion(): string + { + // Fixed Version stream required by the ECMA-376 Data Spaces construct. + return hex2bin('3c0000004d006900630072006f0073006f00660074002e0043006f006e007400610069006e00650072002e004400610074006100530070006100630065007300010000000100000001000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.'); + } + + private static function primaryTransform(): string + { + // Fixed Primary transform descriptor for Microsoft.Container.EncryptionTransform. + return hex2bin('58000000010000004c0000007b00460046003900410033004600300033002d0035003600450046002d0034003600310033002d0042004400440035002d003500410034003100430031004400300037003200340036007d004e0000004d006900630072006f0073006f00660074002e0043006f006e007400610069006e00650072002e0045006e006300720079007000740069006f006e005400720061006e00730066006f0072006d00000001000000010000000100000000000000000000000000000004000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.'); + } + + private static function dataSpaceMap(): string + { + // Fixed map binding EncryptedPackage to StrongEncryptionDataSpace. + return hex2bin('08000000010000006800000001000000000000002000000045006e0063007200790070007400650064005000610063006b00610067006500320000005300740072006f006e00670045006e006300720079007000740069006f006e004400610074006100530070006100630065000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.'); + } + + private static function strongEncryptionDataSpace(): string + { + // Fixed definition binding StrongEncryptionDataSpace to its transform. + return hex2bin('0800000001000000320000005300740072006f006e00670045006e006300720079007000740069006f006e005400720061006e00730066006f0072006d000000') ?: throw new Exception('Could not generate XLSX encryption DataSpaces stream.'); + } + + /** @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info */ + private static function verifyIntegrity(array $info, string $secretKey, string $encryptedPackage): void + { + $hmacKeyIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_KEY, $info['hashAlgorithm']); + $hmacValueIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_VALUE, $info['hashAlgorithm']); + $hmacKey = substr(self::aesDecrypt($info['encryptedHmacKey'], $secretKey, $hmacKeyIv, $info['keyBits']), 0, $info['hashSize']); + $expected = self::aesDecrypt($info['encryptedHmacValue'], $secretKey, $hmacValueIv, $info['keyBits']); + if (!hash_equals(hash_hmac(self::HASH_ALGORITHMS[$info['hashAlgorithm']][0], $encryptedPackage, $hmacKey, true), substr($expected, 0, $info['hashSize']))) { + throw new Exception('Encrypted XLSX package integrity check failed.'); + } + } + + /** + * @param array{keyDataSalt: string, passwordSalt: string, encryptedVerifier: string, encryptedVerifierHash: string, encryptedKey: string, encryptedHmacKey: string, encryptedHmacValue: string, spinCount: int, keyBits: int, hashAlgorithm: string, hashSize: int} $info + * @param resource $input + */ + private static function verifyIntegrityFile(array $info, string $secretKey, $input): void + { + $hmacKeyIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_KEY, $info['hashAlgorithm']); + $hmacValueIv = self::iv($info['keyDataSalt'], self::BLOCK_KEY_HMAC_VALUE, $info['hashAlgorithm']); + $hmacKey = substr(self::aesDecrypt($info['encryptedHmacKey'], $secretKey, $hmacKeyIv, $info['keyBits']), 0, $info['hashSize']); + $expected = self::aesDecrypt($info['encryptedHmacValue'], $secretKey, $hmacValueIv, $info['keyBits']); + $hmac = hash_init(self::HASH_ALGORITHMS[$info['hashAlgorithm']][0], HASH_HMAC, $hmacKey); + while (!feof($input)) { + $data = fread($input, 8192); + if ($data === false) { + throw new Exception('Could not read encrypted XLSX package.'); + } + if ($data !== '') { + hash_update($hmac, $data); + } + } + if (!hash_equals(hash_final($hmac, true), substr($expected, 0, $info['hashSize']))) { + throw new Exception('Encrypted XLSX package integrity check failed.'); + } + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Spreadsheet.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Spreadsheet.php index 5681e4a..d708f75 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Spreadsheet.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Spreadsheet.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet; +use Composer\Pcre\Preg; use JsonSerializable; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Cell\IValueBinder; @@ -17,7 +18,7 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class Spreadsheet implements JsonSerializable { - // Allowable values for workbook window visilbity + // Allowable values for workbook window visibility const VISIBILITY_VISIBLE = 'visible'; const VISIBILITY_HIDDEN = 'hidden'; const VISIBILITY_VERY_HIDDEN = 'veryHidden'; @@ -107,14 +108,14 @@ class Spreadsheet implements JsonSerializable private ?string $macrosCertificate = null; /** - * ribbonXMLData : null if workbook is'nt Excel 2007 or not contain a customized UI. + * ribbonXMLData : null if workbook isn't Excel 2007 or not contain a customized UI. * * @var null|array{target: string, data: string} */ private ?array $ribbonXMLData = null; /** - * ribbonBinObjects : null if workbook is'nt Excel 2007 or not contain embedded objects (picture(s)) for Ribbon Elements + * ribbonBinObjects : null if workbook isn't Excel 2007 or not contain embedded objects (picture(s)) for Ribbon Elements * ignored if $ribbonXMLData is null. * * @var null|mixed[] @@ -152,7 +153,7 @@ class Spreadsheet implements JsonSerializable /** * Specifies a boolean value that indicates whether to group dates - * when presenting the user with filtering optiomd in the user + * when presenting the user with filtering options in the user * interface. */ private bool $autoFilterDateGrouping = true; @@ -405,13 +406,28 @@ class Spreadsheet implements JsonSerializable } /** - * This workbook have additionnal object for the ribbon ? + * This workbook have additional object for the ribbon ? */ public function hasRibbonBinObjects(): bool { return $this->ribbonBinObjects !== null; } + /** + * This workbook has in cell images. + */ + public function hasInCellDrawings(): bool + { + $sheetCount = $this->getSheetCount(); + for ($i = 0; $i < $sheetCount; ++$i) { + if ($this->getSheet($i)->getInCellDrawingCollection()->count() > 0) { + return true; + } + } + + return false; + } + /** * Check if a sheet with a specified code name already exists. * @@ -494,6 +510,7 @@ class Spreadsheet implements JsonSerializable unset($worksheet); } $this->workSheetCollection = []; + $this->activeSheetIndex = -1; } /** @@ -511,7 +528,7 @@ class Spreadsheet implements JsonSerializable */ public function getCalculationEngineOrNull(): ?Calculation { - if (!isset($this->calculationEngine)) { //* @phpstan-ignore-line + if (!isset($this->calculationEngine)) { //* @phpstan-ignore isset.initializedProperty (may be null at destruct time) return null; } @@ -1045,7 +1062,7 @@ class Spreadsheet implements JsonSerializable // then look for local defined name (has priority over global defined name if both names exist) if ($worksheet !== null) { $wsTitle = StringHelper::strToUpper($worksheet->getTitle()); - $definedName = (string) preg_replace('/^.*!/', '', $definedName); + $definedName = Preg::replace('/^.*!/', '', $definedName); foreach ($this->definedNames as $dn) { $sheet = $dn->getScope() ?? $dn->getWorksheet(); $upper = StringHelper::strToUpper($dn->getName()); @@ -1132,21 +1149,31 @@ class Spreadsheet implements JsonSerializable /** * Copy workbook (!= clone!). + * + * Uses serialize/unserialize which is broadly faster than clone across + * PHP versions and platforms, though clone uses less memory. + * + * @see \PhpOffice\PhpSpreadsheetBenchmarks\SpreadsheetCopyBenchmarkTest */ public function copy(): self { - return unserialize(serialize($this)); //* @phpstan-ignore-line + return unserialize(serialize($this)); //* @phpstan-ignore return.type (phpstan is wrong) } /** * Implement PHP __clone to create a deep clone, not just a shallow copy. + * + * Clone uses less memory than serialize/unserialize but speed varies + * across PHP versions and platforms. + * + * @see \PhpOffice\PhpSpreadsheetBenchmarks\SpreadsheetCopyBenchmarkTest */ public function __clone() { $this->uniqueID = uniqid('', true); $usedKeys = []; - // I don't now why new Style rather than clone. + // I don't know why new Style rather than clone. $this->cellXfSupervisor = new Style(true); //$this->cellXfSupervisor = clone $this->cellXfSupervisor; $this->cellXfSupervisor->bindParent($this); @@ -1208,6 +1235,16 @@ class Spreadsheet implements JsonSerializable $this->$key = []; foreach ($currentCollection as $item) { $clone = clone $item; + $title = $clone->getWorksheet()?->getTitle(); + if ($title !== null) { + $ws = $this->getSheetByName($title); + $clone->setWorksheet($ws); + } + $title = $clone->getScope()?->getTitle(); + if ($title !== null) { + $ws = $this->getSheetByName($title); + $clone->setScope($ws); + } $this->{$key}[] = $clone; } @@ -1559,7 +1596,7 @@ class Spreadsheet implements JsonSerializable /** * Return whether to group dates when presenting the user with - * filtering optiomd in the user interface. + * filtering options in the user interface. * * @return bool true if workbook window is minimized */ @@ -1570,7 +1607,7 @@ class Spreadsheet implements JsonSerializable /** * Set whether to group dates when presenting the user with - * filtering optiomd in the user interface. + * filtering options in the user interface. * * @param bool $autoFilterDateGrouping true if workbook window is minimized */ @@ -1838,6 +1875,37 @@ class Spreadsheet implements JsonSerializable } } + /** + * Change all 2-digit-year date styles to use 4-digit year; + * change all dd-mm-yyyy and mm-dd-yyyy styles to yyyy-mm-dd; + * dd-mmm-yyyy is unambiguous and left unchanged. + */ + public function disambiguateDateStyles(): void + { + foreach ($this->cellXfCollection as $style) { + $numberFormat = $style->getNumberFormat(); + $oldFormat = (string) $numberFormat->getFormatCode(); + $newFormat = Preg::replace('/\byy\b/i', 'yyyy', $oldFormat); + $newFormat = Preg::replace( + '~\bdd?(-|/|"-"|"/")' + . 'mm?(-|/|"-"|"/")' + . 'yyyy~', + 'yyyy-mm-dd', + $newFormat + ); + $newFormat = Preg::replace( + '~\bmm?(-|/|"-"|"/")' + . 'dd?(-|/|"-"|"/")' + . 'yyyy~', + 'yyyy-mm-dd', + $newFormat + ); + if ($newFormat !== $oldFormat) { + $numberFormat->setFormatCode($newFormat); + } + } + } + public function returnArrayAsArray(): void { $this->calculationEngine->setInstanceArrayReturnType( @@ -1851,4 +1919,46 @@ class Spreadsheet implements JsonSerializable Calculation::RETURN_ARRAY_AS_VALUE ); } + + /** @var string[] */ + private $domainWhiteList = []; + + /** + * Currently used only by WEBSERVICE function. + * + * @param string[] $domainWhiteList + */ + public function setDomainWhiteList(array $domainWhiteList): self + { + $this->domainWhiteList = $domainWhiteList; + + return $this; + } + + /** @return string[] */ + public function getDomainWhiteList(): array + { + return $this->domainWhiteList; + } + + private bool $usesCheckBoxStyle = false; + + public function getUsesCheckBoxStyle(): bool + { + return $this->usesCheckBoxStyle; + } + + public function setUsesCheckBoxStyle(): bool + { + $this->usesCheckBoxStyle = false; + foreach ($this->getCellXfCollection() as $cellXf) { + if ($cellXf->getCheckBox()) { + $this->usesCheckBoxStyle = true; + + break; + } + } + + return $this->usesCheckBoxStyle; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Alignment.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Alignment.php index 6ff44e3..b8b9527 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Alignment.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Alignment.php @@ -58,7 +58,7 @@ class Alignment extends Supervisor self::VERTICAL_CENTER => self::VERTICAL_CENTER, self::VERTICAL_JUSTIFY => self::VERTICAL_JUSTIFY, self::VERTICAL_DISTRIBUTED => self::VERTICAL_DISTRIBUTED, - // css settings that arent't in sync with Excel + // css settings that aren't in sync with Excel self::VERTICAL_BASELINE => self::VERTICAL_BOTTOM, self::VERTICAL_MIDDLE => self::VERTICAL_CENTER, self::VERTICAL_SUB => self::VERTICAL_BOTTOM, @@ -74,7 +74,7 @@ class Alignment extends Supervisor self::VERTICAL_CENTER => self::VERTICAL_MIDDLE, self::VERTICAL_JUSTIFY => self::VERTICAL_MIDDLE, self::VERTICAL_DISTRIBUTED => self::VERTICAL_MIDDLE, - // css settings that arent't in sync with Excel + // css settings that aren't in sync with Excel self::VERTICAL_BASELINE => self::VERTICAL_BASELINE, self::VERTICAL_MIDDLE => self::VERTICAL_MIDDLE, self::VERTICAL_SUB => self::VERTICAL_SUB, @@ -365,7 +365,7 @@ class Alignment extends Supervisor $this->textRotation = $angleInDegrees; } } else { - throw new PhpSpreadsheetException('Text rotation should be a value between -90 and 90.'); + throw new PhpSpreadsheetException("Text rotation $angleInDegrees should be a value between -90 and 90."); } return $this; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php index f0deb56..669bf70 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/CellMatcher.php @@ -244,7 +244,7 @@ class CellMatcher (string) $this->wrapCellValue(), self::COMPARISON_RANGE_OPERATORS[$conditional->getOperatorType()] ), - ...$conditions //* @phpstan-ignore-line + ...$conditions //* @phpstan-ignore argument.type (I don't know what is needed) ); return $this->evaluateExpression($expression); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalColorScale.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalColorScale.php index 539b331..65beebd 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalColorScale.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalColorScale.php @@ -120,7 +120,7 @@ class ConditionalColorScale public function setScaleArray(): self { if ($this->sqref !== null && $this->worksheet !== null) { - $values = $this->worksheet->rangesToArray($this->sqref, null, true, true, true); + $values = $this->worksheet->rangesToArray($this->sqref, null, true, false, true); $this->valueArray = []; foreach ($values as $key => $value) { /** @var array $value */ @@ -166,20 +166,23 @@ class ConditionalColorScale $green2 = hexdec(substr($midColor, 4, 2)); $blue1 = hexdec(substr($minColor, 6, 2)); $blue2 = hexdec(substr($midColor, 6, 2)); - - return strtoupper(dechex((int) ($alpha2 * $blend + $alpha1 * (1 - $blend))) . '' . dechex((int) ($red2 * $blend + $red1 * (1 - $blend))) . '' . dechex((int) ($green2 * $blend + $green1 * (1 - $blend))) . '' . dechex((int) ($blue2 * $blend + $blue1 * (1 - $blend)))); + } else { + $blend = ($value - $this->midValue) / ($this->maxValue - $this->midValue); + $alpha1 = hexdec(substr($midColor, 0, 2)); + $alpha2 = hexdec(substr($maxColor, 0, 2)); + $red1 = hexdec(substr($midColor, 2, 2)); + $red2 = hexdec(substr($maxColor, 2, 2)); + $green1 = hexdec(substr($midColor, 4, 2)); + $green2 = hexdec(substr($maxColor, 4, 2)); + $blue1 = hexdec(substr($midColor, 6, 2)); + $blue2 = hexdec(substr($maxColor, 6, 2)); } - $blend = ($value - $this->midValue) / ($this->maxValue - $this->midValue); - $alpha1 = hexdec(substr($midColor, 0, 2)); - $alpha2 = hexdec(substr($maxColor, 0, 2)); - $red1 = hexdec(substr($midColor, 2, 2)); - $red2 = hexdec(substr($maxColor, 2, 2)); - $green1 = hexdec(substr($midColor, 4, 2)); - $green2 = hexdec(substr($maxColor, 4, 2)); - $blue1 = hexdec(substr($midColor, 6, 2)); - $blue2 = hexdec(substr($maxColor, 6, 2)); + $alpha = (int) ($alpha2 * $blend + $alpha1 * (1 - $blend)); + $red = (int) ($red2 * $blend + $red1 * (1 - $blend)); + $green = (int) ($green2 * $blend + $green1 * (1 - $blend)); + $blue = (int) ($blue2 * $blend + $blue1 * (1 - $blend)); - return strtoupper(dechex((int) ($alpha2 * $blend + $alpha1 * (1 - $blend))) . '' . dechex((int) ($red2 * $blend + $red1 * (1 - $blend))) . '' . dechex((int) ($green2 * $blend + $green1 * (1 - $blend))) . '' . dechex((int) ($blue2 * $blend + $blue1 * (1 - $blend)))); + return sprintf('%02X%02X%02X%02X', $alpha, $red, $green, $blue); } private function getLimitValue(string $type, float $value = 0, float $formula = 0): float @@ -244,7 +247,12 @@ class ConditionalColorScale $green2 = hexdec(substr($maxColor, 4, 2)); $blue1 = hexdec(substr($minColor, 6, 2)); $blue2 = hexdec(substr($maxColor, 6, 2)); - $this->midpointColor = new Color(strtoupper(dechex((int) ($alpha2 * $blend + $alpha1 * (1 - $blend))) . '' . dechex((int) ($red2 * $blend + $red1 * (1 - $blend))) . '' . dechex((int) ($green2 * $blend + $green1 * (1 - $blend))) . '' . dechex((int) ($blue2 * $blend + $blue1 * (1 - $blend))))); + $alpha = (int) ($alpha2 * $blend + $alpha1 * (1 - $blend)); + $red = (int) ($red2 * $blend + $red1 * (1 - $blend)); + $green = (int) ($green2 * $blend + $green1 * (1 - $blend)); + $blue = (int) ($blue2 * $blend + $blue1 * (1 - $blend)); + + $this->midpointColor = new Color(sprintf('%02X%02X%02X%02X', $alpha, $red, $green, $blue)); } else { $this->midpointColor = null; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php deleted file mode 100644 index 906006b..0000000 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/ConditionalFormattingRuleExtension.php +++ /dev/null @@ -1,214 +0,0 @@ -id = '{' . $this->generateUuid() . '}'; - } else { - $this->id = $id; - } - $this->cfRule = $cfRule; - } - - private function generateUuid(): string - { - $chars = mb_str_split('xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx', 1, 'UTF-8'); - - foreach ($chars as $i => $char) { - if ($char === 'x') { - $chars[$i] = dechex(random_int(0, 15)); - } elseif ($char === 'y') { - $chars[$i] = dechex(random_int(8, 11)); - } - } - - return implode('', $chars); - } - - /** @return mixed[] */ - public static function parseExtLstXml(?SimpleXMLElement $extLstXml): array - { - $conditionalFormattingRuleExtensions = []; - $conditionalFormattingRuleExtensionXml = null; - if ($extLstXml instanceof SimpleXMLElement) { - foreach ((count($extLstXml) > 0 ? $extLstXml : [$extLstXml]) as $extLst) { - //this uri is conditionalFormattings - //https://docs.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/07d607af-5618-4ca2-b683-6a78dc0d9627 - if (isset($extLst->ext['uri']) && (string) $extLst->ext['uri'] === '{78C0D931-6437-407d-A8EE-F0AAD7539E65}') { - $conditionalFormattingRuleExtensionXml = $extLst->ext; - } - } - - if ($conditionalFormattingRuleExtensionXml) { - $ns = $conditionalFormattingRuleExtensionXml->getNamespaces(true); - $extFormattingsXml = $conditionalFormattingRuleExtensionXml->children($ns['x14']); - - foreach ($extFormattingsXml->children($ns['x14']) as $extFormattingXml) { - $extCfRuleXml = $extFormattingXml->cfRule; - $attributes = $extCfRuleXml->attributes(); - if (!$attributes || ((string) $attributes->type) !== Conditional::CONDITION_DATABAR) { - continue; - } - - $extFormattingRuleObj = new self((string) $attributes->id); - $extFormattingRuleObj->setSqref((string) $extFormattingXml->children($ns['xm'])->sqref); - $conditionalFormattingRuleExtensions[$extFormattingRuleObj->getId()] = $extFormattingRuleObj; - - $extDataBarObj = new ConditionalDataBarExtension(); - $extFormattingRuleObj->setDataBarExt($extDataBarObj); - $dataBarXml = $extCfRuleXml->dataBar; - self::parseExtDataBarAttributesFromXml($extDataBarObj, $dataBarXml); - self::parseExtDataBarElementChildrenFromXml($extDataBarObj, $dataBarXml, $ns); - } - } - } - - return $conditionalFormattingRuleExtensions; - } - - private static function parseExtDataBarAttributesFromXml( - ConditionalDataBarExtension $extDataBarObj, - SimpleXMLElement $dataBarXml - ): void { - $dataBarAttribute = $dataBarXml->attributes(); - if ($dataBarAttribute === null) { - return; - } - if ($dataBarAttribute->minLength) { - $extDataBarObj->setMinLength((int) $dataBarAttribute->minLength); - } - if ($dataBarAttribute->maxLength) { - $extDataBarObj->setMaxLength((int) $dataBarAttribute->maxLength); - } - if ($dataBarAttribute->border) { - $extDataBarObj->setBorder((bool) (string) $dataBarAttribute->border); - } - if ($dataBarAttribute->gradient) { - $extDataBarObj->setGradient((bool) (string) $dataBarAttribute->gradient); - } - if ($dataBarAttribute->direction) { - $extDataBarObj->setDirection((string) $dataBarAttribute->direction); - } - if ($dataBarAttribute->negativeBarBorderColorSameAsPositive) { - $extDataBarObj->setNegativeBarBorderColorSameAsPositive((bool) (string) $dataBarAttribute->negativeBarBorderColorSameAsPositive); - } - if ($dataBarAttribute->axisPosition) { - $extDataBarObj->setAxisPosition((string) $dataBarAttribute->axisPosition); - } - } - - /** @param string[] $ns */ - private static function parseExtDataBarElementChildrenFromXml(ConditionalDataBarExtension $extDataBarObj, SimpleXMLElement $dataBarXml, array $ns): void - { - if ($dataBarXml->borderColor) { - $attributes = $dataBarXml->borderColor->attributes(); - if ($attributes !== null) { - $extDataBarObj->setBorderColor((string) $attributes['rgb']); - } - } - if ($dataBarXml->negativeFillColor) { - $attributes = $dataBarXml->negativeFillColor->attributes(); - if ($attributes !== null) { - $extDataBarObj->setNegativeFillColor((string) $attributes['rgb']); - } - } - if ($dataBarXml->negativeBorderColor) { - $attributes = $dataBarXml->negativeBorderColor->attributes(); - if ($attributes !== null) { - $extDataBarObj->setNegativeBorderColor((string) $attributes['rgb']); - } - } - if ($dataBarXml->axisColor) { - $axisColorAttr = $dataBarXml->axisColor->attributes(); - if ($axisColorAttr !== null) { - $extDataBarObj->setAxisColor((string) $axisColorAttr['rgb'], (string) $axisColorAttr['theme'], (string) $axisColorAttr['tint']); - } - } - $cfvoIndex = 0; - foreach ($dataBarXml->cfvo as $cfvo) { - $f = (string) $cfvo->children($ns['xm'])->f; - $attributes = $cfvo->attributes(); - if (!($attributes)) { - continue; - } - - if ($cfvoIndex === 0) { - $extDataBarObj->setMinimumConditionalFormatValueObject(new ConditionalFormatValueObject((string) $attributes['type'], null, (empty($f) ? null : $f))); - } - if ($cfvoIndex === 1) { - $extDataBarObj->setMaximumConditionalFormatValueObject(new ConditionalFormatValueObject((string) $attributes['type'], null, (empty($f) ? null : $f))); - } - ++$cfvoIndex; - } - } - - public function getId(): string - { - return $this->id; - } - - public function setId(string $id): self - { - $this->id = $id; - - return $this; - } - - public function getCfRule(): string - { - return $this->cfRule; - } - - public function setCfRule(string $cfRule): self - { - $this->cfRule = $cfRule; - - return $this; - } - - public function getDataBarExt(): ConditionalDataBarExtension - { - return $this->dataBar; - } - - public function setDataBarExt(ConditionalDataBarExtension $dataBar): self - { - $this->dataBar = $dataBar; - - return $this; - } - - public function getSqref(): string - { - return $this->sqref; - } - - public function setSqref(string $sqref): self - { - $this->sqref = $sqref; - - return $this; - } -} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/MergedCellStyle.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/MergedCellStyle.php new file mode 100644 index 0000000..6664d07 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/MergedCellStyle.php @@ -0,0 +1,135 @@ +matched; + } + + /** + * Return a style that combines the base style for a cell + * with any conditional or table styles applicable to the cell. + * + * @param bool $tableFormats True/false to indicate whether + * custom table styles should be considered. + * Note that builtin table styles are not supported. + * @param bool $conditionals True/false to indicate whether + * conditional styles should be considered. + */ + public function getMergedStyle(Worksheet $worksheet, string $coordinate, bool $tableFormats = true, bool $conditionals = true, ?bool $builtInTableStyles = null): Style + { + $builtInTableStyles ??= $tableFormats; + $this->matched = false; + $styleMerger = new StyleMerger($worksheet->getStyle($coordinate)); + if ($tableFormats) { + $this->assessTables($worksheet, $coordinate, $styleMerger); + } + if ($builtInTableStyles) { + $this->assessBuiltinTables($worksheet, $coordinate, $styleMerger); + } + if ($conditionals) { + $this->assessConditionals($worksheet, $coordinate, $styleMerger); + } + + return $styleMerger->getStyle(); + } + + private function assessTables(Worksheet $worksheet, string $coordinate, StyleMerger $styleMerger): void + { + $tables = $worksheet->getTablesWithStylesForCell($worksheet->getCell($coordinate)); + foreach ($tables as $ts) { + $dxfsTableStyle = $ts->getStyle()->getTableDxfsStyle(); + if ($dxfsTableStyle !== null) { + $tableRow = $ts->getRowNumber($coordinate); + if ($tableRow === 0 && $dxfsTableStyle->getHeaderRowStyle() !== null) { + $styleMerger->mergeStyle( + $dxfsTableStyle->getHeaderRowStyle() + ); + $this->matched = true; + } elseif ($tableRow % 2 === 1 && $dxfsTableStyle->getFirstRowStripeStyle() !== null) { + $styleMerger->mergeStyle( + $dxfsTableStyle->getFirstRowStripeStyle() + ); + $this->matched = true; + } elseif ($tableRow % 2 === 0 && $dxfsTableStyle->getSecondRowStripeStyle() !== null) { + $styleMerger->mergeStyle( + $dxfsTableStyle->getSecondRowStripeStyle() + ); + $this->matched = true; + } + } + } + } + + private static ?Style $headerStyle = null; + + private static ?Style $firstRowStyle = null; + + private function assessBuiltinTables(Worksheet $worksheet, string $coordinate, StyleMerger $styleMerger): void + { + if (self::$headerStyle === null) { + self::$headerStyle = new Style(); + self::$headerStyle->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getEndColor() + ->setArgb('FF000000'); + self::$headerStyle->getFill()->getStartColor() + ->setArgb('FF000000'); + self::$headerStyle->getFont() + ->getColor()->setRgb('FFFFFF'); + } + if (self::$firstRowStyle === null) { + self::$firstRowStyle = new Style(); + self::$firstRowStyle->getFill() + ->setFillType(Fill::FILL_SOLID) + ->getEndColor() + ->setArgb('FFD9D9D9'); + self::$firstRowStyle->getFill()->getStartColor() + ->setArgb('FFD9D9D9'); + } + $tables = $worksheet->getTablesWithoutStylesForCell($worksheet->getCell($coordinate)); + foreach ($tables as $table) { + $tableRow = $table->getRowNumber($coordinate); + if ($tableRow === 0 && $table->getShowHeaderRow()) { + $styleMerger->mergeStyle(self::$headerStyle); + $this->matched = true; + } elseif ($tableRow % 2 === 1) { + $styleMerger->mergeStyle(self::$firstRowStyle); + $this->matched = true; + } + } + } + + private function assessConditionals(Worksheet $worksheet, string $coordinate, StyleMerger $styleMerger): void + { + if ($worksheet->getConditionalRange($coordinate) !== null) { + $assessor = new CellStyleAssessor($worksheet->getCell($coordinate), $worksheet->getConditionalRange($coordinate)); + } else { + $assessor = new CellStyleAssessor($worksheet->getCell($coordinate), $coordinate); + } + $matchedStyle = $assessor + ->matchConditionsReturnNullIfNoneMatched( + $worksheet->getConditionalStyles($coordinate), + $worksheet->getCell($coordinate) + ->getCalculatedValueString(), + true + ); + if ($matchedStyle !== null) { + $this->matched = true; + $styleMerger->mergeStyle($matchedStyle); + } + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php index 032bb98..8f3d884 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/CellValue.php @@ -54,7 +54,8 @@ class CellValue extends WizardAbstract implements WizardInterface protected function operator(string $operator): void { if ((!isset(self::SINGLE_OPERATORS[$operator])) && (!isset(self::RANGE_OPERATORS[$operator]))) { - throw new Exception('Invalid Operator for Cell Value CF Rule Wizard'); + // should not happen - compareKeys confirms + throw new Exception('Invalid Operator for Cell Value CF Rule Wizard 1'); // @codeCoverageIgnore } $this->operator = $operator; @@ -66,7 +67,7 @@ class CellValue extends WizardAbstract implements WizardInterface $operand = $this->validateOperand($operand, $operandValueType); } - $this->operand[$index] = $operand; //* @phpstan-ignore-line + $this->operand[$index] = $operand; //* @phpstan-ignore assign.propertyType (I don't know what is needed) $this->operandValueType[$index] = $operandValueType; } @@ -158,10 +159,6 @@ class CellValue extends WizardAbstract implements WizardInterface */ public function __call(string $methodName, array $arguments): self { - if (!isset(self::MAGIC_OPERATIONS[$methodName]) && $methodName !== 'and') { - throw new Exception('Invalid Operator for Cell Value CF Rule Wizard'); - } - if ($methodName === 'and') { if (!isset(self::RANGE_OPERATORS[$this->operator])) { throw new Exception('AND Value is only appropriate for range operators'); @@ -172,6 +169,10 @@ class CellValue extends WizardAbstract implements WizardInterface return $this; } + if (!isset(self::MAGIC_OPERATIONS[$methodName])) { + throw new Exception('Invalid Operator for Cell Value CF Rule Wizard'); + } + $this->operator(self::MAGIC_OPERATIONS[$methodName]); //$this->operand(0, ...$arguments); if (count($arguments) < 2) { @@ -184,4 +185,16 @@ class CellValue extends WizardAbstract implements WizardInterface return $this; } + + /** @internal */ + public static function compareKeys(): bool + { + $retVal = true; + $array = array_merge(array_keys(self::SINGLE_OPERATORS), array_keys(self::RANGE_OPERATORS)); + foreach ($array as $value) { + $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); + } + + return $retVal; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/Duplicates.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/Duplicates.php index 0fbeddb..84412cb 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/Duplicates.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/Duplicates.php @@ -64,7 +64,7 @@ class Duplicates extends WizardAbstract implements WizardInterface public function __call(string $methodName, array $arguments): self { if (!array_key_exists($methodName, self::OPERATORS)) { - throw new Exception('Invalid Operation for Errors CF Rule Wizard'); + throw new Exception('Invalid Operation for Duplicates CF Rule Wizard'); } $this->inverse(self::OPERATORS[$methodName]); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php index b25c4f4..c165766 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/ConditionalFormatting/Wizard/TextValue.php @@ -54,7 +54,8 @@ class TextValue extends WizardAbstract implements WizardInterface protected function operator(string $operator): void { if (!isset(self::OPERATORS[$operator])) { - throw new Exception('Invalid Operator for Text Value CF Rule Wizard'); + // should not happen - compareKeys confirms + throw new Exception('Invalid Operator for Text Value CF Rule Wizard'); // @codeCoverageIgnore } $this->operator = $operator; @@ -161,4 +162,16 @@ class TextValue extends WizardAbstract implements WizardInterface return $this; } + + /** @internal */ + public static function compareKeys(): bool + { + $retVal = true; + $array = array_keys(self::OPERATORS); + foreach ($array as $value) { + $retVal = $retVal && in_array($value, self::MAGIC_OPERATIONS, true); + } + + return $retVal; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Fill.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Fill.php index 66fe271..4c652cb 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Fill.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Fill.php @@ -124,7 +124,7 @@ class Fill extends Supervisor * ); * * - * @param array{fillType?: string, rotation?: float, startColor?: array{rgb?: string, argb?: string}, endColor?: array{rgb?: string, argb?: string}, color?: array{rgb?: string, argb?: string}} $styleArray Array containing style information + * @param array{fillType?: string, rotation?: float, startColor?: array{rgb?: string, argb?: string, theme?: int}, endColor?: array{rgb?: string, argb?: string, theme?: int}, color?: array{rgb?: string, argb?: string, theme?: int}} $styleArray Array containing style information * * @return $this */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Font.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Font.php index ae5ec6c..23ed56b 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Font.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Font.php @@ -177,7 +177,24 @@ class Font extends Supervisor * ); * * - * @param array{name?: string, latin?: string, eastAsian?: string, complexScript?: string, bold?: bool, italic?: bool, superscript?: bool, subscript?: bool, underline?: bool|string, strikethrough?: bool, color?: string[], size?: ?int, chartColor?: ChartColor, scheme?: string, cap?: string, autoColor?: bool} $styleArray Array containing style information + * @param array{ + * autoColor?: bool, + * bold?: bool, + * cap?: string, + * chartColor?: ChartColor, + * color?: string[], + * complexScript?: string, + * eastAsian?: string, + * italic?: bool, + * latin?: string, + * name?: string, + * scheme?: string, + * size?: null|float|int, + * strikethrough?: bool, + * superscript?: bool, + * subscript?: bool, + * underline?: bool|string, + * } $styleArray Array containing style information * * @return $this */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat.php index 07d0017..6e2cd8d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat.php @@ -23,18 +23,20 @@ class NumberFormat extends Supervisor const FORMAT_DATE_YYYYMMDD = 'yyyy-mm-dd'; const FORMAT_DATE_DDMMYYYY = 'dd/mm/yyyy'; - const FORMAT_DATE_DMYSLASH = 'd/m/yy'; + const FORMAT_DATE_DMYSLASH = 'd"/"m"/"yy'; const FORMAT_DATE_DMYMINUS = 'd-m-yy'; const FORMAT_DATE_DMMINUS = 'd-m'; const FORMAT_DATE_MYMINUS = 'm-yy'; const FORMAT_DATE_XLSX14 = 'mm-dd-yy'; const FORMAT_DATE_XLSX14_ACTUAL = 'm/d/yyyy'; const FORMAT_DATE_XLSX15 = 'd-mmm-yy'; + const FORMAT_DATE_XLSX15_YYYY = 'd-mmm-yyyy'; const FORMAT_DATE_XLSX16 = 'd-mmm'; const FORMAT_DATE_XLSX17 = 'mmm-yy'; const FORMAT_DATE_XLSX22 = 'm/d/yy h:mm'; const FORMAT_DATE_XLSX22_ACTUAL = 'm/d/yyyy h:mm'; const FORMAT_DATE_DATETIME = 'd/m/yy h:mm'; + const FORMAT_DATE_DATETIME_BETTER = 'yyyy-mm-dd hh:mm'; const FORMAT_DATE_TIME1 = 'h:mm AM/PM'; const FORMAT_DATE_TIME2 = 'h:mm:ss AM/PM'; const FORMAT_DATE_TIME3 = 'h:mm'; @@ -43,7 +45,8 @@ class NumberFormat extends Supervisor const FORMAT_DATE_TIME6 = 'h:mm:ss'; const FORMAT_DATE_TIME7 = 'i:s.S'; const FORMAT_DATE_TIME8 = 'h:mm:ss;@'; - const FORMAT_DATE_YYYYMMDDSLASH = 'yyyy/mm/dd;@'; + const FORMAT_DATE_TIME_INTERVAL_HMS = '[hh]:mm:ss'; + const FORMAT_DATE_YYYYMMDDSLASH = 'yyyy"/"mm"/"dd;@'; const FORMAT_DATE_LONG_DATE = 'dddd, mmmm d, yyyy'; const DATE_TIME_OR_DATETIME_ARRAY = [ @@ -61,6 +64,7 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_XLSX22, self::FORMAT_DATE_XLSX22_ACTUAL, self::FORMAT_DATE_DATETIME, + self::FORMAT_DATE_DATETIME_BETTER, self::FORMAT_DATE_TIME1, self::FORMAT_DATE_TIME2, self::FORMAT_DATE_TIME3, @@ -69,12 +73,14 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_TIME6, self::FORMAT_DATE_TIME7, self::FORMAT_DATE_TIME8, + self::FORMAT_DATE_TIME_INTERVAL_HMS, self::FORMAT_DATE_YYYYMMDDSLASH, self::FORMAT_DATE_LONG_DATE, ]; const TIME_OR_DATETIME_ARRAY = [ self::FORMAT_DATE_XLSX22, self::FORMAT_DATE_DATETIME, + self::FORMAT_DATE_DATETIME_BETTER, self::FORMAT_DATE_TIME1, self::FORMAT_DATE_TIME2, self::FORMAT_DATE_TIME3, @@ -83,10 +89,17 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_TIME6, self::FORMAT_DATE_TIME7, self::FORMAT_DATE_TIME8, + self::FORMAT_DATE_TIME_INTERVAL_HMS, ]; - const FORMAT_CURRENCY_USD_INTEGER = '$#,##0_-'; - const FORMAT_CURRENCY_USD = '$#,##0.00_-'; + private const FORMAT_CURRENCY_AMOUNT_INTEGER = '#,##0_-'; + private const FORMAT_CURRENCY_AMOUNT_FLOAT = '#,##0.00_-'; + const FORMAT_CURRENCY_USD_INTEGER = '$' . self::FORMAT_CURRENCY_AMOUNT_INTEGER; + const FORMAT_CURRENCY_USD = '$' . self::FORMAT_CURRENCY_AMOUNT_FLOAT; + const FORMAT_CURRENCY_GBP_INTEGER = '£' . self::FORMAT_CURRENCY_AMOUNT_INTEGER; + const FORMAT_CURRENCY_GBP = '£' . self::FORMAT_CURRENCY_AMOUNT_FLOAT; + const FORMAT_CURRENCY_YEN_YUAN_INTEGER = '¥' . self::FORMAT_CURRENCY_AMOUNT_INTEGER; + const FORMAT_CURRENCY_YEN_YUAN = '¥' . self::FORMAT_CURRENCY_AMOUNT_FLOAT; const FORMAT_CURRENCY_EUR_INTEGER = '#,##0_-[$€]'; const FORMAT_CURRENCY_EUR = '#,##0.00_-[$€]'; const FORMAT_ACCOUNTING_USD = '_("$"* #,##0.00_);_("$"* \(#,##0.00\);_("$"* "-"??_);_(@_)'; @@ -466,12 +479,12 @@ class NumberFormat extends Supervisor * @param null|bool|float|int|RichText|string $value Value to format * @param string $format Format code: see = self::FORMAT_* for predefined values; * or can be any valid MS Excel custom format string - * @param ?mixed[] $callBack Callback function for additional formatting of string + * @param null|callable(string, string): string $callBack Callback function for additional formatting of string * @param bool $lessFloatPrecision If true, unstyled floats will be converted to a more human-friendly but less computationally accurate value * * @return string Formatted string */ - public static function toFormattedString(mixed $value, string $format, ?array $callBack = null, bool $lessFloatPrecision = false): string + public static function toFormattedString(mixed $value, string $format, ?callable $callBack = null, bool $lessFloatPrecision = false): string { return NumberFormat\Formatter::toFormattedString($value, $format, $callBack, $lessFloatPrecision); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php index 6630888..213894d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php @@ -2,7 +2,10 @@ namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Shared\Date; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; +use Throwable; class DateFormatter { @@ -119,17 +122,18 @@ class DateFormatter /** @param float|int $value value to be formatted */ public static function format(mixed $value, string $format): string { + if ($value < 0 && Preg::isMatch('/^\[?[hms]/i', $format)) { + return '-' . self::format(-$value, $format); + } // strip off first part containing e.g. [$-F800] or [$USD-409] // general syntax: [$-] // language info is in hexadecimal // strip off chinese part like [DBNum1][$-804] - $format = (string) preg_replace('/^(\[DBNum\d\])*(\[\$[^\]]*\])/i', '', $format); + $format = Preg::replace('/^(\[DBNum\d\])*(\[\$[^\]]*\])/i', '', $format); // OpenOffice.org uses upper-case number formats, e.g. 'YYYY', convert to lower-case; // but we don't want to change any quoted strings - /** @var callable $callable */ - $callable = [self::class, 'setLowercaseCallback']; - $format = (string) preg_replace_callback('/(?:^|")([^"]*)(?:$|")/', $callable, $format); + $format = Preg::replaceCallback('/(?:^|")([^"]*)(?:$|")/', self::setLowerCaseCallback(...), $format); // Only process the non-quoted blocks for date format characters @@ -157,15 +161,17 @@ class DateFormatter $format = implode('"', $blocks); // escape any quoted characters so that DateTime format() will render them correctly - /** @var callable $callback */ - $callback = [self::class, 'escapeQuotesCallback']; - $format = (string) preg_replace_callback('/"(.*)"/U', $callback, $format); + $format = Preg::replaceCallback('/"(.*)"/U', self::escapeQuotesCallback(...), $format); - $dateObj = Date::excelToDateTimeObject($value); + try { + $dateObj = Date::excelToDateTimeObject($value); + } catch (Throwable) { + return StringHelper::convertToString($value); + } // If the colon preceding minute had been quoted, as happens in // Excel 2003 XML formats, m will not have been changed to i above. // Change it now. - $format = (string) \preg_replace('/\\\:m/', ':i', $format); + $format = Preg::replace('/\\\:m/', ':i', $format); $microseconds = (int) $dateObj->format('u'); if (str_contains($format, ':s.000')) { $milliseconds = (int) round($microseconds / 1000.0); @@ -201,15 +207,17 @@ class DateFormatter return $dateObj->format($format); } - /** @param string[] $matches */ + /** @param array $matches */ private static function setLowercaseCallback(array $matches): string { + /** @var string[] $matches */ return mb_strtolower($matches[0]); } - /** @param string[] $matches */ + /** @param array $matches */ private static function escapeQuotesCallback(array $matches): string { + /** @var string[] $matches */ return '\\' . implode('\\', mb_str_split($matches[1], 1, 'UTF-8')); } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php index 82f2028..3a71566 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Formatter.php @@ -48,7 +48,7 @@ class Formatter extends BaseFormatter * @param float|int|numeric-string $value value to be formatted * @param string[] $sections * - * @return mixed[] + * @return array{string, string, float|int|numeric-string} */ private static function splitFormatForSectionSelection(array $sections, mixed $value): array { @@ -118,12 +118,12 @@ class Formatter extends BaseFormatter * @param null|array|bool|float|int|RichText|string $value Value to format * @param string $format Format code: see = self::FORMAT_* for predefined values; * or can be any valid MS Excel custom format string - * @param null|array|callable $callBack Callback function for additional formatting of string + * @param null|callable(string, string): string $callBack Callback function for additional formatting of string * @param bool $lessFloatPrecision If true, unstyled floats will be converted to a more human-friendly but less computationally accurate value * * @return string Formatted string */ - public static function toFormattedString($value, string $format, null|array|callable $callBack = null, bool $lessFloatPrecision = false): string + public static function toFormattedString($value, string $format, ?callable $callBack = null, bool $lessFloatPrecision = false): string { while (is_array($value)) { $value = array_shift($value); @@ -136,7 +136,12 @@ class Formatter extends BaseFormatter $formatx = str_replace('\"', self::QUOTE_REPLACEMENT, $format); if (preg_match(self::SECTION_SPLIT, $format) === 0 && preg_match(self::SYMBOL_AT, $formatx) === 1) { if (!str_contains($format, '"')) { - return str_replace('@', StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision), $format); + $temp = str_replace('@', StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision), $format); + if (is_callable($callBack)) { + $temp = $callBack($temp, $format); + } + + return $temp; } //escape any dollar signs on the string, so they are not replaced with an empty value $value = str_replace( @@ -144,17 +149,27 @@ class Formatter extends BaseFormatter ['\$', self::QUOTE_REPLACEMENT], StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision) ); + $temp = preg_replace(self::SYMBOL_AT, $value, $formatx) ?? $value; + if (is_callable($callBack)) { + $temp = $callBack($temp, $formatx); + } return str_replace( ['"', self::QUOTE_REPLACEMENT], ['', '"'], - preg_replace(self::SYMBOL_AT, $value, $formatx) ?? $value + $temp ); } // If we have a text value, return it "as is" if (!is_numeric($value)) { - return StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision); + $temp = StringHelper::convertToString($value, lessFloatPrecision: $lessFloatPrecision); + if (is_callable($callBack)) { + $sections = preg_split(self::SECTION_SPLIT, $format) ?: [$format]; + $temp = $callBack($temp, $sections[3] ?? $sections[0]); + } + + return $temp; } // For 'General' format code, we just pass the value although this is not entirely the way Excel does it, @@ -181,14 +196,13 @@ class Formatter extends BaseFormatter // Convert any other escaped characters to quoted strings, e.g. (\T to "T") $format = (string) preg_replace('/(\\\(((.)(?!((AM\/PM)|(A\/P))))|([^ ])))(?=(?:[^"]|"[^"]*")*$)/ui', '"${2}"', $format); - // Get the sections, there can be up to four sections, separated with a semi-colon (but only if not a quoted literal) + // Get the sections, there can be up to four sections, separated with a semicolon (but only if not a quoted literal) $sections = preg_split(self::SECTION_SPLIT, $format) ?: []; [$colors, $format, $value] = self::splitFormatForSectionSelection($sections, $value); // In Excel formats, "_" is used to add spacing, // The following character indicates the size of the spacing, which we can't do in HTML, so we just use a standard space - /** @var string */ $temp = $format; $format = (string) preg_replace('/_.?/ui', ' ', $temp); @@ -210,11 +224,9 @@ class Formatter extends BaseFormatter $value = substr($format, 1, -1); } elseif (preg_match('/[0#, ]%/', $format)) { // % number format - avoid weird '-0' problem - /** @var float */ $temp = $value; $value = PercentageFormatter::format(0 + (float) $temp, $format); } else { - /** @var float|int|numeric-string */ $temp = $value; $value = NumberFormatter::format($temp, $format); } @@ -224,7 +236,6 @@ class Formatter extends BaseFormatter if (is_callable($callBack)) { $value = $callBack($value, $colors); } - /** @var string $value */ return str_replace(chr(0x00), '.', $value); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php index 7efcf21..22b02b1 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/NumberFormatter.php @@ -55,10 +55,8 @@ class NumberFormatter extends BaseFormatter $number = floor($numberFloat / $divisor); $mask = substr_replace($mask, $blockValue, $offset, $size); } - /** @var string $numberString */ - $numberString = $number; if ($number > 0) { - $mask = substr_replace($mask, $numberString, $offset, 0); + $mask = substr_replace($mask, "$number", $offset, 0); } $result = $mask; } @@ -148,7 +146,7 @@ class NumberFormatter extends BaseFormatter $dec = $matches[2]; $right = $matches[3]; - // minimun width of formatted number (including dot) + // minimum width of formatted number (including dot) $minWidth = strlen($left) + strlen($dec) + strlen($right); if ($useThousands) { $value = number_format( @@ -170,7 +168,7 @@ class NumberFormatter extends BaseFormatter } if (preg_match('/0([^\d\.]+)0/', $format) || substr_count($format, '.') > 1) { if ($valueFloat == floor($valueFloat) && substr_count($format, '.') === 1) { - $value *= 10 ** strlen(explode('.', $format)[1]); //* @phpstan-ignore-line + $value *= 10 ** strlen(explode('.', $format)[1]); //* @phpstan-ignore assignOp.invalid ($value may be mixed) } $result = self::complexNumberFormatMask($value, $format); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php index bb5b355..3c0575b 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/PercentageFormatter.php @@ -32,7 +32,7 @@ class PercentageFormatter extends BaseFormatter } // Number of digits to display before the decimal if (preg_match('/([#0,]+)\.?/u', $format, $matches)) { - $firstZero = preg_replace('/^[#,]*/', '', $matches[1]) ?? ''; + $firstZero = ltrim($matches[1], '#,'); $wholePartSize = max($wholePartSize, strlen($firstZero)); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Date.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Date.php index 61ac117..221c080 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Date.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Date.php @@ -80,7 +80,7 @@ class Date extends DateTimeWizard ]; /** - * @var string[] + * @var array */ protected array $separators; @@ -90,12 +90,12 @@ class Date extends DateTimeWizard protected array $formatBlocks; /** - * @param null|string|string[] $separators + * @param null|array|string $separators * If you want to use the same separator for all format blocks, then it can be passed as a string literal; * if you wish to use different separators, then they should be passed as an array. * If you want to use only a single format block, then pass a null as the separator argument */ - public function __construct($separators = self::SEPARATOR_DASH, string ...$formatBlocks) + public function __construct($separators = self::SEPARATOR_DASH, string|null ...$formatBlocks) { $separators ??= self::SEPARATOR_DASH; $formatBlocks = (count($formatBlocks) === 0) ? self::DATE_DEFAULT : $formatBlocks; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTime.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTime.php index 5f57a24..ffe35da 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTime.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTime.php @@ -5,7 +5,7 @@ namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard; class DateTime extends DateTimeWizard { /** - * @var string[] + * @var array */ protected array $separators; @@ -22,7 +22,7 @@ class DateTime extends DateTimeWizard public function __construct($separators, ...$formatBlocks) { $this->separators = $this->padSeparatorArray( - is_array($separators) ? $separators : [$separators], //* @phpstan-ignore-line + is_array($separators) ? $separators : [$separators], count($formatBlocks) - 1 ); $this->formatBlocks = array_map([$this, 'mapFormatBlocks'], $formatBlocks); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTimeWizard.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTimeWizard.php index 1fdb648..8a7ad7a 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTimeWizard.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/DateTimeWizard.php @@ -9,9 +9,9 @@ abstract class DateTimeWizard implements Stringable, Wizard protected const NO_ESCAPING_NEEDED = "$+-/():!^&'~{}<>= "; /** - * @param string[] $separators + * @param array $separators * - * @return string[] + * @return array */ protected function padSeparatorArray(array $separators, int $count): array { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Duration.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Duration.php index b81f77a..d7b1976 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Duration.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Duration.php @@ -91,7 +91,7 @@ class Duration extends DateTimeWizard ]; /** - * @var string[] + * @var array */ protected array $separators; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Time.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Time.php index 64b9104..7f54dc2 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Time.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/NumberFormat/Wizard/Time.php @@ -57,7 +57,7 @@ class Time extends DateTimeWizard ]; /** - * @var string[] + * @var array */ protected array $separators; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/RgbTint.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/RgbTint.php index 07d0d77..db51f30 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/RgbTint.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/RgbTint.php @@ -16,7 +16,7 @@ class RgbTint private const TWO_THIRD = 2.0 / 3.0; private const RGBMAX = 255.0; /** - * MS excel's tint function expects that HLS is base 240. + * MS Excel's tint function expects that HLS is base 240. * * @see https://social.msdn.microsoft.com/Forums/en-US/e9d8c136-6d62-4098-9b1b-dac786149f43/excel-color-tint-algorithm-incorrect?forum=os_binaryfile#d3c2ac95-52e0-476b-86f1-e2a697f24969 */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Style.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Style.php index feac1cb..9df6289 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Style.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Style/Style.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Style; use PhpOffice\PhpSpreadsheet\Calculation\Functions; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Chart\ChartColor; use PhpOffice\PhpSpreadsheet\Exception; @@ -52,6 +53,8 @@ class Style extends Supervisor */ protected bool $quotePrefix = false; + protected bool $checkBox = false; + /** * Internal cache for styles * Used when applying style on range of cells (column or row) and cleared when @@ -130,6 +133,13 @@ class Style extends Supervisor return $this->getActiveSheet()->getParentOrThrow(); } + private const REGEX_WHOLE_COLUMN = '/^[A-Z]+1:[A-Z]+' + . AddressRange::MAX_ROW + . '$/'; + private const REGEX_WHOLE_ROW = '/^A\d+:' + . AddressRange::MAX_COLUMN + . '\d+$/'; + /** * Build style array from subcomponents. * @@ -191,6 +201,18 @@ class Style extends Supervisor { if ($this->isSupervisor) { $pRange = $this->getSelectedCells(); + if (str_contains($pRange, ',')) { + $ranges = explode(',', $pRange); + $sheet = $this->getActiveSheet(); + foreach ($ranges as $range) { + $sheet + ->setSelectedCells(trim($range)); + $this->applyFromArray($styleArray, $advancedBorders); + } + $sheet->setSelectedCells($pRange); + + return $this; + } // Uppercase coordinate and strip any Worksheet reference from the selected range $pRange = strtoupper($pRange); @@ -356,12 +378,12 @@ class Style extends Supervisor // SIMPLE MODE: // Selection type, inspect - if (preg_match('/^[A-Z]+1:[A-Z]+1048576$/', $pRange)) { + if (preg_match(self::REGEX_WHOLE_COLUMN, $pRange)) { $selectionType = 'COLUMN'; // Enable caching of styles self::$cachedStyles = ['hashByObjId' => [], 'styleByHash' => []]; - } elseif (preg_match('/^A\d+:XFD\d+$/', $pRange)) { + } elseif (preg_match(self::REGEX_WHOLE_ROW, $pRange)) { $selectionType = 'ROW'; // Enable caching of styles @@ -484,7 +506,11 @@ class Style extends Supervisor * borders?: mixed[][], * numberFormat?: string[], * protection?: array{locked?: string, hidden?: string}, + * checkBox?: bool, * quotePrefix?: bool} $styleArray */ + if (isset($styleArray['checkBox'])) { + $this->checkBox = (bool) $styleArray['checkBox']; + } if (isset($styleArray['fill'])) { $this->getFill() ->applyFromArray($styleArray['fill']); @@ -700,6 +726,29 @@ class Style extends Supervisor return $this; } + public function getCheckBox(): bool + { + if ($this->isSupervisor) { + return $this->getSharedComponent()->getCheckBox(); + } + + return $this->checkBox; + } + + public function setCheckBox(bool $checkBox): static + { + if ($this->isSupervisor) { + $styleArray = ['checkBox' => $checkBox]; + $this->getActiveSheet() + ->getStyle($this->getSelectedCells()) + ->applyFromArray($styleArray); + } else { + $this->checkBox = $checkBox; + } + + return $this; + } + /** * Get hash code. * @@ -715,6 +764,7 @@ class Style extends Supervisor . $this->numberFormat->getHashCode() . $this->protection->getHashCode() . ($this->quotePrefix ? 't' : 'f') + . ($this->checkBox ? 't' : 'f') . __CLASS__ ); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter.php index 9074288..ad73302 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; +use Composer\Pcre\Preg; use DateTime; use DateTimeZone; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; @@ -15,6 +16,7 @@ use PhpOffice\PhpSpreadsheet\Exception; use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Worksheet\AutoFilter\Column\Rule; use Stringable; +use Throwable; class AutoFilter implements Stringable { @@ -145,7 +147,7 @@ class AutoFilter implements Stringable $this->evaluated = false; if ($this->workSheet !== null) { $thisrange = $this->range; - $range = (string) preg_replace('/\d+$/', (string) $this->workSheet->getHighestRow(), $thisrange); + $range = Preg::replace('/\d+$/', (string) $this->workSheet->getHighestRow(), $thisrange); if ($range !== $thisrange) { $this->setRange($range); } @@ -215,7 +217,7 @@ class AutoFilter implements Stringable } /** - * Get a specified AutoFilter Column by it's offset. + * Get a specified AutoFilter Column by its offset. * * @param int $columnOffset Column offset within range (starting from 0) */ @@ -238,12 +240,10 @@ class AutoFilter implements Stringable public function setColumn(AutoFilter\Column|string $columnObjectOrString): static { $this->evaluated = false; - if ((is_string($columnObjectOrString)) && (!empty($columnObjectOrString))) { + if (is_string($columnObjectOrString)) { $column = $columnObjectOrString; - } elseif ($columnObjectOrString instanceof AutoFilter\Column) { - $column = $columnObjectOrString->getColumnIndex(); } else { - throw new Exception('Column is not within the autofilter range.'); + $column = $columnObjectOrString->getColumnIndex(); } $this->testColumnInRange($column); @@ -339,7 +339,12 @@ class AutoFilter implements Stringable $timeZone = new DateTimeZone('UTC'); if (is_numeric($cellValue)) { - $dateTime = Date::excelToDateTimeObject((float) $cellValue, $timeZone); + try { + $dateTime = Date::excelToDateTimeObject((float) $cellValue, $timeZone); + } catch (Throwable) { + return false; + } + $cellValue = (float) $cellValue; if ($cellValue < 1) { // Just the time part @@ -428,32 +433,15 @@ class AutoFilter implements Stringable }; } else { // String values are always tested for equality, factoring in for wildcards (hence a regexp test) - switch ($ruleOperator) { - case Rule::AUTOFILTER_COLUMN_RULE_EQUAL: - $retVal = (bool) preg_match('/^' . $ruleValue . '$/i', $cellValueString); - - break; - case Rule::AUTOFILTER_COLUMN_RULE_NOTEQUAL: - $retVal = !((bool) preg_match('/^' . $ruleValue . '$/i', $cellValueString)); - - break; - case Rule::AUTOFILTER_COLUMN_RULE_GREATERTHAN: - $retVal = strcasecmp($cellValueString, $ruleValue) > 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_GREATERTHANOREQUAL: - $retVal = strcasecmp($cellValueString, $ruleValue) >= 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_LESSTHAN: - $retVal = strcasecmp($cellValueString, $ruleValue) < 0; - - break; - case Rule::AUTOFILTER_COLUMN_RULE_LESSTHANOREQUAL: - $retVal = strcasecmp($cellValueString, $ruleValue) <= 0; - - break; - } + $retVal = match ($ruleOperator) { + Rule::AUTOFILTER_COLUMN_RULE_EQUAL => Preg::isMatch('/^' . $ruleValue . '$/i', $cellValueString), + Rule::AUTOFILTER_COLUMN_RULE_NOTEQUAL => !(Preg::isMatch('/^' . $ruleValue . '$/i', $cellValueString)), + Rule::AUTOFILTER_COLUMN_RULE_GREATERTHAN => strcasecmp($cellValueString, $ruleValue) > 0, + Rule::AUTOFILTER_COLUMN_RULE_GREATERTHANOREQUAL => strcasecmp($cellValueString, $ruleValue) >= 0, + Rule::AUTOFILTER_COLUMN_RULE_LESSTHAN => strcasecmp($cellValueString, $ruleValue) < 0, + // Rule::AUTOFILTER_COLUMN_RULE_LESSTHANOREQUAL + default => strcasecmp($cellValueString, $ruleValue) <= 0, + }; } // If there are multiple conditions, then we need to test both using the appropriate join operator switch ($join) { @@ -489,7 +477,12 @@ class AutoFilter implements Stringable } if (is_numeric($cellValue)) { - $dateObject = Date::excelToDateTimeObject((float) $cellValue, new DateTimeZone('UTC')); + try { + $dateObject = Date::excelToDateTimeObject((float) $cellValue, new DateTimeZone('UTC')); + } catch (Throwable) { + return false; + } + $dateValue = (int) $dateObject->format('m'); if (in_array($dateValue, $monthSet)) { return true; @@ -741,14 +734,11 @@ class AutoFilter implements Stringable private function dynamicFilterDateRange(string $dynamicRuleType, AutoFilter\Column &$filterColumn): array { $ruleValues = []; - $callBack = [__CLASS__, self::DATE_FUNCTIONS[$dynamicRuleType]]; // What if not found? + $callBack = [__CLASS__, self::DATE_FUNCTIONS[$dynamicRuleType] ?? throw new Exception("invalid dynamic rule type $dynamicRuleType")]; // Calculate start/end dates for the required date range based on current date // Val is lowest permitted value. // Maxval is greater than highest permitted value - $val = $maxval = 0; - if (is_callable($callBack)) { //* @phpstan-ignore-line - [$val, $maxval] = $callBack(); - } + [$val, $maxval] = $callBack(); $val = Date::dateTimeToExcel($val); $maxval = Date::dateTimeToExcel($maxval); @@ -1037,7 +1027,10 @@ class AutoFilter implements Stringable // If the RowDimension object has not been allocated yet and the row should be visible, // then we can avoid any operation since the rows are visible by default (saves a lot of memory) if ($result === false || $this->workSheet->rowDimensionExists((int) $row)) { - $this->workSheet->getRowDimension((int) $row)->setVisible($result); + $this->workSheet + ->getRowDimension((int) $row) + ->setVisible($result) + ->setVisibleAfterFilter($result); } } $this->evaluated = true; @@ -1073,26 +1066,16 @@ class AutoFilter implements Stringable */ public function __clone() { - $vars = get_object_vars($this); - foreach ($vars as $key => $value) { - if (is_object($value)) { - if ($key === 'workSheet') { - // Detach from worksheet - $this->{$key} = null; - } else { - $this->{$key} = clone $value; - } - } elseif ((is_array($value)) && ($key == 'columns')) { - // The columns array of \PhpOffice\PhpSpreadsheet\Worksheet\Worksheet\AutoFilter objects - $this->{$key} = []; - foreach ($value as $k => $v) { - $this->{$key}[$k] = clone $v; //* @phpstan-ignore-line - // attach the new cloned Column to this new cloned Autofilter object - $this->{$key}[$k]->setParent($this); - } - } else { - $this->{$key} = $value; - } + // workSheet is only property which is an object + // detach from worksheet + $this->workSheet = null; + // columns is the only property which is an array + $columns = $this->columns; + $this->columns = []; + foreach ($columns as $k => $v) { + $this->columns[$k] = clone $v; + // attach the new cloned Column to this new cloned Autofilter object + $this->columns[$k]->setParent($this); } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter/Column/Rule.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter/Column/Rule.php index 441204c..eac6164 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter/Column/Rule.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/AutoFilter/Column/Rule.php @@ -158,7 +158,7 @@ class Rule self::AUTOFILTER_COLUMN_RULE_TOPTEN_BOTTOM, ]; - // Unimplented Rule Operators (Numeric, Boolean etc) + // Unimplemented Rule Operators (Numeric, Boolean etc) // const AUTOFILTER_COLUMN_RULE_BETWEEN = 'between'; // greaterThanOrEqual 1 && lessThanOrEqual 2 // Rule Operators (Numeric Special) which are translated to standard numeric operators with calculated values // Rule Operators (String) which are set as wild-carded values @@ -274,8 +274,7 @@ class Rule throw new PhpSpreadsheetException('Invalid rule value for column AutoFilter Rule.'); } // Set the dateTime grouping that we've anticipated - // I have no idea what Phpstan is complaining about below - $this->setGrouping(self::DATE_TIME_GROUPS[$grouping]); // @phpstan-ignore-line + $this->setGrouping(self::DATE_TIME_GROUPS[$grouping]); // @phpstan-ignore offsetAccess.notFound (no idea what phpstan is complaining about) } $this->value = $value; @@ -357,7 +356,7 @@ class Rule $this->setValue($value); // Only set grouping if it's been passed in as a user-supplied argument, // otherwise we're calculating it when we setValue() and don't want to overwrite that - // If the user supplies an argumnet for grouping, then on their own head be it + // If the user supplies an argument for grouping, then on their own head be it if ($grouping !== null) { $this->setGrouping($grouping); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/BaseDrawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/BaseDrawing.php index faa836c..a14cdf6 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/BaseDrawing.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/BaseDrawing.php @@ -137,6 +137,10 @@ class BaseDrawing implements IComparable */ protected ?int $opacity = null; + protected bool $inCell = false; + + protected int $index = 0; + /** * Create a new BaseDrawing. */ @@ -203,20 +207,30 @@ class BaseDrawing implements IComparable if (!($this instanceof Drawing && $this->getPath() === '')) { $this->worksheet->getCell($this->coordinates); } - $this->worksheet->getDrawingCollection() - ->append($this); + if ($this->inCell) { + $this->worksheet->getInCellDrawingCollection() + ->append($this); + } else { + $this->worksheet->getDrawingCollection() + ->append($this); + } } } else { if ($overrideOld) { // Remove drawing from old Worksheet - $iterator = $this->worksheet->getDrawingCollection()->getIterator(); + $collections = [ + $this->worksheet->getDrawingCollection(), + $this->worksheet->getInCellDrawingCollection(), + ]; - while ($iterator->valid()) { - if ($iterator->current()->getHashCode() === $this->getHashCode()) { - $this->worksheet->getDrawingCollection()->offsetUnset($iterator->key()); - $this->worksheet = null; + foreach ($collections as $collection) { + foreach ($collection as $key => $drawing) { + if ($drawing->getHashCode() === $this->getHashCode()) { + $collection->offsetUnset($key); + $this->worksheet = null; - break; + break 2; // break both loops + } } } @@ -572,4 +586,28 @@ class BaseDrawing implements IComparable { return $this->opacity; } + + public function setInCell(bool $inCell): self + { + $this->inCell = $inCell; + + return $this; + } + + public function isInCell(): ?bool + { + return $this->inCell; + } + + public function setIndex(int $index): self + { + $this->index = $index; + + return $this; + } + + public function getIndex(): int + { + return $this->index; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Drawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Drawing.php index 2133514..bfc38b8 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Drawing.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Drawing.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use ZipArchive; @@ -58,6 +59,9 @@ class Drawing extends BaseDrawing */ public function getExtension(): string { + if (Preg::isMatch('~^data:image/([^;]+);base64,~', $this->path, $matches)) { + return $matches[1]; + } $exploded = explode('.', basename($this->path)); return $exploded[count($exploded) - 1]; @@ -89,27 +93,44 @@ class Drawing extends BaseDrawing * @param string $path File path * @param bool $verifyFile Verify file * @param ?ZipArchive $zip Zip archive instance + * @param null|callable(string):bool $isWhitelisted * * @return $this */ - public function setPath(string $path, bool $verifyFile = true, ?ZipArchive $zip = null, bool $allowExternal = true): static + public function setPath(string $path, bool $verifyFile = true, ?ZipArchive $zip = null, bool $allowExternal = true, ?callable $isWhitelisted = null): static { $this->isUrl = false; - if (preg_match('~^data:image/[a-z]+;base64,~', $path) === 1) { + if (Preg::isMatch('~^data:image/[a-z]+;base64,~', $path)) { $this->path = $path; return $this; } $this->path = ''; + if ($zip instanceof ZipArchive) { + $zipPath = explode('#', $path)[1]; + $locate = @$zip->locateName($zipPath); + if ($locate !== false) { + if ($this->isImage($path)) { + $this->path = $path; + $this->setSizesAndType($path); + } + } // Check if a URL has been passed. https://stackoverflow.com/a/2058596/1252979 - if (filter_var($path, FILTER_VALIDATE_URL) || (preg_match('/^([\w\s\x00-\x1f]+):/u', $path) && !preg_match('/^([\w]+):/u', $path))) { - if (!preg_match('/^(http|https|file|ftp|s3):/', $path)) { + } elseif ( + filter_var($path, FILTER_VALIDATE_URL) + || Preg::isMatch('~^phar://~i', $path) + || (Preg::isMatch('/^([\w.\s\x00-\x1f]+):/', $path) && !Preg::isMatch('/^([\w.]+):/', $path)) + ) { + if (!Preg::isMatch('/^(http|https|file|ftp|s3):/', $path)) { throw new PhpSpreadsheetException('Invalid protocol for linked drawing'); } if (!$allowExternal) { return $this; } + if ($isWhitelisted !== null && !$isWhitelisted($path)) { + return $this; + } // Implicit that it is a URL, rather store info than running check above on value in other places. $this->isUrl = true; $ctx = null; @@ -118,6 +139,7 @@ class Drawing extends BaseDrawing if (str_starts_with($path, 'https:') || str_starts_with($path, 'http:')) { $ctxArray = [ 'http' => [ + 'follow_location' => 0, 'user_agent' => 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36', 'header' => [ //'Connection: keep-alive', // unacceptable performance @@ -144,15 +166,6 @@ class Drawing extends BaseDrawing } } } - } elseif ($zip instanceof ZipArchive) { - $zipPath = explode('#', $path)[1]; - $locate = @$zip->locateName($zipPath); - if ($locate !== false) { - if ($this->isImage($path)) { - $this->path = $path; - $this->setSizesAndType($path); - } - } } else { $exists = @file_exists($path); if ($exists !== false && $this->isImage($path)) { @@ -222,7 +235,7 @@ class Drawing extends BaseDrawing } /** - * Get Image file extention for Save. + * Get Image file extension for Save. */ public function getImageFileExtensionForSave(bool $includeDot = true): string { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/HeaderFooter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/HeaderFooter.php index 787b00f..e20f904 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/HeaderFooter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/HeaderFooter.php @@ -30,7 +30,7 @@ namespace PhpOffice\PhpSpreadsheet\Worksheet; * &font size - code for "text font size", where font size is a font size in points. * &K - code for "text font color" * RGB Color is specified as RRGGBB - * Theme Color is specifed as TTSNN where TT is the theme color Id, S is either "+" or "-" of the tint/shade + * Theme Color is specified as TTSNN where TT is the theme color Id, S is either "+" or "-" of the tint/shade * value, NN is the tint/shade value. * &S - code for "text strikethrough" on / off * &X - code for "text super script" on / off diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/MemoryDrawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/MemoryDrawing.php index dfef51d..f036f98 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/MemoryDrawing.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/MemoryDrawing.php @@ -176,13 +176,6 @@ class MemoryDrawing extends BaseDrawing $temporaryFileName = File::temporaryFilename(); file_put_contents($temporaryFileName, $imageString); - $mimeType = self::identifyMimeTypeUsingExif($temporaryFileName); - if ($mimeType !== null) { - unlink($temporaryFileName); - - return $mimeType; - } - $mimeType = self::identifyMimeTypeUsingGd($temporaryFileName); if ($mimeType !== null) { unlink($temporaryFileName); @@ -195,21 +188,12 @@ class MemoryDrawing extends BaseDrawing return self::MIMETYPE_DEFAULT; } - private static function identifyMimeTypeUsingExif(string $temporaryFileName): ?string - { - if (function_exists('exif_imagetype')) { - $imageType = @exif_imagetype($temporaryFileName); - $mimeType = ($imageType) ? image_type_to_mime_type($imageType) : null; - - return self::supportedMimeTypes($mimeType); - } - - return null; - } + /** @internal */ + protected static string $getImageSize = 'getImageSize'; private static function identifyMimeTypeUsingGd(string $temporaryFileName): ?string { - if (function_exists('getimagesize')) { + if (function_exists(static::$getImageSize)) { $imageSize = @getimagesize($temporaryFileName); if (is_array($imageSize)) { $mimeType = $imageSize['mime']; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PageSetup.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PageSetup.php index 4c6a539..ef64406 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PageSetup.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PageSetup.php @@ -431,10 +431,8 @@ class PageSetup */ public function isColumnsToRepeatAtLeftSet(): bool { - if (!empty($this->columnsToRepeatAtLeft)) { - if ($this->columnsToRepeatAtLeft[0] != '' && $this->columnsToRepeatAtLeft[1] != '') { - return true; - } + if ($this->columnsToRepeatAtLeft[0] != '' && $this->columnsToRepeatAtLeft[1] != '') { + return true; } return false; @@ -453,13 +451,13 @@ class PageSetup /** * Set Columns to repeat at left. * - * @param array{string, string} $columnsToRepeatAtLeft Containing start column and end column, empty array if option unset + * @param array{string, string}|array{} $columnsToRepeatAtLeft Containing start column and end column, empty array if option unset * * @return $this */ public function setColumnsToRepeatAtLeft(array $columnsToRepeatAtLeft): static { - $this->columnsToRepeatAtLeft = $columnsToRepeatAtLeft; + $this->columnsToRepeatAtLeft = empty($columnsToRepeatAtLeft) ? ['', ''] : $columnsToRepeatAtLeft; return $this; } @@ -576,7 +574,7 @@ class PageSetup * Get print area. * * @param int $index Identifier for a specific print area range if several ranges have been set - * Default behaviour, or a index value of 0, will return all ranges as a comma-separated string + * Default behaviour, or an index value of 0, will return all ranges as a comma-separated string * Otherwise, the specific range identified by the value of $index will be returned * Print areas are numbered from 1 */ @@ -642,7 +640,7 @@ class PageSetup * @param int $index Identifier for a specific print area range allowing several ranges to be set * When the method is "O"verwrite, then a positive integer index will overwrite that indexed * entry in the print areas list; a negative index value will identify which entry to - * overwrite working bacward through the print area to the list, with the last entry as -1. + * overwrite working backward through the print area to the list, with the last entry as -1. * Specifying an index value of 0, will overwrite all existing print ranges. * When the method is "I"nsert, then a positive index will insert after that indexed entry in * the print areas list, while a negative index will insert before the indexed entry. diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotCacheDefinition.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotCacheDefinition.php new file mode 100644 index 0000000..6d79654 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotCacheDefinition.php @@ -0,0 +1,160 @@ + element. + */ + private ?int $cacheId; + + /** + * Name of the worksheet the source data lives on (worksheet source only). + */ + private ?string $sourceWorksheet = null; + + /** + * Source data range on that worksheet, e.g. "A1:D50" (worksheet source only). + */ + private ?string $sourceRange = null; + + /** + * Names of the cache fields, in declaration order. The index into this + * array is the field index referenced by the pivot table. + * + * @var string[] + */ + private array $cacheFields = []; + + /** + * Distinct values ("shared items") per field name, used when generating a + * cache definition for a newly created pivot table. + * + * @var array + */ + private array $sharedItems = []; + + /** + * Grouping configuration per field name, when a field is grouped. + * + * @var array + */ + private array $fieldGroups = []; + + public function __construct(?int $cacheId = null) + { + $this->cacheId = $cacheId; + } + + public function getCacheId(): ?int + { + return $this->cacheId; + } + + public function setCacheId(?int $cacheId): self + { + $this->cacheId = $cacheId; + + return $this; + } + + public function getSourceWorksheet(): ?string + { + return $this->sourceWorksheet; + } + + public function setSourceWorksheet(?string $sourceWorksheet): self + { + $this->sourceWorksheet = $sourceWorksheet; + + return $this; + } + + public function getSourceRange(): ?string + { + return $this->sourceRange; + } + + public function setSourceRange(?string $sourceRange): self + { + $this->sourceRange = $sourceRange; + + return $this; + } + + /** + * @return string[] + */ + public function getCacheFields(): array + { + return $this->cacheFields; + } + + public function addCacheField(string $name): self + { + $this->cacheFields[] = $name; + + return $this; + } + + /** + * @param string[] $cacheFields + */ + public function setCacheFields(array $cacheFields): self + { + $this->cacheFields = array_values($cacheFields); + + return $this; + } + + /** + * Return the name of the cache field at the given zero-based index, or null. + */ + public function getCacheFieldName(int $index): ?string + { + return $this->cacheFields[$index] ?? null; + } + + /** + * Set the distinct values recorded for a field (used when generating a new + * cache definition). + * + * @param string[] $items + */ + public function setSharedItems(string $fieldName, array $items): self + { + $this->sharedItems[$fieldName] = array_values($items); + + return $this; + } + + /** + * @return string[] + */ + public function getSharedItems(string $fieldName): array + { + return $this->sharedItems[$fieldName] ?? []; + } + + public function setFieldGroup(string $fieldName, PivotFieldGroup $group): self + { + $this->fieldGroups[$fieldName] = $group; + + return $this; + } + + public function getFieldGroup(string $fieldName): ?PivotFieldGroup + { + return $this->fieldGroups[$fieldName] ?? null; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotField.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotField.php new file mode 100644 index 0000000..b6237d4 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotField.php @@ -0,0 +1,140 @@ + section (a value field). + */ + private bool $dataField = false; + + /** + * Aggregation function for a data field (e.g. sum, count, average). + * Null when this is not a data field. + */ + private ?string $subtotal = null; + + /** + * Display caption for a data field (e.g. "Sum of Amount"). Null when this + * is not a data field. + */ + private ?string $dataFieldCaption = null; + + public function __construct(int $index, string $name = '') + { + $this->index = $index; + $this->name = $name; + } + + public function getIndex(): int + { + return $this->index; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getAxis(): string + { + return $this->axis; + } + + public function setAxis(string $axis): self + { + $this->axis = $axis; + + return $this; + } + + public function isDataField(): bool + { + return $this->dataField; + } + + public function setDataField(bool $dataField): self + { + $this->dataField = $dataField; + + return $this; + } + + public function getSubtotal(): ?string + { + return $this->subtotal; + } + + public function setSubtotal(?string $subtotal): self + { + $this->subtotal = $subtotal; + + return $this; + } + + public function getDataFieldCaption(): ?string + { + return $this->dataFieldCaption; + } + + public function setDataFieldCaption(?string $dataFieldCaption): self + { + $this->dataFieldCaption = $dataFieldCaption; + + return $this; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotFieldGroup.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotFieldGroup.php new file mode 100644 index 0000000..947fb3b --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotFieldGroup.php @@ -0,0 +1,136 @@ +type = $type; + } + + /** + * Create a numeric range grouping (buckets of $interval width). + */ + public static function numeric(float $interval, ?float $startNum = null, ?float $endNum = null): self + { + $group = new self(self::TYPE_NUMERIC); + $group->interval = $interval; + $group->startNum = $startNum; + $group->endNum = $endNum; + + return $group; + } + + /** + * Create a date grouping by one or more calendar units. + * + * @param string|string[] $groupBy one or more GROUP_BY_* constants + */ + public static function date(array|string $groupBy, ?string $startDate = null, ?string $endDate = null): self + { + $group = new self(self::TYPE_DATE); + $group->groupBy = is_array($groupBy) ? array_values($groupBy) : [$groupBy]; + $group->startDate = $startDate; + $group->endDate = $endDate; + + return $group; + } + + public function getType(): string + { + return $this->type; + } + + public function isNumeric(): bool + { + return $this->type === self::TYPE_NUMERIC; + } + + public function isDate(): bool + { + return $this->type === self::TYPE_DATE; + } + + public function getInterval(): float + { + return $this->interval; + } + + public function getStartNum(): ?float + { + return $this->startNum; + } + + public function getEndNum(): ?float + { + return $this->endNum; + } + + /** + * @return string[] + */ + public function getGroupBy(): array + { + return $this->groupBy; + } + + public function getStartDate(): ?string + { + return $this->startDate; + } + + public function getEndDate(): ?string + { + return $this->endDate; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTable.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTable.php new file mode 100644 index 0000000..0230144 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTable.php @@ -0,0 +1,207 @@ +name = $name; + } + + public function isGenerated(): bool + { + return $this->generated; + } + + public function setGenerated(bool $generated): self + { + $this->generated = $generated; + + return $this; + } + + /** + * Code to execute when this pivot table is unset(). + */ + public function __destruct() + { + $this->worksheet = null; + } + + public function getName(): string + { + return $this->name; + } + + public function setName(string $name): self + { + $this->name = $name; + + return $this; + } + + public function getWorksheet(): ?Worksheet + { + return $this->worksheet; + } + + public function setWorksheet(?Worksheet $worksheet): self + { + $this->worksheet = $worksheet; + + return $this; + } + + public function getLocation(): string + { + return $this->location; + } + + public function setLocation(string $location): self + { + $this->location = $location; + + return $this; + } + + public function getCacheDefinition(): ?PivotCacheDefinition + { + return $this->cacheDefinition; + } + + public function setCacheDefinition(?PivotCacheDefinition $cacheDefinition): self + { + $this->cacheDefinition = $cacheDefinition; + + return $this; + } + + /** + * @return PivotField[] + */ + public function getFields(): array + { + return $this->fields; + } + + public function addField(PivotField $field): self + { + $this->fields[] = $field; + + return $this; + } + + /** + * Return only the fields placed on the row axis, in order. + * + * @return PivotField[] + */ + public function getRowFields(): array + { + return $this->getFieldsOnAxis(PivotField::AXIS_ROW); + } + + /** + * Return only the fields placed on the column axis, in order. + * + * @return PivotField[] + */ + public function getColumnFields(): array + { + return $this->getFieldsOnAxis(PivotField::AXIS_COLUMN); + } + + /** + * Return only the fields placed on the page/filter axis, in order. + * + * @return PivotField[] + */ + public function getPageFields(): array + { + return $this->getFieldsOnAxis(PivotField::AXIS_PAGE); + } + + /** + * Return only the value/data fields, in order. + * + * @return PivotField[] + */ + public function getDataFields(): array + { + return array_values( + array_filter( + $this->fields, + static fn (PivotField $field): bool => $field->isDataField() + ) + ); + } + + /** + * @return PivotField[] + */ + private function getFieldsOnAxis(string $axis): array + { + return array_values( + array_filter( + $this->fields, + static fn (PivotField $field): bool => $field->getAxis() === $axis + ) + ); + } + + public function __toString(): string + { + return $this->name; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTableBuilder.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTableBuilder.php new file mode 100644 index 0000000..701badf --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/PivotTable/PivotTableBuilder.php @@ -0,0 +1,280 @@ +addRowField('Region') + * ->addColumnField('Product') + * ->addDataField('Amount', PivotField::SUBTOTAL_SUM); + * $pivotTable = $builder->build($pivotSheet, 'A3'); + * ``` + */ +class PivotTableBuilder +{ + private Worksheet $sourceWorksheet; + + private string $sourceRange; + + /** + * Field names in source-column order, taken from the header row. + * + * @var string[] + */ + private array $fieldNames; + + /** + * Requested placements, keyed by field name. + * + * @var array + */ + private array $placements = []; + + /** + * Requested field groupings, keyed by field name. + * + * @var array + */ + private array $groups = []; + + /** + * @param string $sourceRange the source data range including the header row, e.g. "A1:C100" + */ + public function __construct(Worksheet $sourceWorksheet, string $sourceRange) + { + $this->sourceWorksheet = $sourceWorksheet; + $this->sourceRange = str_replace('$', '', $sourceRange); + $this->fieldNames = $this->readHeaderNames(); + } + + /** + * Place a field on the row axis. + */ + public function addRowField(string $fieldName): self + { + return $this->place($fieldName, PivotField::AXIS_ROW, null, null); + } + + /** + * Place a field on the column axis. + */ + public function addColumnField(string $fieldName): self + { + return $this->place($fieldName, PivotField::AXIS_COLUMN, null, null); + } + + /** + * Place a field on the page (report filter) axis. + */ + public function addPageField(string $fieldName): self + { + return $this->place($fieldName, PivotField::AXIS_PAGE, null, null); + } + + /** + * Group a numeric field into fixed-width buckets (e.g. 0-100, 100-200). + * + * @param float $interval bucket width + * @param ?float $startNum lower bound of the first bucket (auto when null) + * @param ?float $endNum upper bound of the last bucket (auto when null) + */ + public function groupFieldByNumericRange(string $fieldName, float $interval, ?float $startNum = null, ?float $endNum = null): self + { + $this->assertFieldExists($fieldName); + $this->groups[$fieldName] = PivotFieldGroup::numeric($interval, $startNum, $endNum); + + return $this; + } + + /** + * Group a date/time field by one or more calendar units. + * + * @param string|string[] $groupBy one or more PivotFieldGroup::GROUP_BY_* constants + * @param ?string $startDate ISO-8601 start (auto when null) + * @param ?string $endDate ISO-8601 end (auto when null) + */ + public function groupFieldByDate(string $fieldName, array|string $groupBy, ?string $startDate = null, ?string $endDate = null): self + { + $this->assertFieldExists($fieldName); + $this->groups[$fieldName] = PivotFieldGroup::date($groupBy, $startDate, $endDate); + + return $this; + } + + /** + * Add a value (data) field with the given aggregation function. + * + * @param string $subtotal one of the PivotField::SUBTOTAL_* constants + * @param ?string $caption optional display caption (defaults to e.g. "Sum of Amount") + */ + public function addDataField(string $fieldName, string $subtotal = PivotField::SUBTOTAL_SUM, ?string $caption = null): self + { + return $this->place($fieldName, PivotField::AXIS_VALUES, $subtotal, $caption); + } + + /** + * Build the PivotTable model and register it on the target worksheet. + * + * @param string $targetCell top-left cell of the pivot table, e.g. "A3" + */ + public function build(Worksheet $targetWorksheet, string $targetCell, string $name = 'PivotTable1'): PivotTable + { + $this->assertHasDataField(); + + $cacheDefinition = new PivotCacheDefinition(1); + $cacheDefinition->setSourceWorksheet($this->sourceWorksheet->getTitle()); + $cacheDefinition->setSourceRange($this->sourceRange); + $cacheDefinition->setCacheFields($this->fieldNames); + foreach ($this->fieldNames as $fieldName) { + $cacheDefinition->setSharedItems($fieldName, $this->distinctValues($fieldName)); + if (isset($this->groups[$fieldName])) { + $cacheDefinition->setFieldGroup($fieldName, $this->groups[$fieldName]); + } + } + + $pivotTable = new PivotTable($name); + $pivotTable->setGenerated(true); + $pivotTable->setCacheDefinition($cacheDefinition); + $pivotTable->setLocation($this->targetLocation($targetCell)); + + foreach ($this->fieldNames as $index => $fieldName) { + $field = new PivotField($index, $fieldName); + if (isset($this->placements[$fieldName])) { + $placement = $this->placements[$fieldName]; + if ($placement['axis'] === PivotField::AXIS_VALUES) { + $field->setDataField(true); + $field->setSubtotal($placement['subtotal']); + $field->setDataFieldCaption($placement['caption'] ?? $this->defaultCaption($placement['subtotal'], $fieldName)); + } else { + $field->setAxis($placement['axis']); + } + } + $pivotTable->addField($field); + } + + $targetWorksheet->addPivotTable($pivotTable); + + return $pivotTable; + } + + private function place(string $fieldName, string $axis, ?string $subtotal, ?string $caption): self + { + $this->assertFieldExists($fieldName); + $this->placements[$fieldName] = ['axis' => $axis, 'subtotal' => $subtotal, 'caption' => $caption]; + + return $this; + } + + private function assertFieldExists(string $fieldName): void + { + if (!in_array($fieldName, $this->fieldNames, true)) { + throw new PhpSpreadsheetException("Pivot source field '{$fieldName}' does not exist in range {$this->sourceRange}"); + } + } + + private function assertHasDataField(): void + { + foreach ($this->placements as $placement) { + if ($placement['axis'] === PivotField::AXIS_VALUES) { + return; + } + } + + throw new PhpSpreadsheetException('A pivot table requires at least one data (value) field'); + } + + /** + * @return string[] + */ + private function readHeaderNames(): array + { + [$start, $end] = Coordinate::rangeBoundaries($this->sourceRange); + $startColumn = (int) $start[0]; + $endColumn = (int) $end[0]; + $headerRow = (int) $start[1]; + $names = []; + for ($col = $startColumn; $col <= $endColumn; ++$col) { + $cell = Coordinate::stringFromColumnIndex($col) . $headerRow; + $names[] = $this->sourceWorksheet->getCell($cell)->getValueString(); + } + + return $names; + } + + /** + * Distinct string values in a field's data rows, in first-seen order. + * + * @return string[] + */ + private function distinctValues(string $fieldName): array + { + $fieldIndex = array_search($fieldName, $this->fieldNames, true); + $result = []; + if ($fieldIndex !== false) { + [$start, $end] = Coordinate::rangeBoundaries($this->sourceRange); + $column = Coordinate::stringFromColumnIndex((int) $start[0] + (int) $fieldIndex); + $firstDataRow = (int) $start[1] + 1; + $lastRow = (int) $end[1]; + + $values = []; + for ($row = $firstDataRow; $row <= $lastRow; ++$row) { + $value = $this->sourceWorksheet->getCell($column . $row)->getValueString(); + if ($value !== '') { + $values[$value] = true; + } + } + + /** @var string[] $result */ + $result = array_keys($values); + } + + return $result; + } + + /** + * Resolve the pivot table's rendered range. The exact extent is recomputed + * by the spreadsheet application on refresh; a single-cell ref is a valid + * starting point that Excel expands. + */ + private function targetLocation(string $targetCell): string + { + $targetCell = str_replace('$', '', $targetCell); + + return "$targetCell:$targetCell"; + } + + private function defaultCaption(?string $subtotal, string $fieldName): string + { + $labels = [ + PivotField::SUBTOTAL_SUM => 'Sum', + PivotField::SUBTOTAL_COUNT => 'Count', + PivotField::SUBTOTAL_AVERAGE => 'Average', + PivotField::SUBTOTAL_MAX => 'Max', + PivotField::SUBTOTAL_MIN => 'Min', + PivotField::SUBTOTAL_PRODUCT => 'Product', + PivotField::SUBTOTAL_COUNT_NUMS => 'Count', + PivotField::SUBTOTAL_STD_DEV => 'StdDev', + PivotField::SUBTOTAL_STD_DEV_P => 'StdDevp', + PivotField::SUBTOTAL_VAR => 'Var', + PivotField::SUBTOTAL_VAR_P => 'Varp', + ]; + $label = $labels[$subtotal] ?? 'Sum'; + + return "{$label} of {$fieldName}"; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowCellIterator.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowCellIterator.php index 4d4901c..59de9fa 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowCellIterator.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowCellIterator.php @@ -142,7 +142,15 @@ class RowCellIterator extends CellIterator { do { ++$this->currentColumnIndex; - } while (($this->onlyExistingCells) && (!$this->cellCollection->has(Coordinate::stringFromColumnIndex($this->currentColumnIndex) . $this->rowIndex)) && ($this->currentColumnIndex <= $this->endColumnIndex)); + } while ( + $this->onlyExistingCells + && $this->currentColumnIndex <= $this->endColumnIndex + && !$this->cellCollection->has( + Coordinate::stringFromColumnIndex( + $this->currentColumnIndex + ) . $this->rowIndex + ) + ); } /** @@ -152,7 +160,15 @@ class RowCellIterator extends CellIterator { do { --$this->currentColumnIndex; - } while (($this->onlyExistingCells) && (!$this->cellCollection->has(Coordinate::stringFromColumnIndex($this->currentColumnIndex) . $this->rowIndex)) && ($this->currentColumnIndex >= $this->startColumnIndex)); + } while ( + $this->onlyExistingCells + && $this->currentColumnIndex >= $this->startColumnIndex + && !$this->cellCollection->has( + Coordinate::stringFromColumnIndex( + $this->currentColumnIndex + ) . $this->rowIndex + ) + ); } /** @@ -177,10 +193,28 @@ class RowCellIterator extends CellIterator protected function adjustForExistingOnlyRange(): void { if ($this->onlyExistingCells) { - while ((!$this->cellCollection->has(Coordinate::stringFromColumnIndex($this->startColumnIndex) . $this->rowIndex)) && ($this->startColumnIndex <= $this->endColumnIndex)) { + while ( + $this->startColumnIndex <= $this->endColumnIndex + && ( + !$this->cellCollection->has( + Coordinate::stringFromColumnIndex( + $this->startColumnIndex + ) . $this->rowIndex + ) + ) + ) { ++$this->startColumnIndex; } - while ((!$this->cellCollection->has(Coordinate::stringFromColumnIndex($this->endColumnIndex) . $this->rowIndex)) && ($this->endColumnIndex >= $this->startColumnIndex)) { + while ( + $this->endColumnIndex >= $this->startColumnIndex + && ( + !$this->cellCollection->has( + Coordinate::stringFromColumnIndex( + $this->endColumnIndex + ) . $this->rowIndex + ) + ) + ) { --$this->endColumnIndex; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowDimension.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowDimension.php index 1242358..93acfbc 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowDimension.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/RowDimension.php @@ -22,6 +22,20 @@ class RowDimension extends Dimension private bool $customFormat = false; + private bool $visibleAfterFilter = true; + + public function setVisibleAfterFilter(bool $visibleAfterFilter): self + { + $this->visibleAfterFilter = $visibleAfterFilter; + + return $this; + } + + public function getVisibleAfterFilter(): bool + { + return $this->visibleAfterFilter; + } + /** * @param ?int $index Numeric row index */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/Sparkline.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/Sparkline.php new file mode 100644 index 0000000..a487db3 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/Sparkline.php @@ -0,0 +1,84 @@ +setLocation($location); + } + $this->setDataRange($dataRange); + } + + public function getLocation(): string + { + return $this->location; + } + + /** + * Set the cell the sparkline is drawn in. Must be a single cell, not a range. + */ + public function setLocation(CellAddress|string $location): self + { + $location = (string) $location; + if (str_contains($location, ':')) { + throw new PhpSpreadsheetException('Sparkline location must be a single cell, not a range'); + } + // Normalise: strip absolute markers and any sheet qualifier. + $location = str_replace('$', '', $location); + if (str_contains($location, '!')) { + $location = substr($location, strrpos($location, '!') + 1); + } + // Validate by attempting to resolve the coordinate. + Coordinate::indexesFromString($location); + $this->location = strtoupper($location); + + return $this; + } + + public function getDataRange(): string + { + return $this->dataRange; + } + + public function setDataRange(string $dataRange): self + { + $this->dataRange = $dataRange; + + return $this; + } + + public function __toString(): string + { + return $this->location; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineGroup.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineGroup.php new file mode 100644 index 0000000..51373f2 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineGroup.php @@ -0,0 +1,403 @@ +type; + } + + public function setType(SparklineType $type): self + { + $this->type = $type; + + return $this; + } + + /** + * @return Sparkline[] + */ + public function getSparklines(): array + { + return $this->sparklines; + } + + public function addSparkline(Sparkline $sparkline): self + { + $this->sparklines[] = $sparkline; + + return $this; + } + + /** + * Convenience helper: create and add a sparkline from a location cell and + * data range, returning this group for chaining. + */ + public function createSparkline(CellAddress|string $location, string $dataRange): self + { + return $this->addSparkline(new Sparkline($location, $dataRange)); + } + + /** + * @param Sparkline[] $sparklines + */ + public function setSparklines(array $sparklines): self + { + $this->sparklines = array_values($sparklines); + + return $this; + } + + public function getLineWeight(): float + { + return $this->lineWeight; + } + + public function setLineWeight(float $lineWeight): self + { + $this->lineWeight = $lineWeight; + + return $this; + } + + public function getDisplayMarkers(): bool + { + return $this->displayMarkers; + } + + public function setDisplayMarkers(bool $displayMarkers): self + { + $this->displayMarkers = $displayMarkers; + + return $this; + } + + public function getDisplayHigh(): bool + { + return $this->displayHigh; + } + + public function setDisplayHigh(bool $displayHigh): self + { + $this->displayHigh = $displayHigh; + + return $this; + } + + public function getDisplayLow(): bool + { + return $this->displayLow; + } + + public function setDisplayLow(bool $displayLow): self + { + $this->displayLow = $displayLow; + + return $this; + } + + public function getDisplayFirst(): bool + { + return $this->displayFirst; + } + + public function setDisplayFirst(bool $displayFirst): self + { + $this->displayFirst = $displayFirst; + + return $this; + } + + public function getDisplayLast(): bool + { + return $this->displayLast; + } + + public function setDisplayLast(bool $displayLast): self + { + $this->displayLast = $displayLast; + + return $this; + } + + public function getDisplayNegative(): bool + { + return $this->displayNegative; + } + + public function setDisplayNegative(bool $displayNegative): self + { + $this->displayNegative = $displayNegative; + + return $this; + } + + public function getDisplayXAxis(): bool + { + return $this->displayXAxis; + } + + public function setDisplayXAxis(bool $displayXAxis): self + { + $this->displayXAxis = $displayXAxis; + + return $this; + } + + public function getDisplayHidden(): bool + { + return $this->displayHidden; + } + + public function setDisplayHidden(bool $displayHidden): self + { + $this->displayHidden = $displayHidden; + + return $this; + } + + public function getRightToLeft(): bool + { + return $this->rightToLeft; + } + + public function setRightToLeft(bool $rightToLeft): self + { + $this->rightToLeft = $rightToLeft; + + return $this; + } + + public function getDisplayEmptyCellsAs(): string + { + return $this->displayEmptyCellsAs; + } + + public function setDisplayEmptyCellsAs(string $displayEmptyCellsAs): self + { + $this->displayEmptyCellsAs = $displayEmptyCellsAs; + + return $this; + } + + public function getMinAxisType(): string + { + return $this->minAxisType; + } + + public function setMinAxisType(string $minAxisType): self + { + $this->minAxisType = $minAxisType; + + return $this; + } + + public function getMaxAxisType(): string + { + return $this->maxAxisType; + } + + public function setMaxAxisType(string $maxAxisType): self + { + $this->maxAxisType = $maxAxisType; + + return $this; + } + + public function getManualMin(): ?float + { + return $this->manualMin; + } + + public function setManualMin(?float $manualMin): self + { + $this->manualMin = $manualMin; + + return $this; + } + + public function getManualMax(): ?float + { + return $this->manualMax; + } + + public function setManualMax(?float $manualMax): self + { + $this->manualMax = $manualMax; + + return $this; + } + + public function getColorSeries(): ?string + { + return $this->colorSeries; + } + + public function setColorSeries(?string $colorSeries): self + { + $this->colorSeries = $colorSeries; + + return $this; + } + + public function getColorNegative(): ?string + { + return $this->colorNegative; + } + + public function setColorNegative(?string $colorNegative): self + { + $this->colorNegative = $colorNegative; + + return $this; + } + + public function getColorAxis(): ?string + { + return $this->colorAxis; + } + + public function setColorAxis(?string $colorAxis): self + { + $this->colorAxis = $colorAxis; + + return $this; + } + + public function getColorMarkers(): ?string + { + return $this->colorMarkers; + } + + public function setColorMarkers(?string $colorMarkers): self + { + $this->colorMarkers = $colorMarkers; + + return $this; + } + + public function getColorFirst(): ?string + { + return $this->colorFirst; + } + + public function setColorFirst(?string $colorFirst): self + { + $this->colorFirst = $colorFirst; + + return $this; + } + + public function getColorLast(): ?string + { + return $this->colorLast; + } + + public function setColorLast(?string $colorLast): self + { + $this->colorLast = $colorLast; + + return $this; + } + + public function getColorHigh(): ?string + { + return $this->colorHigh; + } + + public function setColorHigh(?string $colorHigh): self + { + $this->colorHigh = $colorHigh; + + return $this; + } + + public function getColorLow(): ?string + { + return $this->colorLow; + } + + public function setColorLow(?string $colorLow): self + { + $this->colorLow = $colorLow; + + return $this; + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineType.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineType.php new file mode 100644 index 0000000..a2d70db --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Worksheet/Sparkline/SparklineType.php @@ -0,0 +1,22 @@ + + */ + private ArrayObject $inCellDrawingCollection; + /** * Collection of Chart objects. * @@ -122,6 +133,20 @@ class Worksheet */ private ArrayObject $tableCollection; + /** + * Collection of SparklineGroup objects. + * + * @var ArrayObject + */ + private ArrayObject $sparklineGroupCollection; + + /** + * Collection of PivotTable objects. + * + * @var ArrayObject + */ + private ArrayObject $pivotTableCollection; + /** * Worksheet title. */ @@ -318,7 +343,9 @@ class Worksheet { // Set parent and title $this->parent = $parent; - $this->setTitle($title, false); + // Chart collection must be set before title + $this->chartCollection = new ArrayObject(); + $this->setTitle($title, false, changeChartSheetNames: false); // setTitle can change $pTitle $this->setCodeName($this->getTitle()); $this->setSheetState(self::SHEETSTATE_VISIBLE); @@ -334,8 +361,8 @@ class Worksheet $this->sheetView = new SheetView(); // Drawing collection $this->drawingCollection = new ArrayObject(); - // Chart collection - $this->chartCollection = new ArrayObject(); + // In Cell Drawing collection + $this->inCellDrawingCollection = new ArrayObject(); // Protection $this->protection = new Protection(); // Default row dimension @@ -346,15 +373,22 @@ class Worksheet $this->autoFilter = new AutoFilter('', $this); // Table collection $this->tableCollection = new ArrayObject(); + // Sparkline group collection + $this->sparklineGroupCollection = new ArrayObject(); + + // Pivot table collection + $this->pivotTableCollection = new ArrayObject(); } /** * Disconnect all cells from this Worksheet object, * typically so that the worksheet object can be unset. + * The worksheet will be in an unusable state after + * this method has completed. */ public function disconnectCells(): void { - if (isset($this->cellCollection)) { //* @phpstan-ignore-line + if (isset($this->cellCollection)) { //* @phpstan-ignore isset.initializedProperty (may be null at destruct time) $this->cellCollection->unsetWorksheetCells(); unset($this->cellCollection); } @@ -371,7 +405,7 @@ class Worksheet ?->clearCalculationCacheForWorksheet($this->title); $this->disconnectCells(); - unset($this->rowDimensions, $this->columnDimensions, $this->tableCollection, $this->drawingCollection, $this->chartCollection, $this->autoFilter); + unset($this->rowDimensions, $this->columnDimensions, $this->tableCollection, $this->sparklineGroupCollection, $this->drawingCollection, $this->inCellDrawingCollection, $this->chartCollection, $this->autoFilter, $this->pivotTableCollection); } /** @@ -453,7 +487,7 @@ class Worksheet */ public function getCoordinates(bool $sorted = true): array { - if (!isset($this->cellCollection)) { //* @phpstan-ignore-line + if (!isset($this->cellCollection)) { //* @phpstan-ignore isset.initializedProperty (may be null at destruct time) return []; } @@ -519,6 +553,16 @@ class Worksheet return $this->drawingCollection; } + /** + * Get collection of drawings. + * + * @return ArrayObject + */ + public function getInCellDrawingCollection(): ArrayObject + { + return $this->inCellDrawingCollection; + } + /** * Get collection of charts. * @@ -550,14 +594,14 @@ class Worksheet /** * Get a chart by its index position. * - * @param ?string $index Chart index position + * @param null|int|string $index Chart index position * * @return Chart|false */ - public function getChartByIndex(?string $index) + public function getChartByIndex(null|int|string $index) { $chartCount = count($this->chartCollection); - if ($chartCount == 0) { + if ($chartCount === 0 || (is_string($index) && $index !== (string) (int) $index)) { return false; } if ($index === null) { @@ -777,7 +821,9 @@ class Worksheet $this->activePane = $holdActivePane; } if ($activeSheet !== null && $activeSheet >= 0) { - $this->getParent()?->setActiveSheetIndex($activeSheet); + // Okay, I get it now - if $activeSheet is not null, + // then $this->getParent() must also be non-null. + $this->getParent()->setActiveSheetIndex($activeSheet); } $this->setSelectedCells($selectedCells); @@ -855,7 +901,7 @@ class Worksheet * * @return $this */ - public function setTitle(string $title, bool $updateFormulaCellReferences = true, bool $validate = true): static + public function setTitle(string $title, bool $updateFormulaCellReferences = true, bool $validate = true, bool $changeChartSheetNames = true): static { // Is this a 'rename' or not? if ($this->getTitle() == $title) { @@ -908,10 +954,61 @@ class Worksheet ReferenceHelper::getInstance()->updateNamedFormulae($this->parent, $oldTitle, $newTitle); } } + if ($changeChartSheetNames) { + $this->changeChartSheetNames($oldTitle, $title); + } return $this; } + private function changeChartSheetNames(string $oldTitle, string $title): void + { + $worksheets = [$this]; + if ($this->parent !== null) { + $sheets = $this->parent->getAllSheets(); + if (in_array($this, $sheets, true)) { + $worksheets = $sheets; + } + } + $titleq = "'$title'!"; + $oldTitleq1 = preg_quote("'$oldTitle'!"); + $oldTitleq2 = preg_quote("$oldTitle!"); + $preg1 = "/$oldTitleq1|\\b$oldTitleq2/"; + foreach ($worksheets as $sheet) { + foreach ($sheet->getChartCollection() as $chart) { + foreach (($chart->getPlotArea()?->getPlotGroup() ?? []) as $plotGroup) { + foreach ($plotGroup->getPlotCategories() as $plotCategory) { + $dataSource = (string) $plotCategory->getDataSource(); + $dataSource2 = Preg::replace($preg1, $titleq, $dataSource); + if ($dataSource2 !== $dataSource) { + $plotCategory->setDataSource( + $dataSource2 + ); + } + } + foreach ($plotGroup->getPlotLabels() as $plotLabel) { + $dataSource = (string) $plotLabel->getDataSource(); + $dataSource2 = Preg::replace($preg1, $titleq, $dataSource); + if ($dataSource2 !== $dataSource) { + $plotLabel->setDataSource( + $dataSource2 + ); + } + } + foreach ($plotGroup->getPlotValues() as $plotValue) { + $dataSource = (string) $plotValue->getDataSource(); + $dataSource2 = Preg::replace($preg1, $titleq, $dataSource); + if ($dataSource2 !== $dataSource) { + $plotValue->setDataSource( + $dataSource2 + ); + } + } + } + } + } + } + /** * Get sheet state. * @@ -1214,8 +1311,7 @@ class Worksheet throw new Exception('Sheet not found for named range: ' . $namedRange->getName()); } - /** @phpstan-ignore-next-line */ - $cellCoordinate = ltrim(substr($namedRange->getValue(), strrpos($namedRange->getValue(), '!')), '!'); + $cellCoordinate = ltrim(substr($namedRange->getValue(), (int) strrpos($namedRange->getValue(), '!')), '!'); $finalCoordinate = str_replace('$', '', $cellCoordinate); } } @@ -1420,14 +1516,13 @@ class Worksheet * @param Cell $cell * The Cell for which the tables are retrieved * - * @return mixed[] + * @return Table[] */ public function getTablesWithStylesForCell(Cell $cell): array { $retVal = []; foreach ($this->tableCollection as $table) { - /** @var Table $table */ $dxfsTableStyle = $table->getStyle()->getTableDxfsStyle(); if ($dxfsTableStyle !== null) { if ($dxfsTableStyle->getHeaderRowStyle() !== null || $dxfsTableStyle->getFirstRowStripeStyle() !== null || $dxfsTableStyle->getSecondRowStripeStyle() !== null) { @@ -1442,6 +1537,31 @@ class Worksheet return $retVal; } + /** + * Get tables without styles set for the for given cell. + * + * @param Cell $cell + * The Cell for which the tables are retrieved + * + * @return Table[] + */ + public function getTablesWithoutStylesForCell(Cell $cell): array + { + $retVal = []; + + foreach ($this->tableCollection as $table) { + $range = $table->getRange(); + if ($cell->isInRange($range)) { + $dxfsTableStyle = $table->getStyle()->getTableDxfsStyle(); + if ($dxfsTableStyle === null || ($dxfsTableStyle->getHeaderRowStyle() === null && $dxfsTableStyle->getFirstRowStripeStyle() === null && $dxfsTableStyle->getSecondRowStripeStyle() === null)) { + $retVal[] = $table; + } + } + } + + return $retVal; + } + /** * Get conditional styles for a cell. * @@ -1639,7 +1759,7 @@ class Worksheet public function duplicateConditionalStyle(array $styles, string $range = ''): static { foreach ($styles as $cellStyle) { - if (!($cellStyle instanceof Conditional)) { // @phpstan-ignore-line + if (!($cellStyle instanceof Conditional)) { throw new Exception('Style is not a conditional style'); } } @@ -1833,6 +1953,7 @@ class Worksheet } if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) { + /** @var string[] $leftCellValue */ $this->getCell($upperLeft)->setValueExplicit(implode(' ', $leftCellValue), DataType::TYPE_STRING); } } @@ -1857,6 +1978,7 @@ class Worksheet } if ($behaviour === self::MERGE_CELL_CONTENT_MERGE) { + /** @var string[] $leftCellValue */ $this->getCell($upperLeft)->setValueExplicit(implode(' ', $leftCellValue), DataType::TYPE_STRING); } } @@ -2125,6 +2247,133 @@ class Worksheet return $this; } + /** + * Get collection of SparklineGroups. + * + * @return ArrayObject + */ + public function getSparklineGroupCollection(): ArrayObject + { + return $this->sparklineGroupCollection; + } + + /** + * Add a SparklineGroup. + * + * @return $this + */ + public function addSparklineGroup(SparklineGroup $sparklineGroup): self + { + $this->sparklineGroupCollection[] = $sparklineGroup; + + return $this; + } + + /** + * Add a single Sparkline, wrapping it in its own SparklineGroup. + * + * This is a convenience method for the common case of adding one sparkline + * with default formatting; the created group is returned so its formatting + * can be adjusted. + * + * @param SparklineType $type the type of sparkline (defaults to line) + */ + public function addSparkline(Sparkline $sparkline, SparklineType $type = SparklineType::Line): SparklineGroup + { + $group = new SparklineGroup(); + $group->setType($type); + $group->addSparkline($sparkline); + $this->addSparklineGroup($group); + + return $group; + } + + /** + * Remove all SparklineGroups. + * + * @return $this + */ + public function removeSparklineGroupCollection(): self + { + $this->sparklineGroupCollection = new ArrayObject(); + + return $this; + } + + /** + * Get collection of PivotTables. + * + * @return ArrayObject + */ + public function getPivotTableCollection(): ArrayObject + { + return $this->pivotTableCollection; + } + + /** + * Get collection of PivotTables (alias of getPivotTableCollection()). + * + * @return ArrayObject + */ + public function getPivotTables(): ArrayObject + { + return $this->pivotTableCollection; + } + + /** + * Add a PivotTable to this worksheet. + * + * @return $this + */ + public function addPivotTable(PivotTable $pivotTable): self + { + $pivotTable->setWorksheet($this); + $this->pivotTableCollection[] = $pivotTable; + + return $this; + } + + /** + * @return string[] array of PivotTable names + */ + public function getPivotTableNames(): array + { + $pivotTableNames = []; + + foreach ($this->pivotTableCollection as $pivotTable) { + $pivotTableNames[] = $pivotTable->getName(); + } + + return $pivotTableNames; + } + + /** + * @param string $name the pivot table name to search + * + * @return null|PivotTable The pivot table from the collection, or null if not found + */ + public function getPivotTableByName(string $name): ?PivotTable + { + $name = StringHelper::strToUpper($name); + foreach ($this->pivotTableCollection as $pivotTable) { + if (StringHelper::strToUpper($pivotTable->getName()) === $name) { + return $pivotTable; + } + } + + return null; + } + + /** + * Remove collection of PivotTables. + */ + public function removePivotTableCollection(): self + { + $this->pivotTableCollection = new ArrayObject(); + + return $this; + } + /** * Get Freeze Pane. */ @@ -2397,6 +2646,18 @@ class Worksheet if ($row < 1) { throw new Exception('Rows to be deleted should at least start from row 1.'); } + if ($numberOfRows === 0) { + return $this; + } + if ($numberOfRows < 0) { + $newRow = max(1, $row + $numberOfRows + 1); + $numberOfRows = $row - $newRow + 1; + $row = $newRow; + } + $newHighestRow = $this->cachedHighestRow; + if ($newHighestRow >= $row) { + $newHighestRow = max($row - 1, $this->cachedHighestRow - $numberOfRows); + } $startRow = $row; $endRow = $startRow + $numberOfRows - 1; $removeKeys = []; @@ -2453,6 +2714,7 @@ class Worksheet } $this->rowDimensions = $holdRowDimensions; + $this->cachedHighestRow = $newHighestRow; return $this; } @@ -2491,6 +2753,19 @@ class Worksheet throw new Exception('Column references should not be numeric.'); } $startColumnInt = Coordinate::columnIndexFromString($column); + if ($numberOfColumns === 0) { + return $this; + } + if ($numberOfColumns < 0) { + $newStartColumnInt = max(1, $startColumnInt + $numberOfColumns + 1); + $numberOfColumns = $startColumnInt - $newStartColumnInt + 1; + $startColumnInt = $newStartColumnInt; + $column = Coordinate::stringFromColumnIndex($startColumnInt); + } + $newHighestColumn = $this->cachedHighestColumn; + if ($newHighestColumn >= $startColumnInt) { + $newHighestColumn = max($startColumnInt - 1, $this->cachedHighestColumn - $numberOfColumns); + } $endColumnInt = $startColumnInt + $numberOfColumns - 1; $removeKeys = []; $addKeys = []; @@ -2541,6 +2816,8 @@ class Worksheet $this->columnDimensions = $holdColumnDimensions; if ($pColumnIndex > $highestColumnIndex) { + $this->cachedHighestColumn = $newHighestColumn; + return $this; } @@ -2550,6 +2827,7 @@ class Worksheet $this->cellCollection->removeColumn($highestColumn); $highestColumn = Coordinate::stringFromColumnIndex(Coordinate::columnIndexFromString($highestColumn) - 1); } + $this->cachedHighestColumn = $newHighestColumn; $this->garbageCollect(); @@ -2942,19 +3220,24 @@ class Worksheet * @param null|bool|float|int|RichText|string $nullValue value to use when null * @param bool $formatData Whether to format data according to cell's style. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @throws Exception * @throws \PhpOffice\PhpSpreadsheet\Calculation\Exception */ - protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $formatData, mixed $nullValue, bool $lessFloatPrecision = false): mixed + protected function cellToArray(Cell $cell, bool $calculateFormulas, bool $formatData, mixed $nullValue, bool $lessFloatPrecision = false, $oldCalculatedValue = false): mixed { $returnValue = $nullValue; if ($cell->getValue() !== null) { if ($cell->getValue() instanceof RichText) { $returnValue = $cell->getValue()->getPlainText(); + } elseif ($calculateFormulas) { + $returnValue = $cell->getCalculatedValue(); + } elseif ($oldCalculatedValue && ($cell->getDataType() === DataType::TYPE_FORMULA)) { + $returnValue = $cell->getOldCalculatedValue() ?? $cell->getValue(); } else { - $returnValue = ($calculateFormulas) ? $cell->getCalculatedValue() : $cell->getValue(); + $returnValue = $cell->getValue(); } if ($formatData) { @@ -2984,6 +3267,7 @@ class Worksheet * True - Don't return values for rows/columns that are defined as hidden. * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @return mixed[][] */ @@ -2995,12 +3279,13 @@ class Worksheet bool $returnCellRef = false, bool $ignoreHidden = false, bool $reduceArrays = false, - bool $lessFloatPrecision = false + bool $lessFloatPrecision = false, + bool $oldCalculatedValue = false, ): array { $returnValue = []; // Loop through rows - foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) { + foreach ($this->rangeToArrayYieldRows($range, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision, $oldCalculatedValue) as $rowRef => $rowArray) { /** @var int $rowRef */ $returnValue[$rowRef] = $rowArray; } @@ -3021,6 +3306,7 @@ class Worksheet * True - Don't return values for rows/columns that are defined as hidden. * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @return mixed[][] */ @@ -3033,13 +3319,14 @@ class Worksheet bool $ignoreHidden = false, bool $reduceArrays = false, bool $lessFloatPrecision = false, + bool $oldCalculatedValue = false, ): array { $returnValue = []; $parts = explode(',', $ranges); foreach ($parts as $part) { // Loop through rows - foreach ($this->rangeToArrayYieldRows($part, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision) as $rowRef => $rowArray) { + foreach ($this->rangeToArrayYieldRows($part, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision, $oldCalculatedValue) as $rowRef => $rowArray) { /** @var int $rowRef */ $returnValue[$rowRef] = $rowArray; } @@ -3061,6 +3348,7 @@ class Worksheet * True - Don't return values for rows/columns that are defined as hidden. * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @return Generator> */ @@ -3072,7 +3360,8 @@ class Worksheet bool $returnCellRef = false, bool $ignoreHidden = false, bool $reduceArrays = false, - bool $lessFloatPrecision = false + bool $lessFloatPrecision = false, + bool $oldCalculatedValue = false, ) { $range = Validations::validateCellOrCellRange($range); @@ -3138,7 +3427,7 @@ class Worksheet $columnRef = $returnCellRef ? $col : ($thisCol - $minColInt); $cell = $this->cellCollection->get("{$col}{$thisRow}"); if ($cell !== null) { - $value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue, lessFloatPrecision: $lessFloatPrecision); + $value = $this->cellToArray($cell, $calculateFormulas, $formatData, $nullValue, lessFloatPrecision: $lessFloatPrecision, oldCalculatedValue: $oldCalculatedValue); if ($reduceArrays) { while (is_array($value)) { $value = array_shift($value); @@ -3241,6 +3530,7 @@ class Worksheet * True - Don't return values for rows/columns that are defined as hidden. * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @return mixed[][] */ @@ -3252,7 +3542,8 @@ class Worksheet bool $returnCellRef = false, bool $ignoreHidden = false, bool $reduceArrays = false, - bool $lessFloatPrecision = false + bool $lessFloatPrecision = false, + bool $oldCalculatedValue = false, ): array { $retVal = []; $namedRange = $this->validateNamedRange($definedName); @@ -3261,7 +3552,7 @@ class Worksheet $cellRange = str_replace('$', '', $cellRange); $workSheet = $namedRange->getWorksheet(); if ($workSheet !== null) { - $retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision); + $retVal = $workSheet->rangeToArray($cellRange, $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision, $oldCalculatedValue); } } @@ -3280,6 +3571,7 @@ class Worksheet * True - Don't return values for rows/columns that are defined as hidden. * @param bool $reduceArrays If true and result is a formula which evaluates to an array, reduce it to the top leftmost value. * @param bool $lessFloatPrecision If true, formatting unstyled floats will convert them to a more human-friendly but less computationally accurate value + * @param bool $oldCalculatedValue If calculateFormulas is false and this is true, use oldCalculatedFormula instead. * * @return mixed[][] */ @@ -3290,7 +3582,8 @@ class Worksheet bool $returnCellRef = false, bool $ignoreHidden = false, bool $reduceArrays = false, - bool $lessFloatPrecision = false + bool $lessFloatPrecision = false, + bool $oldCalculatedValue = false, ): array { // Garbage collect... $this->garbageCollect(); @@ -3301,7 +3594,7 @@ class Worksheet $maxRow = $this->getHighestRow(); // Return - return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision); + return $this->rangeToArray("A1:{$maxCol}{$maxRow}", $nullValue, $calculateFormulas, $formatData, $returnCellRef, $ignoreHidden, $reduceArrays, $lessFloatPrecision, $oldCalculatedValue); } /** @@ -3411,7 +3704,7 @@ class Worksheet public static function unApostrophizeTitle(?string $title): string { $title ??= ''; - if ($title[0] === "'" && substr($title, -1) === "'") { + if (str_starts_with($title, "'") && str_ends_with($title, "'")) { $title = str_replace("''", "'", substr($title, 1, -1)); } @@ -3425,6 +3718,7 @@ class Worksheet */ public function getHyperlink(string $cellCoordinate): Hyperlink { + $this->getCell($cellCoordinate)->setHadHyperlink(true); // return hyperlink if we already have one if (isset($this->hyperlinkCollection[$cellCoordinate])) { return $this->hyperlinkCollection[$cellCoordinate]; @@ -3443,12 +3737,17 @@ class Worksheet * * @return $this */ - public function setHyperlink(string $cellCoordinate, ?Hyperlink $hyperlink = null): static + public function setHyperlink(string $cellCoordinate, ?Hyperlink $hyperlink = null, bool $reset = true): static { if ($hyperlink === null) { unset($this->hyperlinkCollection[$cellCoordinate]); + if ($reset) { + $this->getCell($cellCoordinate) + ->setHadHyperlink(false); + } } else { $this->hyperlinkCollection[$cellCoordinate] = $hyperlink; + $this->getCell($cellCoordinate)->setHadHyperlink(true); } return $this; @@ -3730,6 +4029,13 @@ class Worksheet $newDrawing = clone $item; $newDrawing->setWorksheet($this); } + } elseif ($key === 'inCellDrawingCollection') { + $currentCollection = $this->inCellDrawingCollection; + $this->inCellDrawingCollection = new ArrayObject(); + foreach ($currentCollection as $item) { + $newDrawing = clone $item; + $newDrawing->setWorksheet($this); + } } elseif ($key === 'tableCollection') { $currentCollection = $this->tableCollection; $this->tableCollection = new ArrayObject(); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Csv.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Csv.php index 0ec8aa4..4de9bb7 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Csv.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Csv.php @@ -95,7 +95,7 @@ class Csv extends BaseWriter $this->setUseBOM(true); // Enforce UTF-8 BOM Header $this->setIncludeSeparatorLine(true); // Set separator line $this->setEnclosure('"'); // Set enclosure to " - $this->setDelimiter(';'); // Set delimiter to a semi-colon + $this->setDelimiter(';'); // Set delimiter to a semicolon $this->setLineEnding("\r\n"); } @@ -251,9 +251,9 @@ class Csv extends BaseWriter return $this->outputEncoding; } - public function setOutputEncoding(string $outputEnconding): self + public function setOutputEncoding(string $outputEncoding): self { - $this->outputEncoding = $outputEnconding; + $this->outputEncoding = $outputEncoding; return $this; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Html.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Html.php index ecf79d8..fcb8a8d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Html.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Html.php @@ -24,8 +24,7 @@ use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Conditional; -use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\CellStyleAssessor; -use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\StyleMerger; +use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\MergedCellStyle; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Font; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; @@ -35,7 +34,6 @@ use PhpOffice\PhpSpreadsheet\Worksheet\Drawing; use PhpOffice\PhpSpreadsheet\Worksheet\MemoryDrawing; use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup; use PhpOffice\PhpSpreadsheet\Worksheet\Table; -use PhpOffice\PhpSpreadsheet\Worksheet\Table\TableDxfsStyle; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; class Html extends BaseWriter @@ -59,6 +57,8 @@ class Html extends BaseWriter */ public const COMMENT_HTML_TAGS_PLAINTEXT = true; + private const BRX = '
'; + /** * Spreadsheet object. */ @@ -67,18 +67,53 @@ class Html extends BaseWriter /** * Sheet index to write. */ - private ?int $sheetIndex = 0; + protected ?int $sheetIndex = 0; /** * Images root. */ - private string $imagesRoot = ''; + protected string $imagesRoot = ''; /** * embed images, or link to images. */ protected bool $embedImages = false; + protected string $lineEnding = PHP_EOL; + + public function getLineEnding(): string + { + return $this->lineEnding; + } + + public function setLineEnding(string $lineEnding): self + { + if ($lineEnding != "\n" && $lineEnding !== "\r\n") { + throw new Exception('Line ending must be \n (Unix) or \r\n (Windows)'); + } + $this->lineEnding = $lineEnding; + + return $this; + } + + protected bool $dataFormula = false; + + public function setDataFormula(bool $dataFormula): self + { + $this->dataFormula = $dataFormula; + + return $this; + } + + protected bool $preserveFormatAndValue = false; + + public function setPreserveFormatAndValue(bool $preserveFormatAndValue): self + { + $this->preserveFormatAndValue = $preserveFormatAndValue; + + return $this; + } + /** * Use inline CSS? */ @@ -122,13 +157,6 @@ class Html extends BaseWriter */ private array $isBaseCell = []; - /** - * Excel rows that should not be written as HTML rows. - * - * @var mixed[][] - */ - private array $isSpannedRow = []; - /** * Is the current writer creating PDF? */ @@ -168,6 +196,13 @@ class Html extends BaseWriter */ protected bool $tableFormats = false; + /** + * Table formats for unstyled tables. + * Enables default style for builtin table formats. + * If null, it takes on the same value as $tableFormats. + */ + protected ?bool $tableFormatsBuiltin = null; + /** * Conditional Formatting * Enables conditional formatting in writer, disabled here, must be enabled in writer via a setter. @@ -255,6 +290,11 @@ class Html extends BaseWriter // Write footer $html .= $this->generateHTMLFooter(); + if ($this instanceof Pdf\Mpdf) { + $html = str_replace(self::BRX, '
', $html); + } else { + $html = str_replace(self::BRX, '
' . $this->lineEnding, $html); + } $callback = $this->editHtmlCallback; if ($callback) { $html = $callback($html); @@ -378,13 +418,14 @@ class Html extends BaseWriter return $this; } - private static function generateMeta(?string $val, string $desc): string + private function generateMeta(?string $val, string $desc): string { return ($val || $val === '0') - ? (' ' . PHP_EOL) + ? (' ' . $this->lineEnding) : ''; } + /** @deprecated 5.4.0 No replacement. */ public const BODY_LINE = ' ' . PHP_EOL; private const CUSTOM_TO_META = [ @@ -404,33 +445,33 @@ class Html extends BaseWriter { // Construct HTML $properties = $this->spreadsheet->getProperties(); - $html = '' . PHP_EOL; + $html = '' . $this->lineEnding; $rtl = ($this->rtlSheets && !$this->ltrSheets) ? " dir='rtl'" : ''; - $html .= '' . PHP_EOL; - $html .= ' ' . PHP_EOL; - $html .= ' ' . PHP_EOL; - $html .= ' ' . PHP_EOL; + $html .= '' . $this->lineEnding; + $html .= ' ' . $this->lineEnding; + $html .= ' ' . $this->lineEnding; + $html .= ' ' . $this->lineEnding; $title = $properties->getTitle(); if ($title === '') { $title = $this->spreadsheet->getActiveSheet()->getTitle(); } - $html .= ' ' . htmlspecialchars($title, Settings::htmlEntityFlags()) . '' . PHP_EOL; - $html .= self::generateMeta($properties->getCreator(), 'author'); - $html .= self::generateMeta($properties->getTitle(), 'title'); - $html .= self::generateMeta($properties->getDescription(), 'description'); - $html .= self::generateMeta($properties->getSubject(), 'subject'); - $html .= self::generateMeta($properties->getKeywords(), 'keywords'); - $html .= self::generateMeta($properties->getCategory(), 'category'); - $html .= self::generateMeta($properties->getCompany(), 'company'); - $html .= self::generateMeta($properties->getManager(), 'manager'); - $html .= self::generateMeta($properties->getLastModifiedBy(), 'lastModifiedBy'); - $html .= self::generateMeta($properties->getViewport(), 'viewport'); + $html .= ' ' . htmlspecialchars($title, Settings::htmlEntityFlags()) . '' . $this->lineEnding; + $html .= $this->generateMeta($properties->getCreator(), 'author'); + $html .= $this->generateMeta($properties->getTitle(), 'title'); + $html .= $this->generateMeta($properties->getDescription(), 'description'); + $html .= $this->generateMeta($properties->getSubject(), 'subject'); + $html .= $this->generateMeta($properties->getKeywords(), 'keywords'); + $html .= $this->generateMeta($properties->getCategory(), 'category'); + $html .= $this->generateMeta($properties->getCompany(), 'company'); + $html .= $this->generateMeta($properties->getManager(), 'manager'); + $html .= $this->generateMeta($properties->getLastModifiedBy(), 'lastModifiedBy'); + $html .= $this->generateMeta($properties->getViewport(), 'viewport'); $date = Date::dateTimeFromTimestamp((string) $properties->getCreated()); $date->setTimeZone(Date::getDefaultOrLocalTimeZone()); - $html .= self::generateMeta($date->format(DATE_W3C), 'created'); + $html .= $this->generateMeta($date->format(DATE_W3C), 'created'); $date = Date::dateTimeFromTimestamp((string) $properties->getModified()); $date->setTimeZone(Date::getDefaultOrLocalTimeZone()); - $html .= self::generateMeta($date->format(DATE_W3C), 'modified'); + $html .= $this->generateMeta($date->format(DATE_W3C), 'modified'); $customProperties = $properties->getCustomProperties(); foreach ($customProperties as $customProperty) { @@ -447,19 +488,19 @@ class Html extends BaseWriter } else { $propertyValue = (string) $propertyValue; } - $html .= self::generateMeta($propertyValue, htmlspecialchars("custom.$propertyQualifier.$customProperty")); + $html .= $this->generateMeta($propertyValue, htmlspecialchars("custom.$propertyQualifier.$customProperty")); } } if (!empty($properties->getHyperlinkBase())) { - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } $html .= $includeStyles ? $this->generateStyles(true) : $this->generatePageDeclarations(true); - $html .= ' ' . PHP_EOL; - $html .= '' . PHP_EOL; - $html .= self::BODY_LINE; + $html .= ' ' . $this->lineEnding; + $html .= '' . $this->lineEnding; + $html .= ' ' . $this->lineEnding; return $html; } @@ -501,16 +542,24 @@ class Html extends BaseWriter private function generateSheetTags(int $row, int $theadStart, int $theadEnd, int $tbodyStart): array { // ? - $startTag = ($row == $theadStart) ? (' ' . PHP_EOL) : ''; + $startTag = ($row == $theadStart) ? (' ' . $this->lineEnding) : ''; if (!$startTag) { - $startTag = ($row == $tbodyStart) ? (' ' . PHP_EOL) : ''; + $startTag = ($row == $tbodyStart) ? (' ' . $this->lineEnding) : ''; } - $endTag = ($row == $theadEnd) ? (' ' . PHP_EOL) : ''; + $endTag = ($row == $theadEnd) ? (' ' . $this->lineEnding) : ''; $cellType = ($row >= $tbodyStart) ? 'td' : 'th'; return [$cellType, $startTag, $endTag]; } + private int $printAreaLowRow = -1; + + private int $printAreaHighRow = -1; + + private int $printAreaLowCol = -1; + + private int $printAreaHighCol = -1; + /** * Generate sheet data. */ @@ -529,6 +578,17 @@ class Html extends BaseWriter $activeSheet = $this->spreadsheet->getActiveSheetIndex(); foreach ($sheets as $sheet) { + $this->printAreaLowRow = -1; + $this->printAreaHighRow = -1; + $this->printAreaLowCol = -1; + $this->printAreaHighCol = -1; + $printArea = $sheet->getPageSetup()->getPrintArea(); + if (Preg::isMatch('/^([a-z]+)([0-9]+):([a-z]+)([0-9]+)$/i', $printArea, $matches)) { + $this->printAreaLowCol = Coordinate::columnIndexFromString($matches[1]); + $this->printAreaHighCol = Coordinate::columnIndexFromString($matches[3]); + $this->printAreaLowRow = (int) $matches[2]; + $this->printAreaHighRow = (int) $matches[4]; + } // save active cells $selectedCells = $sheet->getSelectedCells(); // Write table header @@ -548,6 +608,7 @@ class Html extends BaseWriter [$minCol, $minRow, $minColString] = Coordinate::indexesFromString($min); [$maxCol, $maxRow] = Coordinate::indexesFromString($max); $this->extendRowsAndColumns($sheet, $maxCol, $maxRow); + $this->extendRowsAndColumnsForMerge($sheet, $maxCol, $maxRow); [$theadStart, $theadEnd, $tbodyStart] = $this->generateSheetStarts($sheet, $minRow); // Loop through cells @@ -557,7 +618,7 @@ class Html extends BaseWriter $html .= StringHelper::convertToString($startTag); // Write row if there are HTML table cells in it - if ($this->shouldGenerateRow($sheet, $row) && !isset($this->isSpannedRow[$sheet->getParentOrThrow()->getIndex($sheet)][$row])) { + if ($this->shouldGenerateRow($sheet, $row)) { // Start a new rowData $rowData = []; // Loop through columns @@ -581,7 +642,7 @@ class Html extends BaseWriter // Write table footer $html .= $this->generateTableFooter(); // Writing PDF? - if ($this->isPdf && $this->useInlineCss) { + if ($this instanceof Pdf\Tcpdf && $this->useInlineCss) { if ($this->sheetIndex === null && $sheetId + 1 < $this->spreadsheet->getSheetCount()) { $html .= '
'; } @@ -617,14 +678,14 @@ class Html extends BaseWriter // Loop all sheets $sheetId = 0; - $html .= '' . $this->lineEnding; } return $html; @@ -697,6 +758,11 @@ class Html extends BaseWriter $opacity = "opacity:$opacityValue; "; } } + $rotationValue = $drawing->getRotation(); + if ($rotationValue !== 0) { + $rotation = "transform: rotate({$rotationValue}deg); "; + $opacity .= $rotation; + } $filedesc = $drawing->getDescription(); $filedesc = $filedesc ? htmlspecialchars($filedesc, ENT_QUOTES) : 'Embedded image'; if ($drawing instanceof Drawing && $drawing->getPath() !== '') { @@ -714,7 +780,7 @@ class Html extends BaseWriter // Convert UTF8 data to PCDATA $filename = htmlspecialchars($filename, Settings::htmlEntityFlags()); - $html .= PHP_EOL; + $html .= $this->lineEnding; $imageData = self::winFileToUrl($filename, $this instanceof Pdf\Mpdf); if ($this->embedImages || str_starts_with($imageData, 'zip://')) { @@ -745,7 +811,7 @@ class Html extends BaseWriter $dataUri = 'data:image/png;base64,' . base64_encode($contents); // Because of the nature of tables, width is more important than height. - // max-width: 100% ensures that image doesnt overflow containing cell + // max-width: 100% ensures that image doesn't overflow containing cell // However, PR #3535 broke test // 25_In_memory_image, apparently because // of the use of max-with. In addition, @@ -803,7 +869,7 @@ class Html extends BaseWriter return ''; } - $html .= PHP_EOL; + $html .= $this->lineEnding; $imageDetails = getimagesize($chartFileName) ?: ['', '', 'mime' => '']; $filedesc = $filedesc ? htmlspecialchars($filedesc, ENT_QUOTES) : 'Embedded chart'; @@ -813,7 +879,7 @@ class Html extends BaseWriter $base64 = base64_encode($picture); $imageData = 'data:' . $imageDetails['mime'] . ';base64,' . $base64; - $html .= '' . $filedesc . '' . PHP_EOL; + $html .= '' . $filedesc . '' . $this->lineEnding; } } @@ -869,21 +935,21 @@ class Html extends BaseWriter // Start styles if ($generateSurroundingHTML) { - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } // Return @@ -1266,8 +1332,8 @@ class Html extends BaseWriter { // Construct HTML $html = ''; - $html .= ' ' . PHP_EOL; - $html .= '' . PHP_EOL; + $html .= ' ' . $this->lineEnding; + $html .= '' . $this->lineEnding; return $html; } @@ -1306,18 +1372,23 @@ class Html extends BaseWriter ? $this->assembleCSS($this->cssStyles['table']) : ''; $rtl = $this->getDir($worksheet); $float = $this->getFloat($worksheet); + if (str_ends_with($float, 'right')) { + $style .= '; float:right'; + } elseif (str_ends_with($float, 'left')) { + $style .= '; float:left'; + } $prntgrid = $worksheet->getPrintGridlines(); $viewgrid = $this->isPdf ? $prntgrid : $worksheet->getShowGridlines(); + $printArea = $worksheet->getPageSetup()->getPrintArea(); + $dataPrint = ($printArea === '') ? '' : (" data-printarea='" . htmlspecialchars($printArea) . "'"); if ($viewgrid && $prntgrid) { - $html = " " . PHP_EOL; + $html = " " . $this->lineEnding; } elseif ($viewgrid) { - $html = "
" . PHP_EOL; + $html = " " . $this->lineEnding; } elseif ($prntgrid) { - $html = "
" . PHP_EOL; - } elseif ($float === '') { - $html = "
" . PHP_EOL; + $html = " " . $this->lineEnding; } else { - $html = "
" . PHP_EOL; + $html = " " . $this->lineEnding; } return $html; @@ -1327,10 +1398,17 @@ class Html extends BaseWriter { if (!$this->useInlineCss) { $rtl = $this->getDir($worksheet); + $printArea = $worksheet->getPageSetup()->getPrintArea(); + $dataPrint = ($printArea === '') ? '' : (" data-printarea='" . htmlspecialchars($printArea) . "'"); $float = $this->getFloat($worksheet); - $gridlines = $worksheet->getShowGridlines() ? ' gridlines' : ''; - $gridlinesp = $worksheet->getPrintGridlines() ? ' gridlinesp' : ''; - $html .= "
" . PHP_EOL; + if ($this instanceof Pdf\Dompdf) { + $gridlines = $worksheet->getPrintGridlines() ? ' gridlines' : ''; + $gridlinesp = $worksheet->getPrintGridlines() ? ' gridlinesp' : ''; + } else { + $gridlines = $worksheet->getShowGridlines() ? ' gridlines' : ''; + $gridlinesp = $worksheet->getPrintGridlines() ? ' gridlinesp' : ''; + } + $html .= " " . $this->lineEnding; } else { $html .= $this->generateTableTagInline($worksheet, $id); } @@ -1352,9 +1430,9 @@ class Html extends BaseWriter $clear = ($this->rtlSheets && $this->ltrSheets) ? '; clear:both' : ''; if ($showid) { - $html .= "
" . PHP_EOL; + $html .= "
" . $this->lineEnding; } else { - $html .= "
" . PHP_EOL; + $html .= "
" . $this->lineEnding; } $this->generateTableTag($worksheet, $id, $html, $sheetIndex); @@ -1364,11 +1442,11 @@ class Html extends BaseWriter $i = -1; while ($i++ < $highestColumnIndex) { if (!$this->useInlineCss) { - $html .= '
' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } else { $style = isset($this->cssStyles['table.sheet' . $sheetIndex . ' col.col' . $i]) ? $this->assembleCSS($this->cssStyles['table.sheet' . $sheetIndex . ' col.col' . $i]) : ''; - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } } @@ -1380,7 +1458,7 @@ class Html extends BaseWriter */ private function generateTableFooter(): string { - return '
' . PHP_EOL . '' . PHP_EOL; + return ' ' . $this->lineEnding . '' . $this->lineEnding; } /** @@ -1405,21 +1483,21 @@ class Html extends BaseWriter // open table again: + etc. $html .= $this->generateTableHeader($worksheet, false); - $html .= '' . PHP_EOL; + $html .= '' . $this->lineEnding; } } // Write row start if (!$this->useInlineCss) { - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } else { $style = isset($this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row]) ? $this->assembleCSS($this->cssStyles['table.sheet' . $sheetIndex . ' tr.row' . $row]) : ''; if ($style === '') { - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } else { - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; } } @@ -1478,7 +1556,7 @@ class Html extends BaseWriter } } - return nl2br($cellData); + return self::nl2brx($cellData); } private function generateRowCellDataValue(Worksheet $worksheet, Cell $cell, string &$cellData): void @@ -1489,36 +1567,54 @@ class Html extends BaseWriter if ($this->preCalculateFormulas) { try { $origData = $cell->getCalculatedValue(); - } catch (CalculationException $exception) { + } catch (CalculationException) { $origData = '#ERROR'; // mark as error, rather than crash everything } if ($this->betterBoolean && is_bool($origData)) { - $origData2 = $origData ? $this->getTrue : $this->getFalse; + if ($cell->getStyle()->getCheckbox()) { + $origData2 = $origData ? '☑' : '☐'; + } else { + $origData2 = $origData ? $this->getTrue : $this->getFalse; + } } else { - $origData2 = $cell->getCalculatedValueString(); + try { + $origData2 = $cell->getCalculatedValueString(); + } catch (CalculationException) { + $origData2 = '#ERROR'; // mark as error, rather than crash everything + } } } else { $origData = $cell->getValue(); if ($this->betterBoolean && is_bool($origData)) { - $origData2 = $origData ? $this->getTrue : $this->getFalse; + if ($cell->getStyle()->getCheckbox()) { + $origData2 = $origData ? '☑' : '☐'; + } else { + $origData2 = $origData ? $this->getTrue : $this->getFalse; + } } else { $origData2 = $cell->getValueString(); } } - $formatCode = $worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getNumberFormat()->getFormatCode(); + $style = $worksheet->getParent() + ?->getCellXfByIndexOrNull( + $cell->getXfIndex() + ); + $formatCode = $style + ?->getNumberFormat() + ->getFormatCode(); $cellData = NumberFormat::toFormattedString( $origData2, $formatCode ?? NumberFormat::FORMAT_GENERAL, - [$this, 'formatColor'] + $this->formatColor(...) ); if ($cellData === $origData) { $cellData = htmlspecialchars($cellData, Settings::htmlEntityFlags()); } - if ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSuperscript()) { + if (true === $style?->getFont()->getSuperscript()) { $cellData = '' . $cellData . ''; - } elseif ($worksheet->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex())->getFont()->getSubscript()) { + } elseif (true === $style?->getFont()->getSubscript()) { $cellData = '' . $cellData . ''; } } @@ -1541,18 +1637,23 @@ class Html extends BaseWriter $cellData = Preg::replace('/(?m)(?:^|\G) /', ' ', $cellData); // convert newline "\n" to '
' - $cellData = nl2br($cellData); + $cellData = self::nl2brx($cellData); // Extend CSS class? $dataType = $cell->getDataType(); if ($this->betterBoolean && $this->preCalculateFormulas && $dataType === DataType::TYPE_FORMULA) { - $calculatedValue = $cell->getCalculatedValue(); - if (is_bool($calculatedValue)) { - $dataType = DataType::TYPE_BOOL; - } elseif (is_numeric($calculatedValue)) { - $dataType = DataType::TYPE_NUMERIC; - } elseif (is_string($calculatedValue)) { - $dataType = DataType::TYPE_STRING; + try { + $calculatedValue = $cell->getCalculatedValue(); + if (is_bool($calculatedValue)) { + $dataType = DataType::TYPE_BOOL; + } elseif (is_numeric($calculatedValue)) { + $dataType = DataType::TYPE_NUMERIC; + } elseif (is_string($calculatedValue)) { + $dataType = DataType::TYPE_STRING; + } + } catch (CalculationException $exception) { + $calculatedValue = '#ERROR'; + $dataType = DataType::TYPE_ERROR; } } if (!$this->useInlineCss && is_string($cssClass)) { @@ -1629,16 +1730,64 @@ class Html extends BaseWriter $htmlx .= $this->generateRowIncludeCharts($worksheet, $coordinate); // Column start $html .= ' <' . $cellType; + if ($worksheet->getStyle($coordinate)->getCheckbox()) { + $html .= ' data-checkbox="1"'; + } + $dataType = $worksheet->getCell($coordinate)->getDataType(); + $numberFormat = $worksheet->getStyle($coordinate) + ->getNumberFormat() + ->getFormatCode() ?? NumberFormat::FORMAT_GENERAL; if ($this->betterBoolean) { - $dataType = $worksheet->getCell($coordinate)->getDataType(); if ($dataType === DataType::TYPE_BOOL) { $html .= ' data-type="' . DataType::TYPE_BOOL . '"'; - } elseif ($dataType === DataType::TYPE_FORMULA && $this->preCalculateFormulas && is_bool($worksheet->getCell($coordinate)->getCalculatedValue())) { - $html .= ' data-type="' . DataType::TYPE_BOOL . '"'; - } elseif (is_numeric($cellData) && $worksheet->getCell($coordinate)->getDataType() === DataType::TYPE_STRING) { + } elseif ($dataType === DataType::TYPE_FORMULA && $this->preCalculateFormulas) { + try { + $calculatedValue = $worksheet + ->getCell($coordinate) + ->getCalculatedValue(); + if (is_bool($calculatedValue)) { + $html .= ' data-type="' . DataType::TYPE_BOOL . '"'; + } elseif ($this->dataFormula && is_string($calculatedValue)) { + $html .= ' data-type="' . DataType::TYPE_STRING . '"'; + } elseif ($this->dataFormula && (is_int($calculatedValue) || is_float($calculatedValue))) { + $html .= ' data-type="' . DataType::TYPE_NUMERIC . '"'; + } + } catch (CalculationException) { + $html .= ' data-type="' . DataType::TYPE_ERROR . '"'; + } + } elseif ((is_numeric($cellData) || $this->preserveFormatAndValue) && $worksheet->getCell($coordinate)->getDataType() === DataType::TYPE_STRING) { $html .= ' data-type="' . DataType::TYPE_STRING . '"'; + } elseif ($dataType === DataType::TYPE_NUMERIC && $this->preserveFormatAndValue && $numberFormat !== NumberFormat::FORMAT_GENERAL) { + $html .= ' data-type="' . DataType::TYPE_NUMERIC . '"'; } } + if ($this->preserveFormatAndValue) { + if ($numberFormat !== NumberFormat::FORMAT_GENERAL) { + $html .= ' data-format="' . htmlspecialchars($numberFormat) . '"'; + } + } + if ($dataType === DataType::TYPE_FORMULA) { + if ($this->dataFormula) { + if ($this->preCalculateFormulas) { + $html .= ' data-formula="' + . htmlspecialchars( + $worksheet->getCell($coordinate) + ->getValueString() + ) + . '"'; + } + } + } elseif ($dataType === DataType::TYPE_NUMERIC || $dataType === DataType::TYPE_STRING) { + if ($this->preserveFormatAndValue && $numberFormat !== NumberFormat::FORMAT_GENERAL) { + $html .= ' data-value="' + . htmlspecialchars( + $worksheet->getCell($coordinate) + ->getValueString() + ) + . '"'; + } + } + $holdCss = ''; if (!$this->useInlineCss && !$this->isPdf && is_string($cssClass)) { $html .= ' class="' . $cssClass . '"'; @@ -1679,6 +1828,17 @@ class Html extends BaseWriter unset($xcssClass[$borderType]); } } + $foundBorder = false; + if ($this instanceof Pdf\Tcpdf && $worksheet->getPrintGridLines()) { + foreach (['border-top', 'border-bottom', 'border-right', 'border-left'] as $borderType) { + if (isset($xcssClass[$borderType])) { + $foundBorder = true; + } + } + if (!$foundBorder) { + $xcssClass['border'] = '0.1px solid black'; + } + } } if ($htmlx) { @@ -1701,61 +1861,27 @@ class Html extends BaseWriter $html = $this->generateRowSpans($html, $rowSpan, $colSpan); - $tables = $worksheet->getTablesWithStylesForCell($worksheet->getCell($coordinate)); - if (count($tables) > 0 || count($condStyles) > 0) { - $matched = false; // TODO the style gotten from the merger overrides everything - $styleMerger = new StyleMerger($worksheet->getCell($coordinate)->getStyle()); - if ($this->tableFormats) { - if (count($tables) > 0) { - foreach ($tables as $ts) { - /** @var Table $ts */ - $dxfsTableStyle = $ts->getStyle()->getTableDxfsStyle(); - if ($dxfsTableStyle !== null) { - /** @var int */ - $tableRow = $ts->getRowNumber($coordinate); - /** @var TableDxfsStyle $dxfsTableStyle */ - if ($tableRow === 0 && $dxfsTableStyle->getHeaderRowStyle() !== null) { - $styleMerger->mergeStyle($dxfsTableStyle->getHeaderRowStyle()); - $matched = true; - } elseif ($tableRow % 2 === 1 && $dxfsTableStyle->getFirstRowStripeStyle() !== null) { - $styleMerger->mergeStyle($dxfsTableStyle->getFirstRowStripeStyle()); - $matched = true; - } elseif ($tableRow % 2 === 0 && $dxfsTableStyle->getSecondRowStripeStyle() !== null) { - $styleMerger->mergeStyle($dxfsTableStyle->getSecondRowStripeStyle()); - $matched = true; - } - } - } + $mergedCellStyle = new MergedCellStyle(); + $mergedStyle = $mergedCellStyle->getMergedStyle( + $worksheet, + $coordinate, + $this->tableFormats, + $this->conditionalFormatting, + $this->tableFormatsBuiltin + ); + if ($mergedCellStyle->getMatched()) { + $styles = $this->createCSSStyle($mergedStyle, true); + $html .= ' style="'; + if ($holdCss !== '') { + $html .= "$holdCss; "; + $holdCss = ''; + } + foreach ($styles as $key => $value) { + if (!str_starts_with($key, 'border-') || $value !== 'none #000000') { + $html .= $key . ':' . $value . ';'; } } - if (count($condStyles) > 0 && $this->conditionalFormatting) { - if ($worksheet->getConditionalRange($coordinate) !== null) { - $assessor = new CellStyleAssessor($worksheet->getCell($coordinate), $worksheet->getConditionalRange($coordinate)); - } else { - $assessor = new CellStyleAssessor($worksheet->getCell($coordinate), $coordinate); - } - $matchedStyle = $assessor->matchConditionsReturnNullIfNoneMatched($condStyles, $cellData, true); - - if ($matchedStyle !== null) { - $matched = true; - // this is really slow - $styleMerger->mergeStyle($matchedStyle); - } - } - if ($matched) { - $styles = $this->createCSSStyle($styleMerger->getStyle(), true); - $html .= ' style="'; - if ($holdCss !== '') { - $html .= "$holdCss; "; - $holdCss = ''; - } - foreach ($styles as $key => $value) { - if (!str_starts_with($key, 'border-') || $value !== 'none #000000') { - $html .= $key . ':' . $value . ';'; - } - } - $html .= '"'; - } + $html .= '"'; } if ($holdCss !== '') { $html .= ' style="' . $holdCss . '"'; @@ -1770,7 +1896,7 @@ class Html extends BaseWriter $html .= $cellData; // Column end - $html .= '' . PHP_EOL; + $html .= '' . $this->lineEnding; } /** @@ -1797,7 +1923,7 @@ class Html extends BaseWriter $colNum = $key - 1; if (!$tcpdfInited && $key !== 1) { $tempspan = ($colNum > 1) ? " colspan='$colNum'" : ''; - $html .= "\n"; + $html .= "" . $this->lineEnding; } $tcpdfInited = true; } @@ -1865,25 +1991,24 @@ class Html extends BaseWriter // Next column ++$colNum; } + if ($this instanceof Pdf\Tcpdf) { + if (str_ends_with($html, '' . $this->lineEnding)) { + $html .= '' . $this->lineEnding; + } + } // Write row end - $html .= ' ' . PHP_EOL; + $html .= ' ' . $this->lineEnding; // Return return $html; } - /** @param string[] $matches */ - private static function replaceNonAscii(array $matches): string - { - return '&#' . mb_ord($matches[0], 'UTF-8') . ';'; - } - private static function replaceControlChars(string $convert): string { - return (string) preg_replace_callback( + return Preg::replaceCallback( '/[\x00-\x1f]/', - [self::class, 'replaceNonAscii'], + fn (array $matches) => '&#' . ord($matches[0]) . ';', $convert ); } @@ -1969,9 +2094,10 @@ class Html extends BaseWriter return $this->tableFormats; } - public function setTableFormats(bool $tableFormats): self + public function setTableFormats(bool $tableFormats, ?bool $tableFormatsBuiltin = null): self { $this->tableFormats = $tableFormats; + $this->tableFormatsBuiltin = $tableFormatsBuiltin; return $this; } @@ -2018,7 +2144,7 @@ class Html extends BaseWriter } // convert to PCDATA - $result = htmlspecialchars($value, Settings::htmlEntityFlags()); + $result = htmlspecialchars($value, ENT_NOQUOTES); // color span tag if ($color !== null) { @@ -2084,51 +2210,12 @@ class Html extends BaseWriter } } } - - $this->calculateSpansOmitRows($sheet, $sheetIndex, $candidateSpannedRow); - - // TODO: Same for columns } // We have calculated the spans $this->spansAreCalculated = true; } - /** @param int[] $candidateSpannedRow */ - private function calculateSpansOmitRows(Worksheet $sheet, int $sheetIndex, array $candidateSpannedRow): void - { - // Identify which rows should be omitted in HTML. These are the rows where all the cells - // participate in a merge and the where base cells are somewhere above. - $countColumns = Coordinate::columnIndexFromString($sheet->getHighestColumn()); - foreach ($candidateSpannedRow as $rowIndex) { - if (isset($this->isSpannedCell[$sheetIndex][$rowIndex])) { - if (count($this->isSpannedCell[$sheetIndex][$rowIndex]) == $countColumns) { - $this->isSpannedRow[$sheetIndex][$rowIndex] = $rowIndex; - } - } - } - - // For each of the omitted rows we found above, the affected rowspans should be subtracted by 1 - if (isset($this->isSpannedRow[$sheetIndex])) { - foreach ($this->isSpannedRow[$sheetIndex] as $rowIndex) { - /** @var int $rowIndex */ - $adjustedBaseCells = []; - $c = -1; - $e = $countColumns - 1; - while ($c++ < $e) { - $baseCell = $this->isSpannedCell[$sheetIndex][$rowIndex][$c]['baseCell']; - - if (!in_array($baseCell, $adjustedBaseCells, true)) { - // subtract rowspan by 1 - /** @var array $baseCell */ - --$this->isBaseCell[$sheetIndex][$baseCell[0]][$baseCell[1]]['rowspan']; - $adjustedBaseCells[] = $baseCell; - } - } - } - } - } - /** * Write a comment in the same format as LibreOffice. * @@ -2148,7 +2235,7 @@ class Html extends BaseWriter if ($sanitizedString !== '') { $result .= ''; $result .= "
" . $sanitizedString . '
'; - $result .= PHP_EOL; + $result .= $this->lineEnding; } } @@ -2178,7 +2265,7 @@ class Html extends BaseWriter } // Construct HTML - $htmlPage = $generateSurroundingHTML ? ('' . PHP_EOL) : ''; + $htmlPage .= $generateSurroundingHTML ? ('' . $this->lineEnding) : ''; return $htmlPage; } + private function printAreaStyles(int $sheetId, Worksheet $worksheet): string + { + $retVal = ''; + $printArea = $worksheet->getPageSetup()->getPrintArea(); + if (Preg::isMatch('/^([a-z]+)([0-9]+):([a-z]+)([0-9]+)$/i', $printArea, $matches)) { + $lowCol = Coordinate::columnIndexFromString($matches[1]) - 1; + $highCol = Coordinate::columnIndexFromString($matches[3]) - 1; + $lowRow = (int) $matches[2] - 1; + $highRow = (int) $matches[4] - 1; + $retVal = '@media print {' . $this->lineEnding; + $highDataRow = $worksheet->getHighestDataRow(); + for ($row = 0; $row < $highDataRow; ++$row) { + if ($row < $lowRow || $row > $highRow) { + $retVal .= " table.sheet$sheetId tr.row$row td { display:none }" . $this->lineEnding; + } + } + $highDataColumn = $worksheet->getHighestDataColumn(); + $highDataCol = Coordinate::columnIndexFromString($highDataColumn); + for ($col = 0; $col < $highDataCol; ++$col) { + if ($col < $lowCol || $col > $highCol) { + $retVal .= " table.sheet$sheetId td.column$col { display:none }" . $this->lineEnding; + } + } + $retVal .= '}' . $this->lineEnding; + } + + return $retVal; + } + private function shouldGenerateRow(Worksheet $sheet, int $row): bool { + if ($this->isPdf) { + if ($this->printAreaLowRow >= 0) { + if ($row < $this->printAreaLowRow || $row > $this->printAreaHighRow) { + return false; + } + } + } if (!($this instanceof Pdf\Mpdf || $this instanceof Pdf\Tcpdf)) { return true; } @@ -2234,6 +2360,14 @@ class Html extends BaseWriter private function shouldGenerateColumn(Worksheet $sheet, string $colStr): bool { + if ($this->isPdf) { + if ($this->printAreaLowCol >= 0) { + $col = Coordinate::columnIndexFromString($colStr); + if ($col < $this->printAreaLowCol || $col > $this->printAreaHighCol) { + return false; + } + } + } if (!($this instanceof Pdf\Mpdf || $this instanceof Pdf\Tcpdf)) { return true; } @@ -2255,4 +2389,31 @@ class Html extends BaseWriter return $this; } + + private static function nl2brx(string $string, bool $useXhtml = false): string + { + return str_replace( + ["\r\n", "\n\r", "\r", "\n"], + self::BRX, + $string + ); + } + + private function extendRowsAndColumnsForMerge(Worksheet $worksheet, int &$colMax, int &$rowMax): void + { + foreach ($worksheet->getMergeCells() as $cellRange) { + if (Preg::isMatch('/[a-z]{1,3}\d+:([a-z]{1,3})(\d+)/i', $cellRange, $matches)) { + $col = Coordinate::columnIndexFromString($matches[1]); + if ($colMax < $col) { + $colMax = $col; + $worksheet->getColumnDimension($matches[1]); + } + $row = (int) $matches[2]; + if ($rowMax < $row) { + $rowMax = $row; + $worksheet->getRowDimension($row); + } + } + } + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods.php index 638c16f..5293021 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods.php @@ -86,6 +86,12 @@ class Ods extends BaseWriter return $this->writerPartThumbnails; } + /** @param array $additionalNumberFormats */ + public function useAdditionalNumberFormats(array $additionalNumberFormats): void + { + $this->writerPartContent->additionalNumberFormats = $additionalNumberFormats; + } + /** * Save PhpSpreadsheet to file. * @@ -100,6 +106,18 @@ class Ods extends BaseWriter $this->openFileHandle($filename); + // Collect all drawings from all worksheets + $drawingWriter = $this->getWriterPartContent()->getDrawingWriter(); + $drawingWriter->reset(); // Reset state + $sheetCount = $this->spreadSheet->getSheetCount(); + for ($i = 0; $i < $sheetCount; ++$i) { + $sheet = $this->spreadSheet->getSheet($i); + $drawingWriter->collectDrawings($sheet); + } + $drawings = $drawingWriter->getAllImageFiles(); + // Pass image files to MetaInf writer + $this->getWriterPartMetaInf()->setImageFiles($drawings); + $zip = $this->createZip(); $zip->addFile('META-INF/manifest.xml', $this->getWriterPartMetaInf()->write()); @@ -110,6 +128,9 @@ class Ods extends BaseWriter $zip->addFile('meta.xml', $this->getWriterPartmeta()->write()); $zip->addFile('mimetype', $this->getWriterPartmimetype()->write()); $zip->addFile('styles.xml', $this->getWriterPartstyles()->write()); + foreach ($drawings as $imagePath => $imageContent) { + $zip->addFile($imagePath, $imageContent); + } // Close file try { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php index 5c3cf52..939c29e 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php @@ -2,13 +2,17 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Ods\Cell; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Helper\Dimension; +use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Style\Alignment; use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Font; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Style\Protection; use PhpOffice\PhpSpreadsheet\Style\Style as CellStyle; use PhpOffice\PhpSpreadsheet\Worksheet\ColumnDimension; use PhpOffice\PhpSpreadsheet\Worksheet\RowDimension; @@ -24,9 +28,19 @@ class Style private XMLWriter $writer; - public function __construct(XMLWriter $writer) + /** @var array */ + private array $additionalNumberFormats; + + /** @param array $additionalNumberFormats */ + public function __construct(XMLWriter $writer, array $additionalNumberFormats = []) { $this->writer = $writer; + $this->additionalNumberFormats = $additionalNumberFormats; + } + + public function getWriter(): XMLWriter + { + return $this->writer; } private function mapHorizontalAlignment(?string $horizontalAlignment): string @@ -54,18 +68,28 @@ class Style { switch ($fill->getFillType()) { case Fill::FILL_SOLID: - $this->writer->writeAttribute('fo:background-color', sprintf( - '#%s', - strtolower($fill->getStartColor()->getRGB()) - )); + $this->writer->writeAttribute( + 'fo:background-color', + sprintf( + '#%s', + // no idea why strtolower, but it doesn't hurt + strtolower($fill->getStartColor()->getRGB()) + ) + ); - break; - case Fill::FILL_GRADIENT_LINEAR: - case Fill::FILL_GRADIENT_PATH: - /// TODO :: To be implemented break; case Fill::FILL_NONE: - default: + $this->writer->writeAttribute( + 'fo:background-color', + 'transparent' + ); + + break; + //case Fill::FILL_GRADIENT_LINEAR: + //case Fill::FILL_GRADIENT_PATH: + // TODO :: To be implemented + //break; + //default: } } @@ -75,6 +99,13 @@ class Style $this->writeBorderStyle('left', $borders->getLeft()); $this->writeBorderStyle('right', $borders->getRight()); $this->writeBorderStyle('top', $borders->getTop()); + $diagonal = $borders->getDiagonalDirection(); + if ($diagonal === Borders::DIAGONAL_DOWN || $diagonal === Borders::DIAGONAL_BOTH) { + $this->writeBorderStyle('style:diagonal-tl-br', $borders->getDiagonal()); + } + if ($diagonal === Borders::DIAGONAL_UP || $diagonal === Borders::DIAGONAL_BOTH) { + $this->writeBorderStyle('style:diagonal-bl-tr', $borders->getDiagonal()); + } } private function writeBorderStyle(string $direction, Border $border): void @@ -83,7 +114,8 @@ class Style return; } - $this->writer->writeAttribute('fo:border-' . $direction, sprintf( + $attrName = str_starts_with($direction, 'style:') ? $direction : ('fo:border-' . $direction); + $this->writer->writeAttribute($attrName, sprintf( '%s %s #%s', $this->mapBorderWidth($border), $this->mapBorderStyle($border), @@ -91,59 +123,56 @@ class Style )); } + private const MAP_BORDER_WIDTH = [ + Border::BORDER_THIN => '0.75pt', + Border::BORDER_DASHED => '0.75pt', + Border::BORDER_DASHDOT => '0.75pt', + Border::BORDER_DASHDOTDOT => '0.75pt', + Border::BORDER_DOTTED => '0.75pt', + Border::BORDER_HAIR => '0.75pt', + // end of thin styles + Border::BORDER_MEDIUM => '1.75pt', + Border::BORDER_MEDIUMDASHED => '1.75pt', + Border::BORDER_MEDIUMDASHDOT => '1.75pt', + Border::BORDER_MEDIUMDASHDOTDOT => '1.75pt', + Border::BORDER_SLANTDASHDOT => '1.75pt', + // end of medium styles + Border::BORDER_DOUBLE => '2.5pt', + Border::BORDER_THICK => '2.5pt', + ]; + private function mapBorderWidth(Border $border): string { - switch ($border->getBorderStyle()) { - case Border::BORDER_THIN: - case Border::BORDER_DASHED: - case Border::BORDER_DASHDOT: - case Border::BORDER_DASHDOTDOT: - case Border::BORDER_DOTTED: - case Border::BORDER_HAIR: - return '0.75pt'; - case Border::BORDER_MEDIUM: - case Border::BORDER_MEDIUMDASHED: - case Border::BORDER_MEDIUMDASHDOT: - case Border::BORDER_MEDIUMDASHDOTDOT: - case Border::BORDER_SLANTDASHDOT: - return '1.75pt'; - case Border::BORDER_DOUBLE: - case Border::BORDER_THICK: - return '2.5pt'; - } - - return '1pt'; + return self::MAP_BORDER_WIDTH[$border->getBorderStyle()] ?? '1pt'; } + private const MAP_BORDER_STYLE = [ + Border::BORDER_DOTTED => 'dotted', + Border::BORDER_DASHED => 'dashed', + Border::BORDER_MEDIUMDASHED => 'dashed', + Border::BORDER_DASHDOT => 'dash-dot', + Border::BORDER_MEDIUMDASHDOT => 'dash-dot', + Border::BORDER_DASHDOTDOT => 'dash-dot-dot', + Border::BORDER_MEDIUMDASHDOTDOT => 'dash-dot-dot', + Border::BORDER_SLANTDASHDOT => 'dashed', + Border::BORDER_DOUBLE => 'double', + Border::BORDER_NONE => 'none', + // HAIR, MEDIUM, THICK, THIN fall through to default solid + ]; + private function mapBorderStyle(Border $border): string { - switch ($border->getBorderStyle()) { - case Border::BORDER_DOTTED: - case Border::BORDER_MEDIUMDASHDOTDOT: - return Border::BORDER_DOTTED; - - case Border::BORDER_DASHED: - case Border::BORDER_DASHDOT: - case Border::BORDER_DASHDOTDOT: - case Border::BORDER_MEDIUMDASHDOT: - case Border::BORDER_MEDIUMDASHED: - case Border::BORDER_SLANTDASHDOT: - return Border::BORDER_DASHED; - - case Border::BORDER_DOUBLE: - return Border::BORDER_DOUBLE; - - case Border::BORDER_HAIR: - case Border::BORDER_MEDIUM: - case Border::BORDER_THICK: - case Border::BORDER_THIN: - return 'solid'; - } - - return 'solid'; + return self::MAP_BORDER_STYLE[$border->getBorderStyle()] ?? 'solid'; } - private function writeCellProperties(CellStyle $style): void + // 2d array, 1st index is locked, 2nd is hidden + private const PROTECTION_MAP = [ + Protection::PROTECTION_PROTECTED => [Protection::PROTECTION_PROTECTED => 'protected formula-hidden', Protection::PROTECTION_UNPROTECTED => 'protected'], + Protection::PROTECTION_UNPROTECTED => [Protection::PROTECTION_PROTECTED => 'formula-hidden', Protection::PROTECTION_UNPROTECTED => 'none'], + ]; + + /** @internal */ + public function writeCellProperties(CellStyle $style): void { // Align $hAlign = $style->getAlignment()->getHorizontal(); @@ -152,6 +181,8 @@ class Style $wrap = $style->getAlignment()->getWrapText(); $indent = $style->getAlignment()->getIndent(); $readOrder = $style->getAlignment()->getReadOrder(); + $shrinkToFit = $style->getAlignment()->getShrinkToFit(); + $textRotation = $style->getAlignment()->getTextRotation(); $this->writer->startElement('style:table-cell-properties'); if (!empty($vAlign) || $wrap) { @@ -163,7 +194,16 @@ class Style $this->writer->writeAttribute('fo:wrap-option', 'wrap'); } } + if ($textRotation !== null) { + if ($textRotation < 0) { + $textRotation += 360; + } + $this->writer->writeAttribute('style:rotation-angle', (string) $textRotation); + } $this->writer->writeAttribute('style:rotation-align', 'none'); + if ($shrinkToFit) { + $this->writer->writeAttribute('style:shrink-to-fit', 'true'); + } // Fill $this->writeFillStyle($style->getFill()); @@ -171,6 +211,12 @@ class Style // Border $this->writeBordersStyle($style->getBorders()); + // protection + $protection = self::PROTECTION_MAP[$style->getProtection()->getLocked()][$style->getProtection()->getHidden()] ?? ''; + if ($protection !== '') { + $this->writer->writeAttribute('style:cell-protect', $protection); + } + $this->writer->endElement(); if ($hAlign !== '' || !empty($indent) || $readOrder === Alignment::READORDER_RTL || $readOrder === Alignment::READORDER_LTR) { @@ -201,7 +247,8 @@ class Style }; } - protected function writeTextProperties(CellStyle $style): void + /** @internal */ + public function writeTextProperties(CellStyle $style): void { // Font $this->writer->startElement('style:text-properties'); @@ -236,16 +283,27 @@ class Style } if ($size = $font->getSize()) { - $this->writer->writeAttribute('fo:font-size', sprintf('%.1Fpt', $size)); + $this->writer->writeAttribute('fo:font-size', ($size == (int) $size) ? sprintf('%dpt', $size) : sprintf('%.1Fpt', $size)); } if ($font->getUnderline() && $font->getUnderline() !== Font::UNDERLINE_NONE) { - $this->writer->writeAttribute('style:text-underline-style', 'solid'); - $this->writer->writeAttribute('style:text-underline-width', 'auto'); - $this->writer->writeAttribute('style:text-underline-color', 'font-color'); + $this->writer + ->writeAttribute('style:text-underline-style', 'solid'); + $this->writer + ->writeAttribute('style:text-underline-width', 'auto'); + $this->writer + ->writeAttribute('style:text-underline-color', 'font-color'); $underline = $this->mapUnderlineStyle($font); - $this->writer->writeAttribute('style:text-underline-type', $underline); + $this->writer + ->writeAttribute('style:text-underline-type', $underline); + } + + if ($font->getStrikethrough()) { + $this->writer + ->writeAttribute('style:text-line-through-style', 'solid'); + $this->writer + ->writeAttribute('style:text-line-through-type', 'single'); } $this->writer->endElement(); // Close style:text-properties @@ -344,12 +402,52 @@ class Style $this->writer->endElement(); // Close style:style } + private int $numFmtIndex = 199; + + /** @var array */ + private array $numFmtIndexes = []; + + private function writeNumFmt(string $numFmt): void + { + if (array_key_exists($numFmt, $this->numFmtIndexes)) { + return; + } + $method = $this->additionalNumberFormats[$numFmt] ?? self::NUMBER_FORMAT_METHODS[$numFmt] ?? null; + if ($method === null) { + if (Preg::isMatch('/^0[0#]+$/', $numFmt)) { + $this->additionalNumberFormats[$numFmt] = $method = [self::class, 'formatIntLeading0']; + } elseif (Date::isDateTimeFormatCode($numFmt)) { + if (!Preg::isMatch('/[HhSs]/', $numFmt)) { + $method = [self::class, 'formatDateYyyymmdd']; + } elseif (Preg::isMatch('/[YyDd]/', $numFmt)) { + $method = [self::class, 'formatDateDatetimeBetter']; + } else { + $method = [self::class, 'formatDateTime1']; + } + } else { + return; + } + } + ++$this->numFmtIndex; + $name = 'N' . $this->numFmtIndex; + $this->numFmtIndexes[$numFmt] = $name; + $method($this, $name, $numFmt); + } + public function write(CellStyle $style): void { + $numFmt = (string) $style->getNumberFormat()->getFormatCode(); + $this->writeNumFmt($numFmt); $this->writer->startElement('style:style'); $this->writer->writeAttribute('style:name', self::CELL_STYLE_PREFIX . $style->getIndex()); $this->writer->writeAttribute('style:family', 'table-cell'); $this->writer->writeAttribute('style:parent-style-name', 'Default'); + if (array_key_exists($numFmt, $this->numFmtIndexes)) { + $this->writer->writeAttribute( + 'style:data-style-name', + $this->numFmtIndexes[$numFmt] + ); + } // Alignment, fill colour, etc $this->writeCellProperties($style); @@ -360,4 +458,711 @@ class Style // End $this->writer->endElement(); // Close style:style } + + private const NUMBER_FORMAT_METHODS = [ + NumberFormat::FORMAT_NUMBER => [self::class, 'formatNumber'], + NumberFormat::FORMAT_NUMBER_0 => [self::class, 'formatNumber0'], + NumberFormat::FORMAT_NUMBER_00 => [self::class, 'formatNumber00'], + NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 => [self::class, 'formatNumberCommaSeparated1'], + NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED2 => [self::class, 'formatNumberCommaSeparated2'], + NumberFormat::FORMAT_PERCENTAGE => [self::class, 'formatPercentage'], + NumberFormat::FORMAT_PERCENTAGE_0 => [self::class, 'formatPercentage0'], + NumberFormat::FORMAT_PERCENTAGE_00 => [self::class, 'formatPercentage00'], + NumberFormat::FORMAT_DATE_YYYYMMDD => [self::class, 'formatDateYyyymmdd'], + NumberFormat::FORMAT_DATE_DDMMYYYY => [self::class, 'formatDateDdmmyyyy'], + NumberFormat::FORMAT_DATE_DMYSLASH => [self::class, 'formatDateDmyslash'], + NumberFormat::FORMAT_DATE_DMYMINUS => [self::class, 'formatDateDmyminus'], + NumberFormat::FORMAT_DATE_DMMINUS => [self::class, 'formatDateDmminus'], + NumberFormat::FORMAT_DATE_MYMINUS => [self::class, 'formatDateMyminus'], + NumberFormat::FORMAT_DATE_XLSX14 => [self::class, 'formatDateXlsx14'], + NumberFormat::FORMAT_DATE_XLSX14_ACTUAL => [self::class, 'formatDateXlsx14Actual'], + NumberFormat::FORMAT_DATE_XLSX15 => [self::class, 'formatDateXlsx15'], + NumberFormat::FORMAT_DATE_XLSX15_YYYY => [self::class, 'formatDateXlsx15Yyyy'], + NumberFormat::FORMAT_DATE_XLSX16 => [self::class, 'formatDateXlsx16'], + NumberFormat::FORMAT_DATE_XLSX17 => [self::class, 'formatDateXlsx17'], + NumberFormat::FORMAT_DATE_XLSX22 => [self::class, 'formatDateXlsx22'], + NumberFormat::FORMAT_DATE_XLSX22_ACTUAL => [self::class, 'formatDateXlsx22Actual'], + NumberFormat::FORMAT_DATE_DATETIME => [self::class, 'formatDateDatetime'], + NumberFormat::FORMAT_DATE_DATETIME_BETTER => [self::class, 'formatDateDatetimeBetter'], + NumberFormat::FORMAT_DATE_TIME1 => [self::class, 'formatDateTime1'], + NumberFormat::FORMAT_DATE_TIME2 => [self::class, 'formatDateTime2'], + NumberFormat::FORMAT_DATE_TIME3 => [self::class, 'formatDateTime3'], + NumberFormat::FORMAT_DATE_TIME4 => [self::class, 'formatDateTime4'], + NumberFormat::FORMAT_DATE_TIME5 => [self::class, 'formatDateTime5'], + //NumberFormat::FORMAT_DATE_TIME6 => [self::class, 'formatDateTime6'], // FORMAT_DATE_TIME6 is identical to TIME4 + NumberFormat::FORMAT_DATE_TIME7 => [self::class, 'formatDateTime7'], // constant is probably mis-coded + NumberFormat::FORMAT_DATE_TIME8 => [self::class, 'formatDateTime8'], + NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS => [self::class, 'formatDateTimeIntervalHms'], + NumberFormat::FORMAT_DATE_YYYYMMDDSLASH => [self::class, 'formatDateYyyymmddslash'], + NumberFormat::FORMAT_DATE_LONG_DATE => [self::class, 'formatDateLongDate'], + NumberFormat::FORMAT_CURRENCY_USD_INTEGER => [self::class, 'formatCurrencyUsdInteger'], + NumberFormat::FORMAT_CURRENCY_USD => [self::class, 'formatCurrencyUsd'], + NumberFormat::FORMAT_ACCOUNTING_USD => [self::class, 'formatCurrencyUsd'], // ACCOUNTING and CURRENCY are same in Ods + NumberFormat::FORMAT_CURRENCY_EUR_INTEGER => [self::class, 'formatCurrencyEurInteger'], + NumberFormat::FORMAT_CURRENCY_EUR => [self::class, 'formatCurrencyEur'], + NumberFormat::FORMAT_ACCOUNTING_EUR => [self::class, 'formatCurrencyEur'], // ACCOUNTING and CURRENCY are same in Ods + NumberFormat::FORMAT_CURRENCY_GBP_INTEGER => [self::class, 'formatCurrencyGbpInteger'], + NumberFormat::FORMAT_CURRENCY_GBP => [self::class, 'formatCurrencyGbp'], + NumberFormat::FORMAT_CURRENCY_YEN_YUAN_INTEGER => [self::class, 'formatCurrencyYenYuanInteger'], + NumberFormat::FORMAT_CURRENCY_YEN_YUAN => [self::class, 'formatCurrencyYenYuan'], + ]; + + protected static function formatIntLeading0(self $obj, string $name, string $numFmt): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '0'); + $obj->writer->writeAttribute('number:min-integer-digits', (string) strlen($numFmt)); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumber(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumber0(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '1'); + $obj->writer->writeAttribute('number:min-decimal-places', '1'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumber00(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumberCommaSeparated1(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumberCommaSeparated2(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + //$obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->endElement(); // number:number-style + } + + protected static function formatPercentage(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '0'); + $obj->writer->writeAttribute('number:min-decimal-places', '0'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatPercentage0(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '1'); + $obj->writer->writeAttribute('number:min-decimal-places', '1'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatPercentage00(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatDateYyyymmdd(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDdmmyyyy(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmyslash(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmyminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:month'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateMyminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx14(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx14Actual(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx15(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx15Yyyy(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx16(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx17(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx22(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx22Actual(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDatetime(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDatetimeBetter(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', ' '); + $obj->writer->startElement('number:hours'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:hours + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateTime1(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:am-pm'); + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime2(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:am-pm'); + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime3(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime4(self $obj, string $name): void + { + // TIME4 and TIME6 are identical + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime5(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime7(self $obj, string $name): void + { + // constant is probably mis-coded + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime8(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name . 'P0'); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + $obj->writer->startElement('number:text-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:text-content'); + $obj->writer->startElement('style:map'); + $obj->writer->writeAttribute('style:condition', 'value()>=0'); + $obj->writer->writeAttribute('style:apply-style-name', $name . 'P0'); + $obj->writer->endElement(); // number:style-map + $obj->writer->endElement(); // number:text-style + } + + protected static function formatDateTimeIntervalHms(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeAttribute( + 'number:truncate-on-overflow', + 'false' + ); + $obj->writer->startElement('number:hours'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:hours + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateYyyymmddslash(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name . 'P0'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->endElement(); // number:date-style + $obj->writer->startElement('number:text-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:text-content'); + $obj->writer->startElement('style:map'); + $obj->writer->writeAttribute('style:condition', 'value()>=0'); + $obj->writer->writeAttribute('style:apply-style-name', $name . 'P0'); + $obj->writer->endElement(); // number:style-map + $obj->writer->endElement(); // number:text-style + } + + protected static function formatDateLongDate(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day-of-week'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day-of-week + $obj->writer->writeElement('number:text', ', '); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', ', '); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatCurrencyUsdInteger(self $obj, string $name, string $symbol = '$'): void + { + $obj->writer->startElement('number:number-style'); // not currency-style + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->WriteElement('number:text', $symbol); + $obj->writer->startElement('number:number'); + $decimals = '0'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->startElement('number:embedded-text'); + $obj->writer->writeAttribute('number-position', '0'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:embedded-text + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatCurrencyGbpInteger(self $obj, string $name): void + { + self::formatCurrencyUsdInteger($obj, $name, '£'); + } + + protected static function formatCurrencyYenYuanInteger(self $obj, string $name): void + { + self::formatCurrencyUsdInteger($obj, $name, '¥'); + } + + protected static function formatCurrencyUsd(self $obj, string $name, string $symbol = '$'): void + { + // Ods uses same format for Currency and Accounting + $obj->writer->startElement('number:number-style'); // NOT currency-style + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->WriteElement('number:text', $symbol); + $obj->writer->startElement('number:number'); + $decimals = '2'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', ' '); + $obj->writer->endElement(); // number:currency-style + } + + protected static function formatCurrencyGbp(self $obj, string $name): void + { + self::formatCurrencyUsd($obj, $name, '£'); + } + + protected static function formatCurrencyYenYuan(self $obj, string $name): void + { + self::formatCurrencyUsd($obj, $name, '¥'); + } + + protected static function formatCurrencyEurInteger(self $obj, string $name): void + { + $obj->writer->startElement('number:currency-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $decimals = '0'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->startElement('number:embedded-text'); + $obj->writer->writeAttribute('number:position', '0'); + $obj->writer->endElement(); // number:embedded-text + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + // $obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->startElement('number:currency-symbol'); + $obj->writer->writeAttribute('number:language', 'en'); + $obj->writer->writeAttribute('number:country', 'us'); + $obj->writer->text('€'); + $obj->writer->endElement(); // number:currency-symbol + + $obj->writer->endElement(); // number:currency-style + } + + protected static function formatCurrencyEur(self $obj, string $name): void + { + // Ods uses same format for Currency and Accounting + $obj->writer->startElement('number:currency-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $decimals = '2'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + // $obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->startElement('number:currency-symbol'); + $obj->writer->writeAttribute('number:language', 'en'); + $obj->writer->writeAttribute('number:country', 'us'); + $obj->writer->text('€'); + $obj->writer->endElement(); // number:currency-symbol + + $obj->writer->endElement(); // number:currency-style + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Content.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Content.php index 5aea6c2..ac00973 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -5,11 +5,15 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Ods; use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; +use PhpOffice\PhpSpreadsheet\Calculation\Information\ExcelError; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing; use PhpOffice\PhpSpreadsheet\Worksheet\RowCellIterator; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer\Ods; @@ -23,6 +27,10 @@ class Content extends WriterPart { private Formula $formulaConvertor; + private Drawing $drawingWriter; + + private int $drawingIndex = 0; + /** * Set parent Ods writer. */ @@ -31,6 +39,7 @@ class Content extends WriterPart parent::__construct($writer); $this->formulaConvertor = new Formula($this->getParentWriter()->getSpreadsheet()->getDefinedNames()); + $this->drawingWriter = new Drawing($writer); } /** @@ -111,6 +120,14 @@ class Content extends WriterPart return $objWriter->getData(); } + /** + * Get drawing writer instance. + */ + public function getDrawingWriter(): Drawing + { + return $this->drawingWriter; + } + /** * Write sheets. */ @@ -139,7 +156,6 @@ class Content extends WriterPart 'table:style-name', sprintf('%s_%d_%d', Style::COLUMN_STYLE_PREFIX, $sheetIndex, $columnDimension->getColumnNumeric()) ); - $objWriter->writeAttribute('table:default-cell-style-name', 'ce0'); $objWriter->endElement(); } $this->writeRows($objWriter, $spreadsheet->getSheet($sheetIndex), $sheetIndex); @@ -154,11 +170,26 @@ class Content extends WriterPart { $spanRow = 0; $rows = $sheet->getRowIterator(); + + // Build a map of drawings by their row position + /** @var array> */ + $drawingsByRow = []; + foreach ($sheet->getDrawingCollection() as $drawing) { + ++$this->drawingIndex; + $coordinates = Coordinate::coordinateFromString($drawing->getCoordinates()); + $row = (int) $coordinates[1]; + if (!isset($drawingsByRow[$row])) { + $drawingsByRow[$row] = []; + } + $drawingsByRow[$row][] = ['drawing' => $drawing, 'index' => $this->drawingIndex]; + } + foreach ($rows as $row) { $cellIterator = $row->getCellIterator(iterateOnlyExistingCells: true); $cellIterator->rewind(); $rowStyleExists = $sheet->rowDimensionExists($row->getRowIndex()) && $sheet->getRowDimension($row->getRowIndex())->getRowHeight() > 0; - if ($cellIterator->valid() || $rowStyleExists) { + $rowIndex = $row->getRowIndex(); + if ($cellIterator->valid() || $rowStyleExists || isset($drawingsByRow[$rowIndex])) { if ($spanRow) { $objWriter->startElement('table:table-row'); $objWriter->writeAttribute( @@ -172,15 +203,15 @@ class Content extends WriterPart if ($rowStyleExists) { $objWriter->writeAttribute( 'table:style-name', - sprintf('%s_%d_%d', Style::ROW_STYLE_PREFIX, $sheetIndex, $row->getRowIndex()) + sprintf('%s_%d_%d', Style::ROW_STYLE_PREFIX, $sheetIndex, $rowIndex) ); - } elseif ($sheet->getDefaultRowDimension()->getRowHeight() > 0.0 && !$sheet->getRowDimension($row->getRowIndex())->getCustomFormat()) { + } elseif ($sheet->getDefaultRowDimension()->getRowHeight() > 0.0 && !$sheet->getRowDimension($rowIndex)->getCustomFormat()) { $objWriter->writeAttribute( 'table:style-name', sprintf('%s%d', Style::ROW_STYLE_PREFIX, $sheetIndex) ); } - $this->writeCells($objWriter, $cellIterator); + $this->writeCells($objWriter, $cellIterator, $drawingsByRow, $rowIndex, $sheet); $objWriter->endElement(); } else { ++$spanRow; @@ -190,10 +221,20 @@ class Content extends WriterPart /** * Write cells of the specified row. + * + * @param array> $drawingsByRow */ - private function writeCells(XMLWriter $objWriter, RowCellIterator $cells): void + private function writeCells(XMLWriter $objWriter, RowCellIterator $cells, array $drawingsByRow, int $rowIndex, Worksheet $sheet): void { $prevColumn = -1; + // Get drawings for this row + $rowDrawings = $drawingsByRow[$rowIndex] ?? []; + $drawingsByColumn = []; + foreach ($rowDrawings as $drawingData) { + $coordinates = Coordinate::coordinateFromString($drawingData['drawing']->getCoordinates()); + $column = Coordinate::columnIndexFromString($coordinates[0]) - 1; + $drawingsByColumn[$column] = $drawingData; + } foreach ($cells as $cell) { /** @var Cell $cell */ $column = Coordinate::columnIndexFromString($cell->getColumn()) - 1; @@ -223,11 +264,13 @@ class Content extends WriterPart break; case DataType::TYPE_FORMULA: $formulaValue = $cell->getValueString(); + $formulaValueCalc = $formulaValue; if ($this->getParentWriter()->getPreCalculateFormulas()) { try { $formulaValue = $cell->getCalculatedValueString(); + $formulaValueCalc = $cell->getCalculatedValue(); } catch (CalculationException $e) { - // don't do anything + $formulaValue = $formulaValueCalc = ExcelError::CALC(); } } if (isset($attributes['ref'])) { @@ -247,19 +290,115 @@ class Content extends WriterPart } } $objWriter->writeAttribute('table:formula', $this->formulaConvertor->convertFormula($cell->getValueString())); - if (is_numeric($formulaValue)) { - $objWriter->writeAttribute('office:value-type', 'float'); - } else { - $objWriter->writeAttribute('office:value-type', 'string'); - } - $objWriter->writeAttribute('office:value', $formulaValue); - $objWriter->writeElement('text:p', $formulaValue); + if (is_bool($formulaValueCalc)) { + $objWriter->writeAttribute( + 'office:value-type', + 'boolean' + ); + $objWriter->writeAttribute( + 'office:boolean-value', + $formulaValueCalc ? 'true' : 'false' + ); + $objWriter->writeElement('text:p', $formulaValueCalc ? 'TRUE' : 'FALSE'); - break; + break; + } + if (!is_numeric($formulaValue)) { + $objWriter->writeAttribute( + 'office:value-type', + 'string' + ); + $objWriter->writeAttribute( + 'office:string-value', + $formulaValue + ); + $objWriter->writeElement('text:p', $formulaValue); + + break; + } + // no break case DataType::TYPE_NUMERIC: - $objWriter->writeAttribute('office:value-type', 'float'); - $objWriter->writeAttribute('office:value', $cell->getValueString()); - $objWriter->writeElement('text:p', $cell->getValueString()); + $holdWorksheet = $cell->getWorksheet(); + $holdSelected = $holdWorksheet->getSelectedCells(); + $holdSpreadsheet = $holdWorksheet->getParent(); + $holdActiveSheetIndex = $holdSpreadsheet?->getActiveSheetIndex(); + $formatted = $cell->getFormattedValue(); + $type = 'float'; + $valueType = 'value'; + $value = $cell->getCalculatedValueString(); + $numFmt = $cell->getStyle() + ->getNumberFormat() + ->getFormatCode() ?? ''; + $holdWorksheet->setSelectedCells($holdSelected); + if (isset($holdSpreadsheet, $holdActiveSheetIndex)) { + $holdSpreadsheet->setActiveSheetIndex( + $holdActiveSheetIndex + ); + } + if (Date::isDateTimeFormatCode($numFmt, true)) { + $valueCalc = $cell->getCalculatedValueString(); + if (Preg::isMatch('/[HhSs]/', $numFmt) && !Preg::isMatch('/[YyDd]/', $numFmt)) { + $minus = ''; + $type = 'time'; + $valueType = 'time-value'; + if (str_starts_with($valueCalc, '-')) { + $minus = '-'; + $absVal = fmod(abs((float) $value), 1.0); + $hms = (int) round(86400 * $absVal); + $hours = intdiv($hms, 3600); + $hms -= $hours * 3600; + $minutes = intdiv($hms, 60); + $seconds = $hms % 60; + $value = sprintf('-PT%02dH%02dM%02dS', $hours, $minutes, $seconds); + $formatted = sprintf('-%02d:%02d:%02d', $hours, $minutes, $seconds); + } else { + $hhmmss = NumberFormat::toFormattedString( + $value, + NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS + ); + $daysAndHours = 24 * (int) $valueCalc + (int) substr($hhmmss, 0, 2); + $value = "PT$daysAndHours" + . 'H' + . substr($hhmmss, 3, 2) + . 'M' + . substr($hhmmss, 6, 2) + . 'S'; + } + } else { + $type = 'date'; + $valueType = 'date-value'; + $value = NumberFormat::toFormattedString( + $value, + 'yyyy-mm-dd"T"hh:mm:ss' + ); + } + } elseif (str_ends_with($numFmt, '%')) { + $type = 'percentage'; + } + if ($numFmt === NumberFormat::FORMAT_CURRENCY_EUR || $numFmt === NumberFormat::FORMAT_CURRENCY_EUR_INTEGER) { + $objWriter->writeAttribute( + 'office:value-type', + 'currency' + ); + $objWriter->writeAttribute( + 'office:currency', + 'EUR' + ); + $objWriter->writeAttribute( + 'office:value', + $value + ); + } else { + $objWriter->writeAttribute( + 'office:value-type', + $type + ); + $objWriter->writeAttribute( + "office:$valueType", + $value + ); + } + $objWriter->writeElement('text:p', $formatted); break; case DataType::TYPE_INLINE: @@ -286,10 +425,47 @@ class Content extends WriterPart break; } + + // Write drawing if it belongs to this cell + if (isset($drawingsByColumn[$column])) { + $drawingData = $drawingsByColumn[$column]; + $this->drawingWriter->writeDrawingFrame( + $objWriter, + $drawingData['drawing'], + $drawingData['index'], + $sheet->getTitle() + ); + } + Comment::write($objWriter, $cell); $objWriter->endElement(); $prevColumn = $column; } + + // Write any remaining drawings that don't have cells. + // I don't know how to trigger the if condition below, + // and would prefer to eliminate the code and have + // someone raise an issue rather than have them + // execute unpredictable code. + + /* + foreach ($drawingsByColumn as $column => $drawingData) { + if ($column > $prevColumn) { + $this->writeCellSpan($objWriter, $column, $prevColumn); + $objWriter->startElement('table:table-cell'); + + $this->drawingWriter->writeDrawingFrame( + $objWriter, + $drawingData['drawing'], + $drawingData['index'], + $sheet->getTitle() + ); + + $objWriter->endElement(); + $prevColumn = $column; + } + } + */ } /** @@ -307,12 +483,15 @@ class Content extends WriterPart } } + /** @var array */ + public array $additionalNumberFormats = []; + /** * Write XF cell styles. */ private function writeXfStyles(XMLWriter $writer, Spreadsheet $spreadsheet): void { - $styleWriter = new Style($writer); + $styleWriter = new Style($writer, $this->additionalNumberFormats); $sheetCount = $spreadsheet->getSheetCount(); for ($i = 0; $i < $sheetCount; ++$i) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Drawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Drawing.php new file mode 100644 index 0000000..f93269e --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Drawing.php @@ -0,0 +1,153 @@ + Image files to be added to ODS */ + private array $imageFiles = []; + + /** @var int Counter for image numbering */ + private int $imageCounter = 0; + + /** + * Required by WriterPart abstract class, but unused. + * + * @codeCoverageIgnore + */ + public function write(): string + { + return ''; + } + + /** + * Reset the drawing writer state. + */ + public function reset(): void + { + $this->imageFiles = []; + $this->imageCounter = 0; + } + + /** + * Collect all drawings from a worksheet and prepare image files. + * + * @return array Array of drawing image files [path => content] + */ + public function collectDrawings(Worksheet $worksheet): array + { + $drawingCollection = $worksheet->getDrawingCollection(); + + if (count($drawingCollection) === 0) { + return []; + } + + $drawings = []; + foreach ($drawingCollection as $drawing) { + ++$this->imageCounter; + + if ($drawing instanceof WorksheetDrawing) { + $filename = $drawing->getPath(); + $filename = str_replace('\\', '/', $filename); + $imageContents = @file_get_contents($filename); + if ($imageContents !== false) { + $extension = $drawing->getExtension(); + $imagePath = "Pictures/image{$this->imageCounter}.{$extension}"; + $drawings[$imagePath] = $imageContents; + $this->imageFiles[$imagePath] = $imageContents; + } + } elseif ($drawing instanceof MemoryDrawing) { + $renderingFunction = $drawing->getRenderingFunction(); + $extension = 'png'; + if ($renderingFunction === MemoryDrawing::RENDERING_JPEG) { + $extension = 'jpg'; + } elseif ($renderingFunction === MemoryDrawing::RENDERING_GIF) { + $extension = 'gif'; + } + + $gdImage = $drawing->getImageResource(); + if ($gdImage !== null) { + ob_start(); + if ($renderingFunction === MemoryDrawing::RENDERING_JPEG) { + imagejpeg($gdImage); + } elseif ($renderingFunction === MemoryDrawing::RENDERING_GIF) { + imagegif($gdImage); + } else { + imagepng($gdImage); + } + $imageContents = ob_get_clean(); + + if ($imageContents !== false && $imageContents !== '') { + $imagePath = "Pictures/image{$this->imageCounter}.{$extension}"; + $drawings[$imagePath] = $imageContents; + $this->imageFiles[$imagePath] = $imageContents; + } + } + } + } + + return $drawings; + } + + /** + * Get all collected image files. + * + * @return array + */ + public function getAllImageFiles(): array + { + return $this->imageFiles; + } + + /** + * Write drawing frames in content.xml within a table cell. + */ + public function writeDrawingFrame(XMLWriter $objWriter, BaseDrawing $drawing, int $imageIndex, string $sheetTitle): void + { + $extension = 'png'; + if ($drawing instanceof WorksheetDrawing) { + $extension = $drawing->getExtension(); + } elseif ($drawing instanceof MemoryDrawing) { + $renderingFunction = $drawing->getRenderingFunction(); + if ($renderingFunction === MemoryDrawing::RENDERING_JPEG) { + $extension = 'jpg'; + } elseif ($renderingFunction === MemoryDrawing::RENDERING_GIF) { + $extension = 'gif'; + } + } + + // Calculate dimensions in cm (ODS uses cm, PhpSpreadsheet uses pixels) + // 1 pixel = 0.0264583333 cm (at 96 DPI) + $widthCm = round($drawing->getWidth() / Dimension::ABSOLUTE_UNITS[Dimension::UOM_CENTIMETERS], 4); + $heightCm = round($drawing->getHeight() / Dimension::ABSOLUTE_UNITS[Dimension::UOM_CENTIMETERS], 4); + + // Write draw:frame + $objWriter->startElement('draw:frame'); + $objWriter->writeAttribute('draw:name', $drawing->getName() ?: "Image {$imageIndex}"); + $objWriter->writeAttribute('draw:z-index', (string) $imageIndex); + $objWriter->writeAttribute('svg:width', "{$widthCm}cm"); + $objWriter->writeAttribute('svg:height', "{$heightCm}cm"); + $objWriter->writeAttribute('draw:style-name', 'gr1'); + + // Write draw:image + $objWriter->startElement('draw:image'); + $objWriter->writeAttribute('xlink:href', "Pictures/image{$imageIndex}.{$extension}"); + $objWriter->writeAttribute('xlink:type', 'simple'); + $objWriter->writeAttribute('xlink:show', 'embed'); + $objWriter->writeAttribute('xlink:actuate', 'onLoad'); + + // Empty text element required by ODS spec + $objWriter->startElement('text:p'); + $objWriter->endElement(); // text:p + + $objWriter->endElement(); // draw:image + $objWriter->endElement(); // draw:frame + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Formula.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Formula.php index 0e470b6..ff38182 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Formula.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Formula.php @@ -24,9 +24,21 @@ class Formula public function convertFormula(string $formula, string $worksheetName = ''): string { - $formula = $this->convertCellReferences($formula, $worksheetName); - $formula = $this->convertDefinedNames($formula); - $formula = $this->convertFunctionNames($formula); + // Convert only outside of string literals, so that the text in + // ="THIS IS E1" is left alone rather than treated as a cell reference. + $converted = ''; + foreach ($this->splitOnStringLiterals($formula) as $index => $part) { + if (1 === $index % 2) { // the captured string literal + $converted .= $part; + + continue; + } + + $part = $this->convertCellReferences($part, $worksheetName); + $part = $this->convertDefinedNames($part); + $converted .= $this->convertFunctionNames($part); + } + $formula = $converted; if (!str_starts_with($formula, '=')) { $formula = '=' . $formula; @@ -35,6 +47,25 @@ class Formula return 'of:' . $formula; } + /** + * Splits a formula into alternating segments outside and inside string literals, + * with the literals themselves at the odd offsets. + * + * A range such as A1:B2 can never be interrupted by a string literal, so every + * reference stays within a single segment and keeps its surrounding context. + * + * @return string[] + */ + private function splitOnStringLiterals(string $formula): array + { + return Preg::split( + '/(' . Calculation::CALCULATION_REGEXP_STRING . ')/ui', + $formula, + -1, + PREG_SPLIT_DELIM_CAPTURE + ); + } + private function convertDefinedNames(string $formula): string { $splitCount = Preg::matchAllWithOffsets( @@ -76,7 +107,7 @@ class Formula $columns = $splitRanges[6]; $rows = $splitRanges[7]; - // Replace any commas in the formula with semi-colons for Ods + // Replace any commas in the formula with semicolons for Ods // If by chance there are commas in worksheet names, then they will be "fixed" again in the loop // because we've already extracted worksheet names with our Preg::matchAllWithOffsets() $formula = str_replace(',', ';', $formula); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/MetaInf.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/MetaInf.php index f3f0d5f..bef9ed4 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/MetaInf.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/MetaInf.php @@ -6,6 +6,15 @@ use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; class MetaInf extends WriterPart { + /** @var array */ + private array $imageFiles = []; + + /** @param array $imageFiles */ + public function setImageFiles(array $imageFiles): void + { + $this->imageFiles = $imageFiles; + } + /** * Write META-INF/manifest.xml to XML format. * @@ -53,6 +62,25 @@ class MetaInf extends WriterPart $objWriter->writeAttribute('manifest:full-path', 'styles.xml'); $objWriter->writeAttribute('manifest:media-type', 'text/xml'); $objWriter->endElement(); + + // Add manifest entries for all image files + foreach ($this->imageFiles as $imagePath => $imageContent) { + $objWriter->startElement('manifest:file-entry'); + $objWriter->writeAttribute('manifest:full-path', $imagePath); + + // Determine MIME type based on extension + $extension = strtolower(pathinfo($imagePath, PATHINFO_EXTENSION)); + $mimeType = match ($extension) { + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + default => 'application/octet-stream', + }; + + $objWriter->writeAttribute('manifest:media-type', $mimeType); + $objWriter->endElement(); // manifest:file-entry + } + $objWriter->endElement(); return $objWriter->getData(); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Styles.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Styles.php index 6bfdfb1..c5c0f9d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Styles.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Ods/Styles.php @@ -55,7 +55,21 @@ class Styles extends WriterPart $objWriter->writeAttribute('office:version', '1.2'); $objWriter->writeElement('office:font-face-decls'); - $objWriter->writeElement('office:styles'); + $objWriter->startElement('office:styles'); + $defaultStyle = $this->getParentWriter() + ->getSpreadsheet() + ->getDefaultStyle(); + $objWriter->startElement('style:default-style'); + $objWriter->writeAttribute('style:family', 'table-cell'); + $writer2 = new Cell\Style($objWriter); + $writer2->writeTextProperties($defaultStyle); + $objWriter->endElement(); // style:default-style + $objWriter->startElement('style:style'); + $objWriter->writeAttribute('style:name', 'Default'); + $objWriter->writeAttribute('style:family', 'table-cell'); + $writer2->writeCellProperties($defaultStyle); + $objWriter->endElement(); // style:style 'Default' table-cell + $objWriter->endElement(); // office:styles $objWriter->startElement('office:automatic-styles'); $objWriter->startElement('style:page-layout'); $objWriter->writeAttribute('style:name', 'Mpm1'); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Dompdf.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Dompdf.php index bad31ce..0b30dd1 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Dompdf.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Dompdf.php @@ -49,10 +49,15 @@ class Dompdf extends Pdf $pdf->loadHtml($this->generateHTMLAll()); $pdf->render(); + $this->callPageScript($pdf); // Write to file fwrite($fileHandle, $pdf->output()); parent::restoreStateAfterSave(); } + + protected function callPageScript(\Dompdf\Dompdf $pdf): void + { + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php index 83d4986..ceb8285 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php @@ -96,17 +96,17 @@ class Mpdf extends Pdf parent::restoreStateAfterSave(); } - protected static int $temporaryVersionCheck = 80500; + protected static int $temporaryVersionCheck = 80600; /** - * Temporary handler for Php8.5 waiting for Dompdf release. + * Temporary handler for Php8.6 mb_regex_encoding deprecation. * * @codeCoverageIgnore */ public function specialErrorHandler(int $errno, string $errstr, string $filename, int $lineno): bool { if ($errno === E_DEPRECATED) { - if (preg_match('/Providing an empty string is deprecated/', $errstr) === 1) { + if (preg_match('/Function mb_\w+[(][)] is deprecated since 8[.]6/', $errstr) === 1) { return true; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php index 94cbaff..50e57f2 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Pdf/Tcpdf.php @@ -8,6 +8,10 @@ use PhpOffice\PhpSpreadsheet\Writer\Pdf; class Tcpdf extends Pdf { + protected bool $writeHeader = false; + + protected bool $writeFooter = false; + /** * Create a new PDF Writer instance. * @@ -66,8 +70,8 @@ class Tcpdf extends Pdf $pdf->SetMargins($printMargins->getLeft() * 72, $printMargins->getTop() * 72, $printMargins->getRight() * 72); $pdf->SetAutoPageBreak(true, $printMargins->getBottom() * 72); - $pdf->setPrintHeader(false); - $pdf->setPrintFooter(false); + $pdf->setPrintHeader($this->writeHeader); + $pdf->setPrintFooter($this->writeFooter); $pdf->AddPage(); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls.php index 3ef9bec..c17feed 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls.php @@ -128,7 +128,7 @@ class Xls extends BaseWriter $this->writerWorksheets[$i] = new Worksheet($this->strTotal, $this->strUnique, $this->strTable, $this->colors, $this->parser, $this->preCalculateFormulas, $this->spreadsheet->getSheet($i), $this->writerWorkbook); } - // build Escher objects. Escher objects for workbooks needs to be build before Escher object for workbook. + // build Escher objects. Escher objects for worksheets need to be built before Escher object for workbook. $this->buildWorksheetEschers(); $this->buildWorkbookEscher(); @@ -144,12 +144,15 @@ class Xls extends BaseWriter $this->writerWorkbook->addXfWriter($style, false); } - // add fonts from rich text eleemnts + // add fonts from rich text elements for ($i = 0; $i < $countSheets; ++$i) { foreach ($this->writerWorksheets[$i]->phpSheet->getCellCollection()->getCoordinates() as $coordinate) { /** @var Cell $cell */ $cell = $this->writerWorksheets[$i]->phpSheet->getCellCollection()->get($coordinate); $cVal = $cell->getValue(); + if ($cVal instanceof RichText && (string) $cVal === '') { + $cVal = ''; + } if ($cVal instanceof RichText) { $active = $this->spreadsheet->getActiveSheetIndex(); $sheet = $cell->getWorksheet(); @@ -339,7 +342,7 @@ class Xls extends BaseWriter while ($iInc <= $iNumColEnd) { ++$countShapes[$sheetIndex]; - // create an Drawing Object for the dropdown + // create a Drawing Object for the dropdown $oDrawing = new BaseDrawing(); // get the coordinates of drawing $cDrawing = Coordinate::stringFromColumnIndex($iInc) . $rangeBounds[0][1]; @@ -413,7 +416,7 @@ class Xls extends BaseWriter } ob_start(); - call_user_func($renderingFunction, $drawing->getImageResource()); // @phpstan-ignore-line + call_user_func($renderingFunction, $drawing->getImageResource()); // @phpstan-ignore argument.type (getImageResource may theoretically be null) $blipData = ob_get_contents(); ob_end_clean(); @@ -698,7 +701,7 @@ class Xls extends BaseWriter // value $dataProp .= 'Feuilles de calcul'; // vtUnalignedString - headingParts - // wType : 0x0003 = 32 bit signed integer + // wType : 0x0003 = 32-bit signed integer $dataProp .= pack('v', 0x0300); // padding $dataProp .= pack('v', 0x0000); @@ -791,7 +794,7 @@ class Xls extends BaseWriter private function writeSummaryProp(string $dataProp, int &$dataSection_NumProps, array &$dataSection, int $sumdata, int $typdata): void { if ($dataProp) { - $dataSection[] = [ + $dataSection[] = [ // @phpstan-ignore parameterByRef.type (I don't know what phpstan wants here) 'summary' => ['pack' => 'V', 'data' => $sumdata], 'offset' => ['pack' => 'V'], 'type' => ['pack' => 'V', 'data' => $typdata], // null-terminated string prepended by dword string length @@ -865,7 +868,7 @@ class Xls extends BaseWriter foreach ($dataSection as $dataProp) { /** @var array{data: array{data: string, length: int}, summary: array{pack: string, data: string}, offset: array{pack: string}, type: array{data: int, pack: string}} $dataProp */ // Summary - $dataSection_Summary .= pack($dataProp['summary']['pack'], $dataProp['summary']['data']); + $dataSection_Summary .= pack($dataProp['summary']['pack'], $dataProp['summary']['data']); // @phpstan-ignore varTag.nativeType (I don't know what phpstan wants) // Offset $dataSection_Summary .= pack($dataProp['offset']['pack'], $dataSection_Content_Offset); // DataType diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/BIFFwriter.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/BIFFwriter.php index 0b0965d..a909783 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/BIFFwriter.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/BIFFwriter.php @@ -4,39 +4,11 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xls; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; -// Original file header of PEAR::Spreadsheet_Excel_Writer_BIFFwriter (used as the base for this class): -// ----------------------------------------------------------------------------------------- -// * Module written/ported by Xavier Noguer -// * -// * The majority of this is _NOT_ my code. I simply ported it from the -// * PERL Spreadsheet::WriteExcel module. -// * -// * The author of the Spreadsheet::WriteExcel module is John McNamara -// * -// * -// * I _DO_ maintain this code, and John McNamara has nothing to do with the -// * porting of this code to PHP. Any questions directly related to this -// * class library should be directed to me. -// * -// * License Information: -// * -// * Spreadsheet_Excel_Writer: A library for generating Excel Spreadsheets -// * Copyright (c) 2002-2003 Xavier Noguer xnoguer@rezebra.com -// * -// * This library is free software; you can redistribute it and/or -// * modify it under the terms of the GNU Lesser General Public -// * License as published by the Free Software Foundation; either -// * version 2.1 of the License, or (at your option) any later version. -// * -// * This library is distributed in the hope that it will be useful, -// * but WITHOUT ANY WARRANTY; without even the implied warranty of -// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// * Lesser General Public License for more details. -// * -// * You should have received a copy of the GNU Lesser General Public -// * License along with this library; if not, write to the Free Software -// * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// */ +/** + * Based on PERL Spreadsheet::WriteExcel module (by John McNamara) + * Ported to PHP for PEAR::Spreadsheet_Excel_Writer_BIFFwriter (by Xavier Noguer) + * Relicensed under the MIT License by both authors. + */ class BIFFwriter { /** diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Parser.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Parser.php index 55a530e..791b271 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Parser.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Parser.php @@ -4,34 +4,16 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xls; use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet as PhpspreadsheetWorksheet; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; -// Original file header of PEAR::Spreadsheet_Excel_Writer_Parser (used as the base for this class): -// ----------------------------------------------------------------------------------------- -// * Class for parsing Excel formulas -// * -// * License Information: -// * -// * Spreadsheet_Excel_Writer: A library for generating Excel Spreadsheets -// * Copyright (c) 2002-2003 Xavier Noguer xnoguer@rezebra.com -// * -// * This library is free software; you can redistribute it and/or -// * modify it under the terms of the GNU Lesser General Public -// * License as published by the Free Software Foundation; either -// * version 2.1 of the License, or (at your option) any later version. -// * -// * This library is distributed in the hope that it will be useful, -// * but WITHOUT ANY WARRANTY; without even the implied warranty of -// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// * Lesser General Public License for more details. -// * -// * You should have received a copy of the GNU Lesser General Public -// * License along with this library; if not, write to the Free Software -// * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// */ +/** + * Based on PEAR::Spreadsheet_Excel_Writer_Parser (by Xavier Noguer) + * Relicensed under the MIT License by the author. + */ class Parser { /** Constants */ @@ -219,7 +201,7 @@ class Parser * The following hash was generated by "function_locale.pl" in the distro. * Refer to function_locale.pl for non-English function names. * - * The array elements are as follow: + * The array elements are as follows: * ptg: The Excel function ptg code. * args: The number of arguments that the function takes: * >=0 is a fixed number of arguments. @@ -770,7 +752,7 @@ class Parser }; } - private bool $tryDefinedName = false; + protected bool $tryDefinedName = false; private function convertDefinedName(string $name): string { @@ -901,10 +883,10 @@ class Parser { $cell = strtoupper($cell); [$row, $col, $row_rel, $col_rel] = $this->cellToRowcol($cell); - if ($col >= 256) { + if ($col >= AddressRange::MAX_COLUMN_INT_XLS) { throw new WriterException("Column in: $cell greater than 255"); } - if ($row >= 65536) { + if ($row >= AddressRange::MAX_ROW_XLS) { throw new WriterException("Row in: $cell greater than 65536 "); } @@ -943,10 +925,10 @@ class Parser --$row2; // Trick poor inocent Excel $col1 = 0; - $col2 = 65535; // FIXME: maximum possible value for Excel 5 (change this!!!) + $col2 = AddressRange::MAX_ROW_XLS - 1; // FIXME: maximum possible value for Excel 5 (change this!!!) // FIXME: this changes for BIFF8 - if (($row1 >= 65536) || ($row2 >= 65536)) { + if (($row1 >= AddressRange::MAX_ROW_XLS) || ($row2 >= AddressRange::MAX_ROW_XLS)) { throw new WriterException("Row in: $range greater than 65536 "); } @@ -1262,7 +1244,7 @@ class Parser } /** - * It parses a expression. It assumes the following rule: + * It parses an expression. It assumes the following rule: * Expr -> Term [("+" | "-") Term] * -> "string" * -> "-" Term : Negative value @@ -1612,7 +1594,7 @@ class Parser $converted_tree = $this->toReversePolish($tree['left']); $polish .= $converted_tree; } elseif ($tree['left'] != '') { // It's a final node - $converted_tree = $this->convert($tree['left']); //* @phpstan-ignore-line + $converted_tree = $this->convert($tree['left']); //* @phpstan-ignore argument.type (tree[left] should be an array bug phpstan considers it mixed) $polish .= $converted_tree; } if (is_array($tree['right'])) { @@ -1622,7 +1604,7 @@ class Parser $converted_tree = $this->convert(StringHelper::convertToString($tree['right'])); $polish .= $converted_tree; } - // if it's a function convert it here (so we can set it's arguments) + // if it's a function convert it here (so we can set its arguments) /** @var string */ $treeValueString = $tree['value']; if ( @@ -1637,7 +1619,7 @@ class Parser ) { // left subtree for a function is always an array. if ($tree['left'] != '') { - $left_tree = $this->toReversePolish($tree['left']); //* @phpstan-ignore-line + $left_tree = $this->toReversePolish($tree['left']); //* @phpstan-ignore argument.type (tree[left] should be an array bug phpstan considers it mixed) } else { $left_tree = ''; } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellBorder.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellBorder.php index 6bdd20b..f2a4da7 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellBorder.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellBorder.php @@ -6,10 +6,7 @@ use PhpOffice\PhpSpreadsheet\Style\Border; class CellBorder { - /** - * @var array - */ - protected static array $styleMap = [ + protected const BORDER_MAP = [ Border::BORDER_NONE => 0x00, Border::BORDER_THIN => 0x01, Border::BORDER_MEDIUM => 0x02, @@ -31,10 +28,6 @@ class CellBorder { $borderStyle = $border->getBorderStyle(); - if (array_key_exists($borderStyle, self::$styleMap)) { - return self::$styleMap[$borderStyle]; - } - - return self::$styleMap[Border::BORDER_NONE]; + return self::BORDER_MAP[$borderStyle] ?? self::BORDER_MAP[Border::BORDER_NONE]; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellFill.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellFill.php index a0a932a..2038d36 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellFill.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Style/CellFill.php @@ -6,10 +6,7 @@ use PhpOffice\PhpSpreadsheet\Style\Fill; class CellFill { - /** - * @var array - */ - protected static array $fillStyleMap = [ + protected const FILL_MAP = [ Fill::FILL_NONE => 0x00, Fill::FILL_SOLID => 0x01, Fill::FILL_PATTERN_MEDIUMGRAY => 0x02, @@ -35,12 +32,8 @@ class CellFill public static function style(Fill $fill): int { - $fillStyle = $fill->getFillType(); + $fillStyle = (string) $fill->getFillType(); - if (is_string($fillStyle) && array_key_exists($fillStyle, self::$fillStyleMap)) { - return self::$fillStyleMap[$fillStyle]; - } - - return self::$fillStyleMap[Fill::FILL_NONE]; + return self::FILL_MAP[$fillStyle] ?? self::FILL_MAP[Fill::FILL_NONE]; } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Workbook.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Workbook.php index 4e0bf3e..d159cab 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Workbook.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Workbook.php @@ -13,40 +13,11 @@ use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Style\Style; -// Original file header of PEAR::Spreadsheet_Excel_Writer_Workbook (used as the base for this class): -// ----------------------------------------------------------------------------------------- -// /* -// * Module written/ported by Xavier Noguer -// * -// * The majority of this is _NOT_ my code. I simply ported it from the -// * PERL Spreadsheet::WriteExcel module. -// * -// * The author of the Spreadsheet::WriteExcel module is John McNamara -// * -// * -// * I _DO_ maintain this code, and John McNamara has nothing to do with the -// * porting of this code to PHP. Any questions directly related to this -// * class library should be directed to me. -// * -// * License Information: -// * -// * Spreadsheet_Excel_Writer: A library for generating Excel Spreadsheets -// * Copyright (c) 2002-2003 Xavier Noguer xnoguer@rezebra.com -// * -// * This library is free software; you can redistribute it and/or -// * modify it under the terms of the GNU Lesser General Public -// * License as published by the Free Software Foundation; either -// * version 2.1 of the License, or (at your option) any later version. -// * -// * This library is distributed in the hope that it will be useful, -// * but WITHOUT ANY WARRANTY; without even the implied warranty of -// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// * Lesser General Public License for more details. -// * -// * You should have received a copy of the GNU Lesser General Public -// * License along with this library; if not, write to the Free Software -// * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// */ +/** + * Based on PERL Spreadsheet::WriteExcel module (by John McNamara) + * Ported to PHP for PEAR::Spreadsheet_Excel_Writer_Workbook (by Xavier Noguer) + * Relicensed under the MIT License by both authors. + */ class Workbook extends BIFFwriter { /** @@ -551,7 +522,7 @@ class Workbook extends BIFFwriter if (empty($worksheet)) { if (($offset === 0) || ($definedRange[$offset - 1] !== ':')) { // We should have a worksheet - $worksheet = $definedName->getWorksheet() ? $definedName->getWorksheet()->getTitle() : null; + $worksheet = $definedName->getWorksheet()?->getTitle(); } } else { $worksheet = str_replace("''", "'", trim($worksheet, "'")); @@ -1116,7 +1087,7 @@ class Workbook extends BIFFwriter // combine into one chunk with all the blocks SST, CONTINUE,... $chunk = ''; foreach ($recordDatas as $i => $recordData) { - // first block should have the SST record header, remaing should have CONTINUE header + // first block should have the SST record header, remaining should have CONTINUE header $record = ($i == 0) ? 0x00FC : 0x003C; $header = pack('vv', $record, strlen($recordData)); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Worksheet.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Worksheet.php index 810abe9..6da5a81 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Worksheet.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Worksheet.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xls; use Composer\Pcre\Preg; use GdImage; +use PhpOffice\PhpSpreadsheet\Cell\AddressRange; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; @@ -20,40 +21,11 @@ use PhpOffice\PhpSpreadsheet\Worksheet\PageSetup; use PhpOffice\PhpSpreadsheet\Worksheet\SheetView; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; -// Original file header of PEAR::Spreadsheet_Excel_Writer_Worksheet (used as the base for this class): -// ----------------------------------------------------------------------------------------- -// /* -// * Module written/ported by Xavier Noguer -// * -// * The majority of this is _NOT_ my code. I simply ported it from the -// * PERL Spreadsheet::WriteExcel module. -// * -// * The author of the Spreadsheet::WriteExcel module is John McNamara -// * -// * -// * I _DO_ maintain this code, and John McNamara has nothing to do with the -// * porting of this code to PHP. Any questions directly related to this -// * class library should be directed to me. -// * -// * License Information: -// * -// * Spreadsheet_Excel_Writer: A library for generating Excel Spreadsheets -// * Copyright (c) 2002-2003 Xavier Noguer xnoguer@rezebra.com -// * -// * This library is free software; you can redistribute it and/or -// * modify it under the terms of the GNU Lesser General Public -// * License as published by the Free Software Foundation; either -// * version 2.1 of the License, or (at your option) any later version. -// * -// * This library is distributed in the hope that it will be useful, -// * but WITHOUT ANY WARRANTY; without even the implied warranty of -// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// * Lesser General Public License for more details. -// * -// * You should have received a copy of the GNU Lesser General Public -// * License along with this library; if not, write to the Free Software -// * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// */ +/** + * Based on PERL Spreadsheet::WriteExcel module (by John McNamara) + * Ported to PHP for PEAR::Spreadsheet_Excel_Writer_Worksheet (by Xavier Noguer) + * Relicensed under the MIT License by both authors. + */ class Worksheet extends BIFFwriter { private static int $always0 = 0; @@ -89,15 +61,13 @@ class Worksheet extends BIFFwriter /** * Whether to have outline summary below. - * Not currently used. */ - private bool $outlineBelow; //* @phpstan-ignore-line + private bool $outlineBelow; //* @phpstan-ignore property.onlyWritten (not currently used) /** * Whether to have outline summary at the right. - * Not currently used. */ - private bool $outlineRight; //* @phpstan-ignore-line + private bool $outlineRight; //* @phpstan-ignore property.onlyWritten (not currently used) /** * Reference to the total number of strings in the workbook. @@ -214,7 +184,7 @@ class Worksheet extends BIFFwriter // BIFF8 DIMENSIONS record requires 0-based indices for both rows and columns // Row methods return 1-based values (Excel UI), so subtract 1 to convert to 0-based $this->firstRowIndex = $minR - 1; - $this->lastRowIndex = ($maxR > 65536) ? 65535 : ($maxR - 1); + $this->lastRowIndex = ($maxR > AddressRange::MAX_ROW_XLS) ? (AddressRange::MAX_ROW_XLS - 1) : ($maxR - 1); // Column methods return 1-based values (columnIndexFromString('A') = 1), so subtract 1 $this->firstColumnIndex = Coordinate::columnIndexFromString($minC) - 1; @@ -282,7 +252,7 @@ class Worksheet extends BIFFwriter // $lastcol last column on the range // $width width to set // $xfIndex The optional cell style Xf index to apply to the columns - // $hidden The optional hidden atribute + // $hidden The optional hidden attribute // $level The optional outline level $this->columnInfo[] = [$i, $i, $width, $xfIndex, $hidden, $level]; } @@ -369,6 +339,9 @@ class Worksheet extends BIFFwriter $xfIndex = $cell->getXfIndex() + 15; // there are 15 cell style Xfs $cVal = $cell->getValue(); + if ($cVal instanceof RichText && (string) $cVal === '') { + $cVal = ''; + } if ($cVal instanceof RichText) { $arrcRun = []; $str_pos = 0; @@ -428,7 +401,7 @@ class Worksheet extends BIFFwriter match ($calctype) { 'integer', 'double' => $this->writeNumber($row, $column, is_numeric($calculatedValue) ? ((float) $calculatedValue) : 0.0, $xfIndex), 'string' => $this->writeString($row, $column, $calculatedValueString, $xfIndex), - 'boolean' => $this->writeBoolErr($row, $column, (int) $calculatedValueString, 0, $xfIndex), + 'boolean' => $this->writeBoolErr($row, $column, (int) $calculatedValue, 0, $xfIndex), // @phpstan-ignore cast.int (calculatedValue should be bool but phpstan considers it mixed) default => $this->writeString($row, $column, $cell->getValueString(), $xfIndex), }; } @@ -480,6 +453,9 @@ class Worksheet extends BIFFwriter [$column, $row] = Coordinate::indexesFromString($coordinate); $url = $hyperlink->getUrl(); + if ($url === '') { + continue; + } if ($url[0] === '#') { $url = "internal:$url"; } elseif (str_starts_with($url, 'sheet://')) { @@ -513,9 +489,14 @@ class Worksheet extends BIFFwriter $this->storeEof(); } - public const MAX_XLS_COLUMN = 256; - public const MAX_XLS_COLUMN_STRING = 'IV'; - public const MAX_XLS_ROW = 65536; + /** @deprecated 5.6.0 Use AddressRange::MAX_COLUMN_INT_XLS */ + public const MAX_XLS_COLUMN = AddressRange::MAX_COLUMN_INT_XLS; + + /** @deprecated 5.6.0 Use AddressRange::MAX_COLUMN_XLS */ + public const MAX_XLS_COLUMN_STRING = AddressRange::MAX_COLUMN_XLS; + + /** @deprecated 5.6.0 Use AddressRange::MAX_ROW_XLS */ + public const MAX_XLS_ROW = AddressRange::MAX_ROW_XLS; private static function limitRange(string $exploded): string { @@ -523,13 +504,13 @@ class Worksheet extends BIFFwriter $ranges = Coordinate::getRangeBoundaries($exploded); $firstCol = Coordinate::columnIndexFromString($ranges[0][0]); $firstRow = (int) $ranges[0][1]; - if ($firstCol <= self::MAX_XLS_COLUMN && $firstRow <= self::MAX_XLS_ROW) { + if ($firstCol <= AddressRange::MAX_COLUMN_INT_XLS && $firstRow <= AddressRange::MAX_ROW_XLS) { $retVal = $exploded; if (str_contains($exploded, ':')) { $lastCol = Coordinate::columnIndexFromString($ranges[1][0]); - $ranges[1][1] = min(self::MAX_XLS_ROW, (int) $ranges[1][1]); - if ($lastCol > self::MAX_XLS_COLUMN) { - $ranges[1][0] = self::MAX_XLS_COLUMN_STRING; + $ranges[1][1] = min(AddressRange::MAX_ROW_XLS, (int) $ranges[1][1]); + if ($lastCol > AddressRange::MAX_COLUMN_INT_XLS) { + $ranges[1][0] = AddressRange::MAX_COLUMN_XLS; } $retVal = "{$ranges[0][0]}{$ranges[0][1]}:{$ranges[1][0]}{$ranges[1][1]}"; } @@ -604,9 +585,9 @@ class Worksheet extends BIFFwriter $lastCell = $explodes[1]; } if (ctype_alpha($lastCell)) { - $lastCell .= (string) self::MAX_XLS_ROW; + $lastCell .= (string) AddressRange::MAX_ROW_XLS; } elseif (ctype_digit($lastCell)) { - $lastCell = self::MAX_XLS_COLUMN_STRING . $lastCell; + $lastCell = AddressRange::MAX_COLUMN_XLS . $lastCell; } $firstCellCoordinates = Coordinate::indexesFromString($firstCell); // e.g. [0, 1] @@ -1333,8 +1314,8 @@ class Worksheet extends BIFFwriter */ private function writeColinfo(array $col_array): void { - $colFirst = $col_array[0] ?? null; - $colLast = $col_array[1] ?? null; + $colFirst = $col_array[0] ?? null; //* @phpstan-ignore nullCoalesce.unnecessary (I think Phpstan is wrong) + $colLast = $col_array[1] ?? null; //* @phpstan-ignore nullCoalesce.unnecessary (I think Phpstan is wrong) $coldx = $col_array[2] ?? 8.43; $xfIndex = $col_array[3] ?? 15; $grbit = $col_array[4] ?? 0; @@ -2174,6 +2155,8 @@ class Worksheet extends BIFFwriter /** * Insert a 24bit bitmap image in a worksheet. * + * @deprecated 5.5.0 No replacement. + * * @param int $row The row we are going to insert the bitmap into * @param int $col The column we are going to insert the bitmap into * @param GdImage|string $bitmap The bitmap filename or GD-image resource @@ -2181,6 +2164,8 @@ class Worksheet extends BIFFwriter * @param int $y the vertical position (offset) of the image inside the cell * @param float $scale_x The horizontal scale * @param float $scale_y The vertical scale + * + * @codeCoverageIgnore */ public function insertBitmap(int $row, int $col, GdImage|string $bitmap, int $x = 0, int $y = 0, float $scale_x = 1, float $scale_y = 1): void { @@ -2188,10 +2173,6 @@ class Worksheet extends BIFFwriter ? $this->processBitmapGd($bitmap) : $this->processBitmap($bitmap); [$width, $height, $size, $data] = $bitmap_array; - /** @var int $width */ - /** @var int $height */ - /** @var int $size */ - /** @var string $data */ // Scale the frame of the image. $width *= $scale_x; @@ -2254,12 +2235,16 @@ class Worksheet extends BIFFwriter * The SDK incorrectly states that the height should be expressed as a * percentage of 1024. * + * @deprecated 5.5.0 No replacement. + * * @param int $col_start Col containing upper left corner of object * @param int $row_start Row containing top left corner of object * @param int $x1 Distance to left side of object * @param int $y1 Distance to top of object * @param int $width Width of image frame * @param int $height Height of image frame + * + * @codeCoverageIgnore */ public function positionImage(int $col_start, int $row_start, int $x1, int $y1, int $width, int $height): void { @@ -2316,9 +2301,11 @@ class Worksheet extends BIFFwriter } /** - * Store the OBJ record that precedes an IMDATA record. This could be generalise + * Store the OBJ record that precedes an IMDATA record. This could be generalised * to support other Excel objects. * + * @deprecated 5.5.0 No replacement. + * * @param int $colL Column containing upper left corner of object * @param int $dxL Distance from left side of cell * @param int $rwT Row containing top left corner of object @@ -2327,6 +2314,8 @@ class Worksheet extends BIFFwriter * @param int $dxR Distance from right of cell * @param int $rwB Row containing bottom right corner of object * @param int $dyB Distance from bottom of cell + * + * @codeCoverageIgnore */ private function writeObjPicture(int $colL, int $dxL, int $rwT, int|float $dyT, int $colR, int $dxR, int $rwB, int $dyB): void { @@ -2396,9 +2385,13 @@ class Worksheet extends BIFFwriter /** * Convert a GD-image into the internal format. * + * @deprecated 5.5.0 No replacement. + * * @param GdImage $image The image to process * - * @return array{0: float, 1: float, 2: int, 3: string} Data and properties of the bitmap + * @return array{0: int, 1: int, 2: int, 3: string} Data and properties of the bitmap + * + * @codeCoverageIgnore */ public function processBitmapGd(GdImage $image): array { @@ -2430,9 +2423,13 @@ class Worksheet extends BIFFwriter * This is described in BITMAPCOREHEADER and BITMAPCOREINFO structures in the * MSDN library. * + * @deprecated 5.5.0 No replacement. + * * @param string $bitmap The bitmap to process * - * @return mixed[] Array with data and properties of the bitmap + * @return array{0: int, 1: int, 2: int, 3: string} Data and properties of the bitmap + * + * @codeCoverageIgnore */ public function processBitmap(string $bitmap): array { @@ -2475,7 +2472,9 @@ class Worksheet extends BIFFwriter // Read and remove the bitmap width and height. Verify the sizes. $width_and_height = unpack('V2', substr($data, 0, 8)) ?: []; + /** @var int */ $width = $width_and_height[1]; + /** @var int */ $height = $width_and_height[2]; $data = substr($data, 8); if ($width > 0xFFFF) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Xf.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Xf.php index bda33ed..695f527 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Xf.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xls/Xf.php @@ -10,40 +10,11 @@ use PhpOffice\PhpSpreadsheet\Writer\Xls\Style\CellAlignment; use PhpOffice\PhpSpreadsheet\Writer\Xls\Style\CellBorder; use PhpOffice\PhpSpreadsheet\Writer\Xls\Style\CellFill; -// Original file header of PEAR::Spreadsheet_Excel_Writer_Format (used as the base for this class): -// ----------------------------------------------------------------------------------------- -// /* -// * Module written/ported by Xavier Noguer -// * -// * The majority of this is _NOT_ my code. I simply ported it from the -// * PERL Spreadsheet::WriteExcel module. -// * -// * The author of the Spreadsheet::WriteExcel module is John McNamara -// * -// * -// * I _DO_ maintain this code, and John McNamara has nothing to do with the -// * porting of this code to PHP. Any questions directly related to this -// * class library should be directed to me. -// * -// * License Information: -// * -// * Spreadsheet_Excel_Writer: A library for generating Excel Spreadsheets -// * Copyright (c) 2002-2003 Xavier Noguer xnoguer@rezebra.com -// * -// * This library is free software; you can redistribute it and/or -// * modify it under the terms of the GNU Lesser General Public -// * License as published by the Free Software Foundation; either -// * version 2.1 of the License, or (at your option) any later version. -// * -// * This library is distributed in the hope that it will be useful, -// * but WITHOUT ANY WARRANTY; without even the implied warranty of -// * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU -// * Lesser General Public License for more details. -// * -// * You should have received a copy of the GNU Lesser General Public -// * License along with this library; if not, write to the Free Software -// * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA -// */ +/** + * Based on PERL PERL Spreadsheet::WriteExcel module (by John McNamara) + * Ported to PHP for PEAR::Spreadsheet_Excel_Writer_Format (by Xavier Noguer) + * Relicensed under the MIT License by both authors. + */ class Xf { /** @@ -219,7 +190,7 @@ class Xf $header = pack('vv', $record, $length); - //BIFF8 options: identation, shrinkToFit and text direction + //BIFF8 options: indentation, shrinkToFit and text direction $biff8_options = $this->style->getAlignment()->getIndent() & 15; $biff8_options |= (int) $this->style->getAlignment()->getShrinkToFit() << 4; $biff8_options |= $this->style->getAlignment()->getReadOrder() << 6; diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx.php index a38c930..36f1b79 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx.php @@ -6,6 +6,8 @@ use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Calculation\Functions; use PhpOffice\PhpSpreadsheet\HashTable; use PhpOffice\PhpSpreadsheet\RichText\RichText; +use PhpOffice\PhpSpreadsheet\Shared\File; +use PhpOffice\PhpSpreadsheet\Shared\Xlsx\AgileEncryption; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Conditional; @@ -21,9 +23,11 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Comments; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\ContentTypes; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\DocProps; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Drawing; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\PivotTable as PivotTableWriter; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Rels; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\RelsRibbon; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\RelsVBA; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\RichDataDrawing; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\StringTable; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Style; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Table; @@ -36,6 +40,15 @@ use ZipStream\ZipStream; class Xlsx extends BaseWriter { + /** Password used to encrypt the written XLSX package; an empty password disables encryption. */ + private string $encryptionPassword = ''; + + private int $encryptionKeyBits = 256; + + private string $encryptionHashAlgorithm = 'SHA512'; + + private int $encryptionSpinCount = 100000; + /** * Office2003 compatibility. */ @@ -53,6 +66,13 @@ class Xlsx extends BaseWriter */ private array $stringTable = []; + /** + * Generated pivot table parts, keyed by their archive path. + * + * @var array + */ + private array $pivotTableContent = []; + /** * Private unique Conditional HashTable. * @@ -155,6 +175,7 @@ class Xlsx extends BaseWriter { // Assign PhpSpreadsheet $this->setSpreadsheet($spreadsheet); + $spreadsheet->setUsesCheckboxStyle(); $this->writerPartChart = new Chart($this); $this->writerPartComments = new Comments($this); @@ -306,6 +327,11 @@ class Xlsx extends BaseWriter */ public function save($filename, int $flags = 0): void { + if ($this->encryptionPassword !== '') { + $this->saveEncrypted($filename, $flags); + + return; + } $this->processFlags($flags); $this->determineUseDynamicArrays(); @@ -324,17 +350,39 @@ class Xlsx extends BaseWriter // Create styles dictionaries $this->createStyleDictionaries(); + // Generate OOXML parts for any in-memory pivot tables and register them + // in the unparsed loaded data, so the relationship, workbook and + // content-type writers emit the correct wiring for them. + $this->pivotTableContent = $this->prepareGeneratedPivotTables(); + // Create drawing dictionary $this->drawingHashTable->addFromSource($this->getWriterPartDrawing()->allDrawings($this->spreadSheet)); - /** @var string[] */ + /** @var array */ $zipContent = []; + $richDataCount = 0; + + if ($this->spreadSheet->hasInCellDrawings()) { + $richDataDrawing = new RichDataDrawing(); + $richDataFiles = $richDataDrawing->generateFiles($this->spreadSheet); + $richDataCount = count($richDataDrawing->getDrawings()); + + // Add all Rich Data files to ZIP + foreach ($richDataFiles as $path => $content) { + $zipContent[$path] = $content; + } + } + // Add [Content_Types].xml to ZIP file $zipContent['[Content_Types].xml'] = $this->getWriterPartContentTypes()->writeContentTypes($this->spreadSheet, $this->includeCharts); - $metadataData = (new Xlsx\Metadata($this))->writeMetadata(); + $metadataData = (new Xlsx\Metadata($this))->writeMetadata($richDataCount); if ($metadataData !== '') { $zipContent['xl/metadata.xml'] = $metadataData; } + $propertyBagData = (new Xlsx\FeaturePropertyBag($this))->writeFeaturePropertyBag($this->spreadSheet); + if ($propertyBagData !== '') { + $zipContent['xl/featurePropertyBag/featurePropertyBag.xml'] = $propertyBagData; + } //if hasMacros, add the vbaProject.bin file, Certificate file(if exists) if ($this->spreadSheet->hasMacros()) { @@ -412,7 +460,7 @@ class Xlsx extends BaseWriter // Add worksheet relationships (drawings, ...) for ($i = 0; $i < $this->spreadSheet->getSheetCount(); ++$i) { // Add relationships - /** @var string[] $zipContent */ + /** @var array $zipContent */ $zipContent['xl/worksheets/_rels/sheet' . ($i + 1) . '.xml.rels'] = $this->getWriterPartRels()->writeWorksheetRelationships($this->spreadSheet->getSheet($i), ($i + 1), $this->includeCharts, $tableRef1, $zipContent); // Add unparsedLoadedData @@ -437,7 +485,9 @@ class Xlsx extends BaseWriter } // Add drawing and image relationship parts - if (($drawingCount > 0) || ($chartCount > 0)) { + /** @var bool $hasPassThroughDrawing */ + $hasPassThroughDrawing = $unparsedSheet['drawingPassThroughEnabled'] ?? false; + if (($drawingCount > 0) || ($chartCount > 0) || $hasPassThroughDrawing) { // Drawing relationships $zipContent['xl/drawings/_rels/drawing' . ($i + 1) . '.xml.rels'] = $this->getWriterPartRels()->writeDrawingRelationships($this->spreadSheet->getSheet($i), $chartRef1, $this->includeCharts); @@ -558,6 +608,14 @@ class Xlsx extends BaseWriter } } + // Add pass-through media files (original media that may not be in the drawing collection) + /** @var array $zipContent */ + $this->addPassThroughMediaFiles($zipContent); + + // Add preserved pivot table parts (pivot tables, caches and their rels) + /** @var array $zipContent */ + $this->addPivotTableFiles($zipContent); + Functions::setReturnDateType($saveDateReturnType); Calculation::getInstance($this->spreadSheet)->getDebugLog()->setWriteDebugLog($saveDebugLog); @@ -578,6 +636,60 @@ class Xlsx extends BaseWriter $this->maybeCloseFileHandle(); } + /** + * Set the password required to open a written XLSX file. + */ + public function setEncryptionPassword(string $encryptionPassword): self + { + $this->encryptionPassword = $encryptionPassword; + + return $this; + } + + public function setEncryptionProfile(int $keyBits, string $hashAlgorithm, int $spinCount = 100000): self + { + $this->encryptionKeyBits = $keyBits; + $this->encryptionHashAlgorithm = $hashAlgorithm; + $this->encryptionSpinCount = $spinCount; + + return $this; + } + + /** + * Write the normal OOXML ZIP to a temporary file, then wrap it in an + * Agile-encrypted CFB container. + * + * @param resource|string $filename + */ + private function saveEncrypted($filename, int $flags): void + { + $temporaryFilename = File::temporaryFilename(); + $password = $this->encryptionPassword; + $this->encryptionPassword = ''; + + try { + $this->save($temporaryFilename, $flags); + $package = AgileEncryption::encryptFile($temporaryFilename, $password, $this->encryptionKeyBits, $this->encryptionHashAlgorithm, $this->encryptionSpinCount); + + try { + $this->openFileHandle($filename); + + try { + AgileEncryption::writeContainerFromFile($this->fileHandle, $package['encryptionInfo'], $package['encryptedPackageFilename']); + } finally { + $this->maybeCloseFileHandle(); + } + } finally { + @unlink($package['encryptedPackageFilename']); + } + } finally { + $this->encryptionPassword = $password; + if (file_exists($temporaryFilename)) { + unlink($temporaryFilename); + } + } + } + /** * Get Spreadsheet object. */ @@ -843,4 +955,202 @@ class Xlsx extends BaseWriter { return $this->restrictMaxColumnWidth; } + + /** + * Add pass-through media files from original spreadsheet. + * This copies media files that are referenced in pass-through drawing XML + * but may not be in the drawing collection (e.g., unsupported formats like SVG). + * + * @param string[] $zipContent + */ + private function addPassThroughMediaFiles(array &$zipContent): void + { + /** @var array> $sheets */ + $sheets = $this->spreadSheet->getUnparsedLoadedData()['sheets'] ?? []; + foreach ($sheets as $sheetData) { + /** @var string[] $mediaFiles */ + $mediaFiles = $sheetData['drawingMediaFiles'] ?? []; + /** @var ?string $sourceFile */ + $sourceFile = $sheetData['drawingSourceFile'] ?? null; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || $mediaFiles === [] || !is_string($sourceFile) || !file_exists($sourceFile)) { + continue; + } + + $sourceZip = new ZipArchive(); + if ($sourceZip->open($sourceFile) !== true) { + continue; // @codeCoverageIgnore + } + + foreach ($mediaFiles as $mediaPath) { + $zipPath = 'xl/media/' . basename($mediaPath); + if (!isset($zipContent[$zipPath])) { + $mediaContent = $sourceZip->getFromName($mediaPath); + if ($mediaContent !== false) { + $zipContent[$zipPath] = $mediaContent; + } + } + } + + $sourceZip->close(); + } + } + + /** + * Re-add the raw pivot table parts that were preserved on load, so pivot + * tables survive a load/save round-trip. Each part is keyed by its original + * path within the archive, keeping the relationships between the pivot + * table, its cache definition and cache records intact. + * + * @param array $zipContent + */ + private function addPivotTableFiles(array &$zipContent): void + { + $unparsedLoadedData = $this->spreadSheet->getUnparsedLoadedData(); + + // Pivot table parts, referenced from each worksheet's relationships. + /** @var array> $sheets */ + $sheets = $unparsedLoadedData['sheets'] ?? []; + foreach ($sheets as $sheetData) { + /** @var array> $pivotTables */ + $pivotTables = $sheetData['pivotTables'] ?? []; + foreach ($pivotTables as $pivotTable) { + $zipContent[$pivotTable['path']] = $pivotTable['content']; + } + } + + // Pivot cache definitions, referenced from the workbook relationships. + /** @var array> $cacheDefinitions */ + $cacheDefinitions = $unparsedLoadedData['pivotCacheDefinitions'] ?? []; + foreach ($cacheDefinitions as $cacheDefinition) { + $zipContent[$cacheDefinition['path']] = $cacheDefinition['content']; + } + + // Remaining pivot parts (cache records, and the rels for the pivot + // table and cache definition parts). + /** @var array $pivotParts */ + $pivotParts = $unparsedLoadedData['pivotCacheParts'] ?? []; + foreach ($pivotParts as $path => $content) { + $zipContent[$path] = $content; + } + + // Parts for pivot tables that were generated in memory. + foreach ($this->pivotTableContent as $path => $content) { + $zipContent[$path] = $content; + } + } + + /** + * Generate OOXML parts for every in-memory (built) pivot table, register + * the resulting wiring in the spreadsheet's unparsed loaded data so the + * existing relationship/workbook/content-type writers emit it, and return + * the generated part contents keyed by their archive path. + * + * @return array + */ + private function prepareGeneratedPivotTables(): array + { + /** @var array $unparsedLoadedData */ + $unparsedLoadedData = $this->spreadSheet->getUnparsedLoadedData(); + + // Continue indices after any pivot parts that were preserved on load, + // so generated file names never collide with preserved ones. + /** @var array> $existingCaches */ + $existingCaches = $unparsedLoadedData['pivotCacheDefinitions'] ?? []; + $tableIndex = $this->countPreservedPivotTables($unparsedLoadedData); + $cacheIndex = count($existingCaches); + + $content = []; + /** @var array $workbookPivotCaches */ + $workbookPivotCaches = $unparsedLoadedData['workbookPivotCaches'] ?? []; + /** @var array $overrideContentTypes */ + $overrideContentTypes = $unparsedLoadedData['override_content_types'] ?? []; + // Pivot table relationship data to merge into the per-sheet unparsed + // data, keyed by sheet code name. + /** @var array>> $sheetPivotTables */ + $sheetPivotTables = []; + + foreach ($this->spreadSheet->getWorksheetIterator() as $worksheet) { + foreach ($worksheet->getPivotTableCollection() as $pivotTable) { + if (!$pivotTable->isGenerated()) { + continue; + } + + ++$tableIndex; + ++$cacheIndex; + $cacheId = $cacheIndex; + $pivotTable->getCacheDefinition()?->setCacheId($cacheId); + + $tablePath = "xl/pivotTables/pivotTable{$tableIndex}.xml"; + $tableRelsPath = "xl/pivotTables/_rels/pivotTable{$tableIndex}.xml.rels"; + $cachePath = "xl/pivotCache/pivotCacheDefinition{$cacheIndex}.xml"; + $cacheRelsPath = "xl/pivotCache/_rels/pivotCacheDefinition{$cacheIndex}.xml.rels"; + $recordsPath = "xl/pivotCache/pivotCacheRecords{$cacheIndex}.xml"; + + $content[$tablePath] = PivotTableWriter::writeDefinition($pivotTable, $cacheId); + $content[$tableRelsPath] = PivotTableWriter::writeTableRelationships($cacheIndex); + $content[$cachePath] = PivotTableWriter::writeCacheDefinition($pivotTable); + $content[$cacheRelsPath] = PivotTableWriter::writeCacheRelationships($cacheIndex); + $content[$recordsPath] = PivotTableWriter::writeCacheRecords(); + + // Worksheet -> pivot table relationship data. + $sheetCode = $worksheet->getCodeName(); + $sheetPivotTables[$sheetCode][] = [ + 'relFilePath' => "../pivotTables/pivotTable{$tableIndex}.xml", + 'path' => $tablePath, + 'content' => $content[$tablePath], + ]; + + // Workbook registry + workbook relationship. + $workbookPivotCaches[] = [ + 'cacheId' => (string) $cacheId, + 'cacheDefinitionPath' => $cachePath, + ]; + $existingCaches[$cachePath] = ['path' => $cachePath, 'content' => $content[$cachePath]]; + + // Content types for the three new parts. + $overrideContentTypes['/' . $tablePath] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotTable+xml'; + $overrideContentTypes['/' . $cachePath] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheDefinition+xml'; + $overrideContentTypes['/' . $recordsPath] = 'application/vnd.openxmlformats-officedocument.spreadsheetml.pivotCacheRecords+xml'; + } + } + + if ($content === []) { + return []; + } + + // Merge the generated pivot table relationships into each sheet's data. + /** @var array> $sheets */ + $sheets = $unparsedLoadedData['sheets'] ?? []; + foreach ($sheetPivotTables as $sheetCode => $pivotTables) { + /** @var array> $existing */ + $existing = $sheets[$sheetCode]['pivotTables'] ?? []; + $sheets[$sheetCode]['pivotTables'] = array_merge($existing, $pivotTables); + } + + $unparsedLoadedData['sheets'] = $sheets; + $unparsedLoadedData['workbookPivotCaches'] = $workbookPivotCaches; + $unparsedLoadedData['pivotCacheDefinitions'] = $existingCaches; + $unparsedLoadedData['override_content_types'] = $overrideContentTypes; + /** @var array|string>>> $unparsedLoadedData */ + $this->spreadSheet->setUnparsedLoadedData($unparsedLoadedData); + + return $content; + } + + /** + * @param array $unparsedLoadedData + */ + private function countPreservedPivotTables(array $unparsedLoadedData): int + { + $count = 0; + /** @var array> $sheets */ + $sheets = $unparsedLoadedData['sheets'] ?? []; + foreach ($sheets as $sheetData) { + /** @var array $pivotTables */ + $pivotTables = $sheetData['pivotTables'] ?? []; + $count += count($pivotTables); + } + + return $count; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Chart.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Chart.php index 93db883..d16b423 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Chart.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Chart.php @@ -3,9 +3,11 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Chart\Axis; +use PhpOffice\PhpSpreadsheet\Chart\Chart as SpreadsheetChart; use PhpOffice\PhpSpreadsheet\Chart\ChartColor; use PhpOffice\PhpSpreadsheet\Chart\DataSeries; use PhpOffice\PhpSpreadsheet\Chart\DataSeriesValues; +use PhpOffice\PhpSpreadsheet\Chart\DataTable; use PhpOffice\PhpSpreadsheet\Chart\Layout; use PhpOffice\PhpSpreadsheet\Chart\Legend; use PhpOffice\PhpSpreadsheet\Chart\PlotArea; @@ -27,7 +29,7 @@ class Chart extends WriterPart * * @return string XML Output */ - public function writeChart(\PhpOffice\PhpSpreadsheet\Chart\Chart $chart, bool $calculateCellValues = true): string + public function writeChart(SpreadsheetChart $chart, bool $calculateCellValues = true): string { // Create XML writer $objWriter = null; @@ -51,10 +53,10 @@ class Chart extends WriterPart $objWriter->writeAttribute('xmlns:r', Namespaces::SCHEMA_OFFICE_DOCUMENT); $objWriter->startElement('c:date1904'); - $objWriter->writeAttribute('val', '0'); + $objWriter->writeAttribute('val', $chart->getDate1904() ? '1' : '0'); $objWriter->endElement(); $objWriter->startElement('c:lang'); - $objWriter->writeAttribute('val', 'en-GB'); + $objWriter->writeAttribute('val', $chart->getLang()); $objWriter->endElement(); $objWriter->startElement('c:roundedCorners'); $objWriter->writeAttribute('val', $chart->getRoundedCorners() ? '1' : '0'); @@ -70,7 +72,6 @@ class Chart extends WriterPart $objWriter->writeAttribute('val', (string) (int) $chart->getAutoTitleDeleted()); $objWriter->endElement(); - $objWriter->startElement('c:view3D'); $surface2D = false; $plotArea = $chart->getPlotArea(); if ($plotArea !== null) { @@ -83,11 +84,14 @@ class Chart extends WriterPart } } } + $this->view3DStarted = false; $this->writeView3D($objWriter, $chart->getRotX(), 'c:rotX', $surface2D, 90); $this->writeView3D($objWriter, $chart->getRotY(), 'c:rotY', $surface2D); $this->writeView3D($objWriter, $chart->getRAngAx(), 'c:rAngAx', $surface2D); $this->writeView3D($objWriter, $chart->getPerspective(), 'c:perspective', $surface2D); - $objWriter->endElement(); // view3D + if ($this->view3DStarted) { + $objWriter->endElement(); // view3D + } $this->writePlotArea($objWriter, $chart->getPlotArea(), $chart->getXAxisLabel(), $chart->getYAxisLabel(), $chart->getChartAxisX(), $chart->getChartAxisY()); @@ -121,7 +125,7 @@ class Chart extends WriterPart $this->writeEffects($objWriter, $borderLines); $objWriter->endElement(); // c:spPr - $this->writePrintSettings($objWriter); + $this->writePrintSettings($objWriter, $chart); $objWriter->endElement(); // c:chartSpace @@ -129,12 +133,18 @@ class Chart extends WriterPart return $objWriter->getData(); } + private bool $view3DStarted = false; + private function writeView3D(XMLWriter $objWriter, ?int $value, string $tag, bool $surface2D, int $default = 0): void { if ($value === null && $surface2D) { $value = $default; } if ($value !== null) { + if (!$this->view3DStarted) { + $objWriter->startElement('c:view3D'); + $this->view3DStarted = true; + } $objWriter->startElement($tag); $objWriter->writeAttribute('val', "$value"); $objWriter->endElement(); @@ -303,6 +313,8 @@ class Chart extends WriterPart $objWriter->endElement(); // c:legend } + private string $chartType = ''; + /** * Write Chart Plot Area. */ @@ -326,6 +338,7 @@ class Chart extends WriterPart $chartType = null; foreach ($chartTypes as $chartType) { $objWriter->startElement('c:' . $chartType); + $this->chartType = $chartType; $groupCount = $plotArea->getPlotGroupCount(); $plotGroup = null; @@ -443,6 +456,10 @@ class Chart extends WriterPart $this->writeValueAxis($objWriter, $xAxisLabel, $chartType, $id2, $id1, $catIsMultiLevelSeries, $xAxis ?? new Axis()); } else { $this->writeCategoryAxis($objWriter, $xAxisLabel, $id1, $id2, $catIsMultiLevelSeries, $xAxis ?? new Axis()); + $dataTable = $plotArea->getDataTable(); + if ($dataTable !== null) { + $this->writeDataTable($objWriter, $dataTable); + } } $this->writeValueAxis($objWriter, $yAxisLabel, $chartType, $id1, $id2, $valIsMultiLevelSeries, $yAxis ?? new Axis()); @@ -481,6 +498,29 @@ class Chart extends WriterPart $objWriter->endElement(); // c:plotArea } + private function writeDataTable(XMLWriter $objWriter, DataTable $dataTable): void + { + $objWriter->startElement('c:dTable'); + + $objWriter->startElement('c:showHorzBorder'); + $objWriter->writeAttribute('val', $dataTable->getShowHorizontalBorder() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showVertBorder'); + $objWriter->writeAttribute('val', $dataTable->getShowVerticalBorder() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showOutline'); + $objWriter->writeAttribute('val', $dataTable->getShowOutline() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->startElement('c:showKeys'); + $objWriter->writeAttribute('val', $dataTable->getShowKeys() ? '1' : '0'); + $objWriter->endElement(); + + $objWriter->endElement(); // c:dTable + } + private function writeDataLabelsBool(XMLWriter $objWriter, string $name, ?bool $value): void { if ($value !== null) { @@ -517,12 +557,12 @@ class Chart extends WriterPart $objWriter->startElement('c:txPr'); $objWriter->startElement('a:bodyPr'); - $objWriter->writeAttribute('wrap', 'square'); - $objWriter->writeAttribute('lIns', '38100'); - $objWriter->writeAttribute('tIns', '19050'); - $objWriter->writeAttribute('rIns', '38100'); - $objWriter->writeAttribute('bIns', '19050'); - $objWriter->writeAttribute('anchor', 'ctr'); + $bodyPr = $chartLayout->getBodyPr(); + foreach (['vertOverflow', 'horzOverflow', 'wrap', 'lIns', 'tIns', 'rIns', 'bIns', 'anchor'] as $key) { + if (isset($bodyPr[$key])) { + $objWriter->writeAttribute($key, $bodyPr[$key]); + } + } $objWriter->startElement('a:spAutoFit'); $objWriter->endElement(); // a:spAutoFit $objWriter->endElement(); // a:bodyPr @@ -1684,27 +1724,32 @@ class Chart extends WriterPart /** * Write Printer Settings. */ - private function writePrintSettings(XMLWriter $objWriter): void + private function writePrintSettings(XMLWriter $objWriter, SpreadsheetChart $chart): void { $objWriter->startElement('c:printSettings'); $objWriter->startElement('c:headerFooter'); $objWriter->endElement(); + $pageMargins = $chart->getPageMargins(); $objWriter->startElement('c:pageMargins'); - $objWriter->writeAttribute('footer', '0.3'); - $objWriter->writeAttribute('header', '0.3'); - $objWriter->writeAttribute('r', '0.7'); - $objWriter->writeAttribute('l', '0.7'); - $objWriter->writeAttribute('t', '0.75'); - $objWriter->writeAttribute('b', '0.75'); - $objWriter->endElement(); + foreach (['b', 'l', 'r', 't', 'header', 'footer'] as $key) { + if (array_key_exists($key, $pageMargins)) { + $objWriter->writeAttribute($key, $pageMargins[$key]); + } + } + $objWriter->endElement(); // c:pageMargins + $pageSetup = $chart->getPageSetup(); $objWriter->startElement('c:pageSetup'); - $objWriter->writeAttribute('orientation', 'portrait'); - $objWriter->endElement(); + foreach (['paperSize', 'orientation'] as $key) { + if (array_key_exists($key, $pageSetup)) { + $objWriter->writeAttribute($key, $pageSetup[$key]); + } + } + $objWriter->endElement(); // c:pageSetup - $objWriter->endElement(); + $objWriter->endElement(); // c:printSettings } private function writeEffects(XMLWriter $objWriter, Properties $yAxis): void @@ -1753,7 +1798,12 @@ class Chart extends WriterPart foreach (['kx', 'ky'] as $sizeType) { $sizeValue = $xAxis->getShadowProperty(['size', $sizeType]); if (is_numeric($sizeValue)) { - $objWriter->writeAttribute($sizeType, Properties::angleToXml((float) $sizeValue)); + $temp = (float) Properties::angleToXml((float) $sizeValue); + if (abs($temp) <= Properties::MAX_SKEW_ANGLE_XML) { + // This corresponds to values between -90 amd +90 EXCLUSIVE. + // 90 and anything higher is invalid. + $objWriter->writeAttribute($sizeType, Properties::angleToXml((float) $sizeValue)); + } } } $rotWithShape = $xAxis->getShadowProperty('rotWithShape'); @@ -1869,9 +1919,11 @@ class Chart extends WriterPart $objWriter->startElement('a:lumMod'); $objWriter->writeAttribute('val', ChartColor::alphaToXml($brightness)); $objWriter->endElement(); // a:lumMod - $objWriter->startElement('a:lumOff'); - $objWriter->writeAttribute('val', ChartColor::alphaToXml($lumOff)); - $objWriter->endElement(); // a:lumOff + if ($this->chartType !== DataSeries::TYPE_RADARCHART) { + $objWriter->startElement('a:lumOff'); + $objWriter->writeAttribute('val', ChartColor::alphaToXml($lumOff)); + $objWriter->endElement(); // a:lumOff + } } $objWriter->endElement(); //a:srgbClr/schemeClr/prstClr if ($solidFill) { diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php index 16c7bb9..70fc874 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/ContentTypes.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; @@ -156,11 +157,45 @@ class ContentTypes extends WriterPart $this->writeDefaultContentType($objWriter, $extension, $mimeType); } } + + if ($spreadsheet->hasInCellDrawings()) { + $this->writeOverrideContentType($objWriter, '/xl/richData/richValueRel.xml', 'application/vnd.ms-excel.richvaluerel+xml'); + $this->writeOverrideContentType($objWriter, '/xl/richData/rdrichvalue.xml', 'application/vnd.ms-excel.rdrichvalue+xml'); + $this->writeOverrideContentType($objWriter, '/xl/richData/rdrichvaluestructure.xml', 'application/vnd.ms-excel.rdrichvaluestructure+xml'); + $this->writeOverrideContentType($objWriter, '/xl/richData/rdRichValueTypes.xml', 'application/vnd.ms-excel.rdrichvaluetypes+xml'); + } + + // Add pass-through media content types + /** @var array> $sheets */ + $sheets = $unparsedLoadedData['sheets'] ?? []; + foreach ($sheets as $sheetData) { + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true) { + continue; + } + /** @var string[] $mediaFiles */ + $mediaFiles = $sheetData['drawingMediaFiles'] ?? []; + foreach ($mediaFiles as $mediaPath) { + $extension = strtolower(pathinfo($mediaPath, PATHINFO_EXTENSION)); + if ($extension !== '' && !isset($aMediaContentTypes[$extension])) { + $mimeType = match ($extension) { // @phpstan-ignore match.unhandled (no default, don't care if it blows up because of that) + 'png' => 'image/png', + 'jpg', 'jpeg' => 'image/jpeg', + 'gif' => 'image/gif', + 'bmp' => 'image/bmp', + 'tif', 'tiff' => 'image/tiff', + 'svg' => 'image/svg+xml', + }; + $aMediaContentTypes[$extension] = $mimeType; + $this->writeDefaultContentType($objWriter, $extension, $mimeType); + } + } + } + if ($spreadsheet->hasRibbonBinObjects()) { // Some additional objects in the ribbon ? // we need to write "Extension" but not already write for media content /** @var string[] */ - $tabRibbonTypes = array_diff($spreadsheet->getRibbonBinObjects('types') ?? [], array_keys($aMediaContentTypes)); + $tabRibbonTypes = array_diff($spreadsheet->getRibbonBinObjects('types') ?? [], array_keys($aMediaContentTypes)); // @phpstan-ignore argument.type (getRibbonBinObjects might return mixed[], array_diff 1st arg needs array of castables to string) foreach ($tabRibbonTypes as $aRibbonType) { $mimeType = 'image/.' . $aRibbonType; //we wrote $mimeType like customUI Editor $this->writeDefaultContentType($objWriter, $aRibbonType, $mimeType); @@ -222,10 +257,14 @@ class ContentTypes extends WriterPart } // Metadata needed for Dynamic Arrays - if ($this->getParentWriter()->useDynamicArrays()) { + if ($this->getParentWriter()->useDynamicArrays() || $spreadsheet->hasInCellDrawings()) { $this->writeOverrideContentType($objWriter, '/xl/metadata.xml', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheetMetadata+xml'); } + if ($spreadsheet->getUsesCheckboxStyle()) { + $this->writeOverrideContentType($objWriter, '/xl/featurePropertyBag/featurePropertyBag.xml', 'application/vnd.ms-excel.featurepropertybag+xml'); + } + $objWriter->endElement(); // Return @@ -243,6 +282,9 @@ class ContentTypes extends WriterPart */ private function getImageMimeType(string $filename): string { + if (Preg::isMatch('~^data:(image/[^;]+);base64,~', $filename, $matches)) { + return $matches[1]; + } if (File::fileExists($filename)) { $image = getimagesize($filename); diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php index 887b436..874982c 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Drawing.php @@ -23,6 +23,11 @@ class Drawing extends WriterPart */ public function writeDrawings(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, bool $includeCharts = false): string { + // Try to use pass-through drawing XML if available + if ($passThroughXml = $this->getPassThroughDrawingXml($worksheet)) { + return $passThroughXml; + } + // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -55,15 +60,8 @@ class Drawing extends WriterPart } if ($includeCharts) { - $chartCount = $worksheet->getChartCount(); - // Loop through charts and write the chart position - if ($chartCount > 0) { - for ($c = 0; $c < $chartCount; ++$c) { - $chart = $worksheet->getChartByIndex((string) $c); - if ($chart !== false) { - $this->writeChart($objWriter, $chart, $c + $i); - } - } + foreach ($worksheet->getChartCollection() as $c => $chart) { + $this->writeChart($objWriter, $chart, $c + $i); } } @@ -562,6 +560,12 @@ class Drawing extends WriterPart while ($iterator->valid()) { $aDrawings[] = $iterator->current(); + $iterator->next(); + } + $iterator = $spreadsheet->getSheet($i)->getInCellDrawingCollection()->getIterator(); + while ($iterator->valid()) { + $aDrawings[] = $iterator->current(); + $iterator->next(); } } @@ -592,4 +596,27 @@ class Drawing extends WriterPart $objWriter->writeAttribute($attr, $val); } } + + /** + * Get pass-through drawing XML if available. + * + * Returns the original drawing XML stored during load (when Reader pass-through was enabled). + * This preserves unsupported drawing elements (shapes, textboxes) that PhpSpreadsheet cannot parse. + * + * @return ?string The pass-through XML, or null if not available or should not be used + */ + private function getPassThroughDrawingXml(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet): ?string + { + /** @var array> $sheets */ + $sheets = $worksheet->getParentOrThrow()->getUnparsedLoadedData()['sheets'] ?? []; + $sheetData = $sheets[$worksheet->getCodeName()] ?? []; + // Only use pass-through XML if the Reader flag was explicitly enabled + /** @var string[] $drawings */ + $drawings = $sheetData['Drawings'] ?? []; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || $drawings === []) { + return null; + } + + return reset($drawings) ?: null; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FeaturePropertyBag.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FeaturePropertyBag.php new file mode 100644 index 0000000..adcd9b6 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/FeaturePropertyBag.php @@ -0,0 +1,66 @@ +getUsesCheckBoxStyle()) { + return ''; + } + // Create XML writer + $objWriter = null; + if ($this->getParentWriter()->getUseDiskCaching()) { + $objWriter = new XMLWriter(XMLWriter::STORAGE_DISK, $this->getParentWriter()->getDiskCachingDirectory()); + } else { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + } + + // XML header + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + + // Types + $objWriter->startElement('FeaturePropertyBags'); + $objWriter->writeAttribute('xmlns', Namespaces::FEATURE_PROPERTY_BAG); + + $objWriter->startElement('bag'); + $objWriter->writeAttribute('type', 'Checkbox'); + $objWriter->endElement(); // bag type=Checkbox + + $objWriter->startElement('bag'); + $objWriter->writeAttribute('type', 'XFControls'); + $objWriter->startElement('bagId'); + $objWriter->writeAttribute('k', 'CellControl'); + $objWriter->text('0'); + $objWriter->endElement(); // bagid + $objWriter->endElement(); // bag type=XFControls + + $objWriter->startElement('bag'); + $objWriter->writeAttribute('type', 'XFComplement'); + $objWriter->startElement('bagId'); + $objWriter->writeAttribute('k', 'XFControls'); + $objWriter->text('1'); + $objWriter->endElement(); // bagid + $objWriter->endElement(); // bag type=XFComplement + + $objWriter->startElement('bag'); + $objWriter->writeAttribute('type', 'XFComplements'); + $objWriter->writeAttribute('extRef', 'XFComplementsMapperExtRef'); + $objWriter->startElement('a'); + $objWriter->writeAttribute('k', 'MappedFeaturePropertyBags'); + $objWriter->startElement('bagId'); + $objWriter->text('2'); + $objWriter->endElement(); // bagid + $objWriter->endElement(); // a + $objWriter->endElement(); // bag type=XFComplements + + $objWriter->endElement(); // FeaturePropertyBags + + return $objWriter->getData(); + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php index 00c15f0..08ff86d 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Metadata.php @@ -12,9 +12,9 @@ class Metadata extends WriterPart * * @return string XML Output */ - public function writeMetadata(): string + public function writeMetadata(int $richDataCount = 0): string { - if (!$this->getParentWriter()->useDynamicArrays()) { + if (!$this->getParentWriter()->useDynamicArrays() && $richDataCount === 0) { return ''; } // Create XML writer @@ -32,98 +32,129 @@ class Metadata extends WriterPart $objWriter->startElement('metadata'); $objWriter->writeAttribute('xmlns', Namespaces::MAIN); $objWriter->writeAttribute('xmlns:xlrd', Namespaces::DYNAMIC_ARRAY_RICHDATA); - $objWriter->writeAttribute('xmlns:xda', Namespaces::DYNAMIC_ARRAY); - $objWriter->startElement('metadataTypes'); - $objWriter->writeAttribute('count', '2'); + if (!$this->getParentWriter()->useDynamicArrays()) { + $objWriter->startElement('metadataTypes'); + $objWriter->writeAttribute('count', '1'); + $this->writeMetadataType($objWriter, 'XLRICHVALUE', false); + $objWriter->endElement(); // metadataTypes - $objWriter->startElement('metadataType'); - $objWriter->writeAttribute('name', 'XLDAPR'); - $objWriter->writeAttribute('minSupportedVersion', '120000'); - $objWriter->writeAttribute('copy', '1'); - $objWriter->writeAttribute('pasteAll', '1'); - $objWriter->writeAttribute('pasteValues', '1'); - $objWriter->writeAttribute('merge', '1'); - $objWriter->writeAttribute('splitFirst', '1'); - $objWriter->writeAttribute('rowColShift', '1'); - $objWriter->writeAttribute('clearFormats', '1'); - $objWriter->writeAttribute('clearComments', '1'); - $objWriter->writeAttribute('assign', '1'); - $objWriter->writeAttribute('coerce', '1'); - $objWriter->writeAttribute('cellMeta', '1'); - $objWriter->endElement(); // metadataType XLDAPR + $this->writeFutureMetadataXLRICHVALUE($objWriter, $richDataCount); + $this->writeValueMetadata($objWriter, $richDataCount); + } else { + $objWriter->writeAttribute('xmlns:xda', Namespaces::DYNAMIC_ARRAY); + $objWriter->startElement('metadataTypes'); + $objWriter->writeAttribute('count', '2'); + $this->writeMetadataType($objWriter, 'XLDAPR'); + $this->writeMetadataType($objWriter, 'XLRICHVALUE', false); + $objWriter->endElement(); // metadataTypes - $objWriter->startElement('metadataType'); - $objWriter->writeAttribute('name', 'XLRICHVALUE'); - $objWriter->writeAttribute('minSupportedVersion', '120000'); - $objWriter->writeAttribute('copy', '1'); - $objWriter->writeAttribute('pasteAll', '1'); - $objWriter->writeAttribute('pasteValues', '1'); - $objWriter->writeAttribute('merge', '1'); - $objWriter->writeAttribute('splitFirst', '1'); - $objWriter->writeAttribute('rowColShift', '1'); - $objWriter->writeAttribute('clearFormats', '1'); - $objWriter->writeAttribute('clearComments', '1'); - $objWriter->writeAttribute('assign', '1'); - $objWriter->writeAttribute('coerce', '1'); - $objWriter->endElement(); // metadataType XLRICHVALUE - - $objWriter->endElement(); // metadataTypes - - $objWriter->startElement('futureMetadata'); - $objWriter->writeAttribute('name', 'XLDAPR'); - $objWriter->writeAttribute('count', '1'); - $objWriter->startElement('bk'); - $objWriter->startElement('extLst'); - $objWriter->startElement('ext'); - $objWriter->writeAttribute('uri', '{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}'); - $objWriter->startElement('xda:dynamicArrayProperties'); - $objWriter->writeAttribute('fDynamic', '1'); - $objWriter->writeAttribute('fCollapsed', '0'); - $objWriter->endElement(); // xda:dynamicArrayProperties - $objWriter->endElement(); // ext - $objWriter->endElement(); // extLst - $objWriter->endElement(); // bk - $objWriter->endElement(); // futureMetadata XLDAPR - - $objWriter->startElement('futureMetadata'); - $objWriter->writeAttribute('name', 'XLRICHVALUE'); - $objWriter->writeAttribute('count', '1'); - $objWriter->startElement('bk'); - $objWriter->startElement('extLst'); - $objWriter->startElement('ext'); - $objWriter->writeAttribute('uri', '{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}'); - $objWriter->startElement('xlrd:rvb'); - $objWriter->writeAttribute('i', '0'); - $objWriter->endElement(); // xlrd:rvb - $objWriter->endElement(); // ext - $objWriter->endElement(); // extLst - $objWriter->endElement(); // bk - $objWriter->endElement(); // futureMetadata XLRICHVALUE - - $objWriter->startElement('cellMetadata'); - $objWriter->writeAttribute('count', '1'); - $objWriter->startElement('bk'); - $objWriter->startElement('rc'); - $objWriter->writeAttribute('t', '1'); - $objWriter->writeAttribute('v', '0'); - $objWriter->endElement(); // rc - $objWriter->endElement(); // bk - $objWriter->endElement(); // cellMetadata - - $objWriter->startElement('valueMetadata'); - $objWriter->writeAttribute('count', '1'); - $objWriter->startElement('bk'); - $objWriter->startElement('rc'); - $objWriter->writeAttribute('t', '2'); - $objWriter->writeAttribute('v', '0'); - $objWriter->endElement(); // rc - $objWriter->endElement(); // bk - $objWriter->endElement(); // valueMetadata + $this->writeFutureMetadataXLDAPR($objWriter, 1); + $this->writeFutureMetadataXLRICHVALUE($objWriter, $richDataCount ?: 1); + $this->writeCellMetadata($objWriter, 1); + $this->writeValueMetadata($objWriter, ($richDataCount === 0) ? 1 : $richDataCount, 2); + } $objWriter->endElement(); // metadata // Return return $objWriter->getData(); } + + private function writeMetadataType(XMLWriter $objWriter, string $name, bool $cellMeta = true): void + { + $objWriter->startElement('metadataType'); + $objWriter->writeAttribute('name', $name); + $objWriter->writeAttribute('minSupportedVersion', '120000'); + $objWriter->writeAttribute('copy', '1'); + $objWriter->writeAttribute('pasteAll', '1'); + $objWriter->writeAttribute('pasteValues', '1'); + $objWriter->writeAttribute('merge', '1'); + $objWriter->writeAttribute('splitFirst', '1'); + $objWriter->writeAttribute('rowColShift', '1'); + $objWriter->writeAttribute('clearFormats', '1'); + $objWriter->writeAttribute('clearComments', '1'); + $objWriter->writeAttribute('assign', '1'); + $objWriter->writeAttribute('coerce', '1'); + + if ($cellMeta) { + $objWriter->writeAttribute('cellMeta', '1'); + } + $objWriter->endElement(); + } + + private function writeFutureMetadataXLDAPR(XMLWriter $objWriter, int $count = 1): void + { + $objWriter->startElement('futureMetadata'); + $objWriter->writeAttribute('name', 'XLDAPR'); + $objWriter->writeAttribute('count', (string) $count); + + for ($index = 0; $index < $count; ++$index) { + $objWriter->startElement('bk'); + $objWriter->startElement('extLst'); + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', '{bdbb8cdc-fa1e-496e-a857-3c3f30c029c3}'); + $objWriter->startElement('xda:dynamicArrayProperties'); + $objWriter->writeAttribute('fDynamic', '1'); + $objWriter->writeAttribute('fCollapsed', '0'); + $objWriter->endElement(); // xda:dynamicArrayProperties + $objWriter->endElement(); // ext + $objWriter->endElement(); // extLst + $objWriter->endElement(); // bk + } + $objWriter->endElement(); // futureMetadata XLDAPR + } + + private function writeFutureMetadataXLRICHVALUE(XMLWriter $objWriter, int $count): void + { + $objWriter->startElement('futureMetadata'); + $objWriter->writeAttribute('name', 'XLRICHVALUE'); + $objWriter->writeAttribute('count', (string) $count); + + for ($index = 0; $index < $count; ++$index) { + $objWriter->startElement('bk'); + $objWriter->startElement('extLst'); + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', '{3e2802c4-a4d2-4d8b-9148-e3be6c30e623}'); + $objWriter->startElement('xlrd:rvb'); + $objWriter->writeAttribute('i', (string) $index); + $objWriter->endElement(); // xlrd:rvb + $objWriter->endElement(); // ext + $objWriter->endElement(); // extLst + $objWriter->endElement(); // bk + } + $objWriter->endElement(); // futureMetadata XLRICHVALUE + } + + private function writeCellMetadata(XMLWriter $objWriter, int $count = 1, int $t = 1): void + { + $objWriter->startElement('cellMetadata'); + $objWriter->writeAttribute('count', (string) $count); + + for ($index = 0; $index < $count; ++$index) { + $objWriter->startElement('bk'); + $objWriter->startElement('rc'); + $objWriter->writeAttribute('t', (string) $t); + $objWriter->writeAttribute('v', (string) $index); + $objWriter->endElement(); // rc + $objWriter->endElement(); // bk + } + $objWriter->endElement(); // cellMetadata + } + + private function writeValueMetadata(XMLWriter $objWriter, int $count = 1, int $t = 1): void + { + $objWriter->startElement('valueMetadata'); + $objWriter->writeAttribute('count', (string) $count); + + for ($index = 0; $index < $count; ++$index) { + $objWriter->startElement('bk'); + $objWriter->startElement('rc'); + $objWriter->writeAttribute('t', (string) $t); + $objWriter->writeAttribute('v', (string) $index); + $objWriter->endElement(); // rc + $objWriter->endElement(); // bk + } + $objWriter->endElement(); // valueMetadata + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/PivotTable.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/PivotTable.php new file mode 100644 index 0000000..3a30419 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/PivotTable.php @@ -0,0 +1,449 @@ +startDocument('1.0', 'UTF-8', 'yes'); + + $objWriter->startElement('pivotTableDefinition'); + $objWriter->writeAttribute('xmlns', Namespaces::MAIN); + $objWriter->writeAttribute('name', $pivotTable->getName()); + $objWriter->writeAttribute('cacheId', (string) $cacheId); + $objWriter->writeAttribute('applyNumberFormats', '0'); + $objWriter->writeAttribute('applyBorderFormats', '0'); + $objWriter->writeAttribute('applyFontFormats', '0'); + $objWriter->writeAttribute('applyPatternFormats', '0'); + $objWriter->writeAttribute('applyAlignmentFormats', '0'); + $objWriter->writeAttribute('applyWidthHeightFormats', '1'); + $objWriter->writeAttribute('dataCaption', 'Values'); + $objWriter->writeAttribute('updatedVersion', '6'); + $objWriter->writeAttribute('minRefreshableVersion', '3'); + $objWriter->writeAttribute('useAutoFormatting', '1'); + $objWriter->writeAttribute('itemPrintTitles', '1'); + $objWriter->writeAttribute('createdVersion', '6'); + $objWriter->writeAttribute('indent', '0'); + $objWriter->writeAttribute('outline', '1'); + $objWriter->writeAttribute('outlineData', '1'); + $objWriter->writeAttribute('multipleFieldFilters', '0'); + + // location. The precise extent is recomputed on refresh; firstDataRow + // leaves room for the column-field header row when one is present. + $hasColumnFields = $pivotTable->getColumnFields() !== []; + $objWriter->startElement('location'); + $objWriter->writeAttribute('ref', $pivotTable->getLocation()); + $objWriter->writeAttribute('firstHeaderRow', '1'); + $objWriter->writeAttribute('firstDataRow', $hasColumnFields ? '2' : '1'); + $objWriter->writeAttribute('firstDataCol', '1'); + $objWriter->endElement(); + + self::writePivotFields($objWriter, $pivotTable); + self::writeAxisFields($objWriter, 'rowFields', $pivotTable->getRowFields()); + self::writeAxisFields($objWriter, 'colFields', $pivotTable->getColumnFields()); + self::writePageFields($objWriter, $pivotTable->getPageFields()); + self::writeDataFields($objWriter, $pivotTable); + + // Style + $objWriter->startElement('pivotTableStyleInfo'); + $objWriter->writeAttribute('name', 'PivotStyleLight16'); + $objWriter->writeAttribute('showRowHeaders', '1'); + $objWriter->writeAttribute('showColHeaders', '1'); + $objWriter->writeAttribute('showRowStripes', '0'); + $objWriter->writeAttribute('showColStripes', '0'); + $objWriter->writeAttribute('showLastColumn', '1'); + $objWriter->endElement(); + + $objWriter->endElement(); // pivotTableDefinition + + return $objWriter->getData(); + } + + /** + * Build the pivotCacheDefinition part, referencing its records by rel id. + */ + public static function writeCacheDefinition(WorksheetPivotTable $pivotTable): string + { + $cache = $pivotTable->getCacheDefinition(); + + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + + $objWriter->startElement('pivotCacheDefinition'); + $objWriter->writeAttribute('xmlns', Namespaces::MAIN); + $objWriter->writeAttribute('xmlns:r', Namespaces::SCHEMA_OFFICE_DOCUMENT); + $objWriter->writeAttribute('r:id', 'rId1'); + $objWriter->writeAttribute('refreshOnLoad', '1'); + $objWriter->writeAttribute('refreshedBy', 'PhpSpreadsheet'); + $objWriter->writeAttribute('createdVersion', '6'); + $objWriter->writeAttribute('refreshedVersion', '6'); + $objWriter->writeAttribute('minRefreshableVersion', '3'); + $objWriter->writeAttribute('recordCount', '0'); + + // cacheSource + $objWriter->startElement('cacheSource'); + $objWriter->writeAttribute('type', 'worksheet'); + $objWriter->startElement('worksheetSource'); + $objWriter->writeAttribute('ref', (string) $cache?->getSourceRange()); + $objWriter->writeAttribute('sheet', (string) $cache?->getSourceWorksheet()); + $objWriter->endElement(); + $objWriter->endElement(); + + // cacheFields + $cacheFields = $cache?->getCacheFields() ?? []; + $objWriter->startElement('cacheFields'); + $objWriter->writeAttribute('count', (string) count($cacheFields)); + foreach ($pivotTable->getFields() as $field) { + $fieldName = $field->getName(); + $group = $cache?->getFieldGroup($fieldName); + $objWriter->startElement('cacheField'); + $objWriter->writeAttribute('name', $fieldName); + $objWriter->writeAttribute('numFmtId', '0'); + + if ($group !== null) { + self::writeGroupedCacheField($objWriter, $group); + } elseif ($field->isDataField()) { + // Numeric value field: describe as containing numbers. + $objWriter->startElement('sharedItems'); + $objWriter->writeAttribute('containsSemiMixedTypes', '0'); + $objWriter->writeAttribute('containsString', '0'); + $objWriter->writeAttribute('containsNumber', '1'); + $objWriter->endElement(); + } else { + $items = $cache?->getSharedItems($fieldName) ?? []; + $objWriter->startElement('sharedItems'); + $objWriter->writeAttribute('count', (string) count($items)); + foreach ($items as $item) { + $objWriter->startElement('s'); + $objWriter->writeAttribute('v', $item); + $objWriter->endElement(); + } + $objWriter->endElement(); + } + + $objWriter->endElement(); // cacheField + } + $objWriter->endElement(); // cacheFields + + $objWriter->endElement(); // pivotCacheDefinition + + return $objWriter->getData(); + } + + /** + * Emit the and for a grouped cache field. + */ + private static function writeGroupedCacheField(XMLWriter $objWriter, PivotFieldGroup $group): void + { + if ($group->isDate()) { + self::writeDateGroup($objWriter, $group); + } else { + self::writeNumericGroup($objWriter, $group); + } + } + + private static function writeNumericGroup(XMLWriter $objWriter, PivotFieldGroup $group): void + { + $start = $group->getStartNum() ?? 0.0; + $end = $group->getEndNum() ?? ($start + $group->getInterval()); + $interval = $group->getInterval() > 0 ? $group->getInterval() : 1.0; + + // Source values are numeric. + $objWriter->startElement('sharedItems'); + $objWriter->writeAttribute('containsSemiMixedTypes', '0'); + $objWriter->writeAttribute('containsString', '0'); + $objWriter->writeAttribute('containsNumber', '1'); + $objWriter->writeAttribute('minValue', self::num($start)); + $objWriter->writeAttribute('maxValue', self::num($end)); + $objWriter->endElement(); + + $objWriter->startElement('fieldGroup'); + $objWriter->startElement('rangePr'); + $objWriter->writeAttribute('groupInterval', self::num($interval)); + $objWriter->writeAttribute('startNum', self::num($start)); + $objWriter->writeAttribute('endNum', self::num($end)); + $objWriter->endElement(); + + // Group items: "-" buckets plus the sentinel bounds Excel + // expects (values below the start and above the end). + $buckets = []; + for ($lower = $start; $lower < $end; $lower += $interval) { + $upper = min($lower + $interval, $end); + $buckets[] = self::num($lower) . '-' . self::num($upper); + } + $objWriter->startElement('groupItems'); + $objWriter->writeAttribute('count', (string) (count($buckets) + 2)); + self::writeGroupItem($objWriter, '<' . self::num($start)); + foreach ($buckets as $bucket) { + self::writeGroupItem($objWriter, $bucket); + } + self::writeGroupItem($objWriter, '>' . self::num($end)); + $objWriter->endElement(); // groupItems + + $objWriter->endElement(); // fieldGroup + } + + /** + * Excel sentinel bounds for an auto-ranged date grouping. These match the + * out-of-range group item labels ("<1/1/1900" and ">12/31/9999") that the + * spreadsheet application expects for every date field group. + */ + private const DATE_GROUP_MIN = '1900-01-01T00:00:00'; + private const DATE_GROUP_MAX = '9999-12-31T00:00:00'; + + private static function writeDateGroup(XMLWriter $objWriter, PivotFieldGroup $group): void + { + $groupByUnits = $group->getGroupBy() === [] ? [PivotFieldGroup::GROUP_BY_MONTHS] : $group->getGroupBy(); + // Excel groups by a single unit per field; use the first requested unit. + $groupBy = $groupByUnits[0]; + + // A date field group is only valid when its bounds are present: the + // sharedItems must carry minDate/maxDate and the rangePr must carry + // startDate/endDate. When the caller did not supply a range, fall back + // to Excel's sentinel bounds so the workbook is not reported as corrupt. + $startDate = $group->getStartDate() ?? self::DATE_GROUP_MIN; + $endDate = $group->getEndDate() ?? self::DATE_GROUP_MAX; + + $objWriter->startElement('sharedItems'); + $objWriter->writeAttribute('containsSemiMixedTypes', '0'); + $objWriter->writeAttribute('containsNonDate', '0'); + $objWriter->writeAttribute('containsDate', '1'); + $objWriter->writeAttribute('containsString', '0'); + $objWriter->writeAttribute('minDate', $startDate); + $objWriter->writeAttribute('maxDate', $endDate); + $objWriter->endElement(); + + $objWriter->startElement('fieldGroup'); + $objWriter->startElement('rangePr'); + $objWriter->writeAttribute('groupBy', $groupBy); + $objWriter->writeAttribute('startDate', $startDate); + $objWriter->writeAttribute('endDate', $endDate); + $objWriter->endElement(); + + // Provide the standard group item labels for the chosen unit; the + // spreadsheet application rebuilds the exact set on refresh. + $items = self::dateGroupItems($groupBy); + $objWriter->startElement('groupItems'); + $objWriter->writeAttribute('count', (string) count($items)); + foreach ($items as $item) { + self::writeGroupItem($objWriter, $item); + } + $objWriter->endElement(); + + $objWriter->endElement(); // fieldGroup + } + + private static function writeGroupItem(XMLWriter $objWriter, string $value): void + { + $objWriter->startElement('s'); + $objWriter->writeAttribute('v', $value); + $objWriter->endElement(); + } + + /** + * The canonical group-item labels Excel uses for each date grouping unit. + * + * @return string[] + */ + private static function dateGroupItems(string $groupBy): array + { + switch ($groupBy) { + case PivotFieldGroup::GROUP_BY_QUARTERS: + return ['<1/1/1900', 'Qtr1', 'Qtr2', 'Qtr3', 'Qtr4', '>12/31/9999']; + case PivotFieldGroup::GROUP_BY_MONTHS: + return [ + '<1/1/1900', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', + 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec', '>12/31/9999', + ]; + //case PivotFieldGroup::GROUP_BY_YEARS: + default: + // Years are enumerated by the application on refresh. + return ['<1/1/1900', '>12/31/9999']; + } + } + + /** + * Format a float without a trailing ".0" for whole numbers. + */ + private static function num(float $value): string + { + if ($value === floor($value) && abs($value) < 1.0e15) { + return (string) (int) $value; + } + + return (string) $value; + } + + /** + * Build the (empty) pivotCacheRecords part. Records are regenerated by the + * spreadsheet application on refresh. + */ + public static function writeCacheRecords(): string + { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + + $objWriter->startElement('pivotCacheRecords'); + $objWriter->writeAttribute('xmlns', Namespaces::MAIN); + $objWriter->writeAttribute('xmlns:r', Namespaces::SCHEMA_OFFICE_DOCUMENT); + $objWriter->writeAttribute('count', '0'); + $objWriter->endElement(); + + return $objWriter->getData(); + } + + /** + * Rels for the pivot table part -> its cache definition. + */ + public static function writeTableRelationships(int $cacheIndex): string + { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + $objWriter->startElement('Relationships'); + $objWriter->writeAttribute('xmlns', Namespaces::RELATIONSHIPS); + $objWriter->startElement('Relationship'); + $objWriter->writeAttribute('Id', 'rId1'); + $objWriter->writeAttribute('Type', Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION); + $objWriter->writeAttribute('Target', "../pivotCache/pivotCacheDefinition{$cacheIndex}.xml"); + $objWriter->endElement(); + $objWriter->endElement(); + + return $objWriter->getData(); + } + + /** + * Rels for the cache definition part -> its records. + */ + public static function writeCacheRelationships(int $cacheIndex): string + { + $objWriter = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $objWriter->startDocument('1.0', 'UTF-8', 'yes'); + $objWriter->startElement('Relationships'); + $objWriter->writeAttribute('xmlns', Namespaces::RELATIONSHIPS); + $objWriter->startElement('Relationship'); + $objWriter->writeAttribute('Id', 'rId1'); + $objWriter->writeAttribute('Type', Namespaces::RELATIONSHIPS_PIVOT_CACHE_RECORDS); + $objWriter->writeAttribute('Target', "pivotCacheRecords{$cacheIndex}.xml"); + $objWriter->endElement(); + $objWriter->endElement(); + + return $objWriter->getData(); + } + + private static function writePivotFields(XMLWriter $objWriter, WorksheetPivotTable $pivotTable): void + { + $fields = $pivotTable->getFields(); + $objWriter->startElement('pivotFields'); + $objWriter->writeAttribute('count', (string) count($fields)); + + foreach ($fields as $field) { + $objWriter->startElement('pivotField'); + if ($field->isDataField()) { + $objWriter->writeAttribute('dataField', '1'); + $objWriter->writeAttribute('showAll', '0'); + } elseif ($field->getAxis() !== PivotField::AXIS_NONE) { + $objWriter->writeAttribute('axis', $field->getAxis()); + $objWriter->writeAttribute('showAll', '0'); + // items are rebuilt on refresh; emit the default placeholder. + $objWriter->startElement('items'); + $objWriter->writeAttribute('count', '1'); + $objWriter->startElement('item'); + $objWriter->writeAttribute('t', 'default'); + $objWriter->endElement(); + $objWriter->endElement(); + } else { + $objWriter->writeAttribute('showAll', '0'); + } + $objWriter->endElement(); + } + + $objWriter->endElement(); + } + + /** + * @param PivotField[] $fields + */ + private static function writeAxisFields(XMLWriter $objWriter, string $element, array $fields): void + { + if ($fields === []) { + return; + } + + $objWriter->startElement($element); + $objWriter->writeAttribute('count', (string) count($fields)); + foreach ($fields as $field) { + $objWriter->startElement('field'); + $objWriter->writeAttribute('x', (string) $field->getIndex()); + $objWriter->endElement(); + } + $objWriter->endElement(); + } + + /** + * @param PivotField[] $fields + */ + private static function writePageFields(XMLWriter $objWriter, array $fields): void + { + if ($fields !== []) { + $objWriter->startElement('pageFields'); + $objWriter->writeAttribute('count', (string) count($fields)); + foreach ($fields as $field) { + $objWriter->startElement('pageField'); + $objWriter->writeAttribute('fld', (string) $field->getIndex()); + // No item selected -> "(All)"; the hierarchy attribute is required. + $objWriter->writeAttribute('hier', '-1'); + $objWriter->endElement(); + } + $objWriter->endElement(); + } + } + + private static function writeDataFields(XMLWriter $objWriter, WorksheetPivotTable $pivotTable): void + { + $dataFields = $pivotTable->getDataFields(); + if ($dataFields === []) { + return; + } + + $objWriter->startElement('dataFields'); + $objWriter->writeAttribute('count', (string) count($dataFields)); + foreach ($dataFields as $field) { + $objWriter->startElement('dataField'); + $caption = $field->getDataFieldCaption(); + if ($caption !== null && $caption !== '') { + $objWriter->writeAttribute('name', $caption); + } + $objWriter->writeAttribute('fld', (string) $field->getIndex()); + $subtotal = $field->getSubtotal(); + if ($subtotal !== null && $subtotal !== PivotField::SUBTOTAL_SUM) { + $objWriter->writeAttribute('subtotal', $subtotal); + } + $objWriter->writeAttribute('baseField', '0'); + $objWriter->writeAttribute('baseItem', '0'); + $objWriter->endElement(); + } + $objWriter->endElement(); + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Rels.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Rels.php index a913767..8a87833 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Rels.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Rels.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Spreadsheet; @@ -148,18 +149,41 @@ class Rels extends WriterPart Namespaces::VBA, 'vbaProject.bin' ); - ++$i; //increment i if needed for an another relation + ++$i; //increment i if needed for another relation } // Metadata needed for Dynamic Arrays - if ($this->getParentWriter()->useDynamicArrays()) { + if ($this->getParentWriter()->useDynamicArrays() || $spreadsheet->hasInCellDrawings()) { $this->writeRelationShip( $objWriter, ($i + 1 + 3), Namespaces::RELATIONSHIPS_METADATA, 'metadata.xml' ); - ++$i; //increment i if needed for an another relation + ++$i; //increment i if needed for another relation + } + + if ($spreadsheet->getActiveSheet()->getInCellDrawingCollection()->count() > 0) { + $i = ($i + 1 + 3); + $this->writeRelationship($objWriter, $i, Namespaces::RELATIONSHIPS_RICH_VALUE, 'richData/rdrichvalue.xml'); + $this->writeRelationship($objWriter, ++$i, Namespaces::RELATIONSHIPS_RICH_VALUE_STRUCTURE, 'richData/rdrichvaluestructure.xml'); + $this->writeRelationship($objWriter, ++$i, Namespaces::RELATIONSHIPS_RICH_VALUE_TYPES, 'richData/rdRichValueTypes.xml'); + $this->writeRelationship($objWriter, ++$i, Namespaces::RELATIONSHIPS_RICH_VALUE_REL, 'richData/richValueRel.xml'); + } + + if ($spreadsheet->getUsesCheckBoxStyle()) { + $this->writeRelationship($objWriter, 'Fpb', Namespaces::RELATIONSHIPS_FEATURE_PROPERTY_BAG, 'featurePropertyBag/featurePropertyBag.xml'); + } + + // Write preserved pivot cache definition relationships. The r:id used + // here is referenced from the element in workbook.xml. + foreach (self::pivotCacheRelationships($spreadsheet) as $rId => $target) { + $this->writeRelationship( + $objWriter, + $rId, + Namespaces::RELATIONSHIPS_PIVOT_CACHE_DEFINITION, + $target + ); } $objWriter->endElement(); @@ -167,6 +191,39 @@ class Rels extends WriterPart return $objWriter->getData(); } + /** + * Build the map of relationship-id => workbook-relative target for each + * preserved pivot cache definition. The relationship id is derived from the + * cache id so the workbook element can reference it. + * + * @return array + */ + public static function pivotCacheRelationships(Spreadsheet $spreadsheet): array + { + /** @var array $unparsedLoadedData */ + $unparsedLoadedData = $spreadsheet->getUnparsedLoadedData(); + /** @var array> $workbookPivotCaches */ + $workbookPivotCaches = $unparsedLoadedData['workbookPivotCaches'] ?? []; + + $relationships = []; + foreach ($workbookPivotCaches as $pivotCache) { + $target = self::relativeToWorkbook($pivotCache['cacheDefinitionPath']); + if ($target !== null) { + $relationships['_pivotCacheDef_' . $pivotCache['cacheId']] = $target; + } + } + + return $relationships; + } + + /** + * Convert an "xl/..." archive path to a target relative to workbook.xml. + */ + private static function relativeToWorkbook(string $path): ?string + { + return str_starts_with($path, 'xl/') ? substr($path, 3) : null; + } + /** * Write worksheet relationships to XML format. * @@ -309,6 +366,19 @@ class Rels extends WriterPart $this->writeUnparsedRelationship($worksheet, $objWriter, 'vmlDrawings', Namespaces::VML); $this->writeUnparsedRelationship($worksheet, $objWriter, 'printerSettings', Namespaces::RELATIONSHIPS_PRINTER_SETTINGS); + // Write preserved pivot table relationships + $pivotTables = $unparsedLoadedData['sheets'][$worksheet->getCodeName()]['pivotTables'] ?? []; + $pivotIndex = 1; + foreach ($pivotTables as $pivotTable) { + /** @var string[] $pivotTable */ + $this->writeRelationship( + $objWriter, + '_pivotTable_' . $pivotIndex++, + Namespaces::RELATIONSHIPS_PIVOT_TABLE, + $pivotTable['relFilePath'] + ); + } + $objWriter->endElement(); return $objWriter->getData(); @@ -345,6 +415,12 @@ class Rels extends WriterPart */ public function writeDrawingRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet, int &$chartRef, bool $includeCharts = false): string { + // Check if we should use pass-through relationships + $passThroughRels = $this->getPassThroughDrawingRelationships($worksheet); + if ($passThroughRels !== null) { + return $passThroughRels; + } + // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -517,10 +593,30 @@ class Rels extends WriterPart $objWriter, $i, Namespaces::HYPERLINK, - $drawing->getHyperlink()->getUrl(), + Preg::replace('~^sheet://~', '#', $drawing->getHyperlink()->getUrl()), $drawing->getHyperlink()->getTypeHyperlink() ); return $i; } + + /** + * Get pass-through drawing relationships XML if available. + * + * Note: When pass-through is used, the original relationships are returned as-is. + * This means any drawings (images, charts, shapes) added programmatically after + * loading will not be included in the relationships. This is a known limitation + * when combining pass-through with drawing modifications. + */ + private function getPassThroughDrawingRelationships(\PhpOffice\PhpSpreadsheet\Worksheet\Worksheet $worksheet): ?string + { + /** @var array> $sheets */ + $sheets = $worksheet->getParentOrThrow()->getUnparsedLoadedData()['sheets'] ?? []; + $sheetData = $sheets[$worksheet->getCodeName()] ?? []; + if (($sheetData['drawingPassThroughEnabled'] ?? false) !== true || !is_string($sheetData['drawingRelationships'] ?? null)) { + return null; + } + + return $sheetData['drawingRelationships']; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/RichDataDrawing.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/RichDataDrawing.php new file mode 100644 index 0000000..0725888 --- /dev/null +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/RichDataDrawing.php @@ -0,0 +1,191 @@ + [path => XML content] + */ + public function generateFiles(Spreadsheet $spreadsheet): array + { + $worksheetCount = $spreadsheet->getSheetCount(); + + $index = 0; + for ($i = 0; $i < $worksheetCount; ++$i) { + $worksheet = $spreadsheet->getSheet($i); + $iterator = $worksheet->getInCellDrawingCollection()->getIterator(); + while ($iterator->valid()) { + /** @var Drawing $pDrawing */ + $pDrawing = $iterator->current(); + $indexedFilename = $pDrawing->getIndexedFilename(); + if (!isset($this->drawings[$indexedFilename])) { + $pDrawing->setIndex(++$index); + $this->drawings[$indexedFilename] = $pDrawing; + } else { + $pDrawing->setIndex($this->drawings[$indexedFilename]->getIndex()); + } + $iterator->next(); + } + } + + return (count($this->drawings) === 0) ? [] : [ + 'xl/richData/rdrichvalue.xml' => $this->writeRdrichvalueXML(), + 'xl/richData/rdrichvaluestructure.xml' => $this->writeRdrichvaluestructureXML(), + 'xl/richData/rdRichValueTypes.xml' => $this->writeRdRichValueTypesXML(), + 'xl/richData/richValueRel.xml' => $this->writeRichValueRelXML(), + 'xl/richData/_rels/richValueRel.xml.rels' => $this->writeRichValueRelRelsXML(), + ]; + } + + /** + * @return Drawing[] + */ + public function getDrawings(): array + { + return $this->drawings; + } + + private function writeRdrichvalueXML(): string + { + $xml = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $xml->startDocument('1.0', 'UTF-8', 'yes'); + $xml->startElement('rvData'); + $xml->writeAttribute('xmlns', 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata'); + $xml->writeAttribute('count', (string) count($this->drawings)); + + $index = 0; + foreach ($this->drawings as $drawing) { + $xml->startElement('rv'); + $xml->writeAttribute('s', '0'); + $xml->writeElement('v', (string) $index++); + $xml->writeElement('v', '5'); + $xml->endElement(); // rv + } + + $xml->endElement(); // rvData + + return $xml->getData(); + } + + private function writeRdrichvaluestructureXML(): string + { + $xml = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $xml->startDocument('1.0', 'UTF-8', 'yes'); + $xml->startElement('rvStructures'); + $xml->writeAttribute('xmlns', 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata'); + $xml->writeAttribute('count', '1'); + + $xml->startElement('s'); + $xml->writeAttribute('t', '_localImage'); + + $xml->startElement('k'); + $xml->writeAttribute('n', '_rvRel:LocalImageIdentifier'); + $xml->writeAttribute('t', 'i'); + $xml->endElement(); + + $xml->startElement('k'); + $xml->writeAttribute('n', 'CalcOrigin'); + $xml->writeAttribute('t', 'i'); + $xml->endElement(); + + $xml->endElement(); // s + + $xml->endElement(); // rvStructures + + return $xml->getData(); + } + + private function writeRdRichValueTypesXML(): string + { + $xml = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $xml->startDocument('1.0', 'UTF-8', 'yes'); + $xml->startElement('rvTypesInfo'); + $xml->writeAttribute('xmlns', 'http://schemas.microsoft.com/office/spreadsheetml/2017/richdata2'); + $xml->writeAttribute('xmlns:mc', 'http://schemas.openxmlformats.org/markup-compatibility/2006'); + $xml->writeAttribute('mc:Ignorable', 'x'); + $xml->writeAttribute('xmlns:x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + + $xml->startElement('global'); + $xml->startElement('keyFlags'); + + $keys = [ + '_Self', '_DisplayString', '_Flags', '_Format', + '_SubLabel', '_Attribution', '_Icon', '_Display', + '_CanonicalPropertyNames', '_ClassificationId', + ]; + + foreach ($keys as $key) { + $xml->startElement('key'); + $xml->writeAttribute('name', $key); + + $xml->startElement('flag'); + $xml->writeAttribute('name', 'ExcludeFromCalcComparison'); + $xml->writeAttribute('value', '1'); + if ($key === '_Self') { + $xml->startElement('flag'); + $xml->writeAttribute('name', 'ExcludeFromFile'); + $xml->writeAttribute('value', '1'); + $xml->endElement(); + } + $xml->endElement(); // flag + $xml->endElement(); // key + } + + $xml->endElement(); // keyFlags + $xml->endElement(); // global + $xml->endElement(); // rvTypesInfo + + return $xml->getData(); + } + + private function writeRichValueRelXML(): string + { + $xml = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $xml->startDocument('1.0', 'UTF-8', 'yes'); + $xml->startElement('richValueRels'); + $xml->writeAttribute('xmlns', 'http://schemas.microsoft.com/office/spreadsheetml/2022/richvaluerel'); + $xml->writeAttribute('xmlns:r', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships'); + + $index = 0; + foreach ($this->drawings as $drawing) { + $xml->startElement('rel'); + $xml->writeAttribute('r:id', 'rId' . ++$index); + $xml->endElement(); + } + + $xml->endElement(); // richValueRels + + return $xml->getData(); + } + + private function writeRichValueRelRelsXML(): string + { + $xml = new XMLWriter(XMLWriter::STORAGE_MEMORY); + $xml->startDocument('1.0', 'UTF-8', 'yes'); + $xml->startElement('Relationships'); + $xml->writeAttribute('xmlns', 'http://schemas.openxmlformats.org/package/2006/relationships'); + + $index = 0; + foreach ($this->drawings as $drawing) { + $xml->startElement('Relationship'); + $xml->writeAttribute('Id', 'rId' . ++$index); + $xml->writeAttribute('Type', 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/image'); + $xml->writeAttribute('Target', '../media/' . $drawing->getIndexedFilename()); + $xml->endElement(); + } + + $xml->endElement(); // Relationships + + return $xml->getData(); + } +} diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Style.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Style.php index 9b79301..1d554c8 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Style.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Style.php @@ -511,6 +511,18 @@ class Style extends WriterPart $objWriter->endElement(); } + if ($style->getCheckBox()) { + $objWriter->startElement('extLst'); + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', Namespaces::STYLE_CHECKBOX_URI); + $objWriter->writeAttribute('xmlns:xfpb', Namespaces::FEATURE_PROPERTY_BAG); + $objWriter->startElement('xfpb:xfComplement'); + $objWriter->writeAttribute('i', '0'); + $objWriter->endElement(); //xfpb:xfComplement + $objWriter->endElement(); //ext + $objWriter->endElement(); //extLst + } + $objWriter->endElement(); } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Table.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Table.php index 4a1364e..3cfd945 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Table.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Table.php @@ -3,6 +3,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Exception as PhpSpreadsheetException; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Worksheet\Table as WorksheetTable; @@ -31,7 +32,7 @@ class Table extends WriterPart // Table $name = 'Table' . $tableRef; - $range = $table->getRange(); + $range = $this->rangeWithRoomForData($table); $objWriter->startElement('table'); $objWriter->writeAttribute('xmlns', Namespaces::MAIN); @@ -111,4 +112,47 @@ class Table extends WriterPart // Return return $objWriter->getData(); } + + /** + * The range to write, given that a table showing a header row needs a row of data under it. + * + * Excel reports a workbook whose table covers its header row alone as unreadable, and repairs + * it by dropping the table — the markup is lost without a word. Excel itself never writes such + * a table: asked to make one over a single row of headings, it writes the table over the row + * below as well, and leaves that row empty (its `sheetData` holds no cell for it). This does + * the same, so what is written is what Excel would have written. + * + * The row below is taken only when it holds nothing: a table silently swallowing a row that + * belongs to something else would change what the sheet says, so that case is refused instead. + * + * @throws PhpSpreadsheetException + */ + private function rangeWithRoomForData(WorksheetTable $table): string + { + $range = $table->getRange(); + + if (!$table->getShowHeaderRow()) { + return $range; + } + + [$rangeStart, $rangeEnd] = Coordinate::rangeBoundaries($range); + + if ($rangeEnd[1] > $rangeStart[1]) { + return $range; + } + + $worksheet = $table->getWorksheet(); + $rowBelow = $rangeEnd[1] + 1; + + if ($worksheet !== null && $worksheet->getHighestDataRow() >= $rowBelow) { + throw new PhpSpreadsheetException( + 'Table ' . $table->getName() . ' shows a header row over ' . $range + . ', which leaves no row for its data, and row ' . $rowBelow + . ' below it is not empty; a table with a header row needs at least 2 rows' + ); + } + + return Coordinate::stringFromColumnIndex($rangeStart[0]) . $rangeStart[1] + . ':' . Coordinate::stringFromColumnIndex($rangeEnd[0]) . $rowBelow; + } } diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Workbook.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Workbook.php index b9aa9b6..0482ecc 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Workbook.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Workbook.php @@ -59,12 +59,45 @@ class Workbook extends WriterPart // calcPr $this->writeCalcPr($objWriter, $preCalculateFormulas, $forceFullCalc); + // pivotCaches (preserved pivot cache registry) + $this->writePivotCaches($objWriter, $spreadsheet); + $objWriter->endElement(); // Return return $objWriter->getData(); } + /** + * Write the pivotCaches registry, mapping each preserved cache id to the + * workbook relationship that points at its cache definition part. + */ + private function writePivotCaches(XMLWriter $objWriter, Spreadsheet $spreadsheet): void + { + /** @var array $unparsedLoadedData */ + $unparsedLoadedData = $spreadsheet->getUnparsedLoadedData(); + /** @var array> $workbookPivotCaches */ + $workbookPivotCaches = $unparsedLoadedData['workbookPivotCaches'] ?? []; + if ($workbookPivotCaches === []) { + return; + } + + $relationships = Rels::pivotCacheRelationships($spreadsheet); + + $objWriter->startElement('pivotCaches'); + foreach ($workbookPivotCaches as $pivotCache) { + $rId = '_pivotCacheDef_' . $pivotCache['cacheId']; + if (isset($relationships[$rId])) { + $objWriter->startElement('pivotCache'); + $objWriter->writeAttribute('cacheId', $pivotCache['cacheId']); + // The relationship writer prefixes ids with "rId". + $objWriter->writeAttribute('r:id', 'rId' . $rId); + $objWriter->endElement(); + } + } + $objWriter->endElement(); + } + /** * Write file version. */ diff --git a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php index 5e445f6..5ae4f15 100644 --- a/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php +++ b/vendor/phpoffice/phpspreadsheet/src/PhpSpreadsheet/Writer/Xlsx/Worksheet.php @@ -18,8 +18,11 @@ use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalDataBar; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalFormattingRuleExtension; use PhpOffice\PhpSpreadsheet\Style\ConditionalFormatting\ConditionalIconSet; use PhpOffice\PhpSpreadsheet\Style\Font; +use PhpOffice\PhpSpreadsheet\Worksheet\BaseDrawing; use PhpOffice\PhpSpreadsheet\Worksheet\RowDimension; use PhpOffice\PhpSpreadsheet\Worksheet\SheetView; +use PhpOffice\PhpSpreadsheet\Worksheet\Sparkline\SparklineGroup; +use PhpOffice\PhpSpreadsheet\Worksheet\Sparkline\SparklineType; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet as PhpspreadsheetWorksheet; class Worksheet extends WriterPart @@ -34,6 +37,8 @@ class Worksheet extends WriterPart private string $evalError = ''; + private string $misleadingFormat = ''; + private bool $explicitStyle0; private bool $useDynamicArrays = false; @@ -58,6 +63,7 @@ class Worksheet extends WriterPart $this->formulaRange = ''; $this->twoDigitTextYear = ''; $this->evalError = ''; + $this->misleadingFormat = ''; // Create XML writer $objWriter = null; if ($this->getParentWriter()->getUseDiskCaching()) { @@ -176,7 +182,13 @@ class Worksheet extends WriterPart } $objWriter->startElement('ignoredError'); $objWriter->writeAttribute('sqref', substr($cells, 1)); - $objWriter->writeAttribute($attr, '1'); + if ($attr === 'misleadingFormat') { + $prefix = 'x16r3'; + $objWriter->writeAttribute("xmlns:$prefix", Namespaces::MISLEADING_FORMAT); + $objWriter->writeAttribute("$prefix:$attr", '1'); + } else { + $objWriter->writeAttribute($attr, '1'); + } $objWriter->endElement(); } } @@ -189,6 +201,7 @@ class Worksheet extends WriterPart $this->writeIgnoredError($objWriter, $started, 'formulaRange', $this->formulaRange); $this->writeIgnoredError($objWriter, $started, 'twoDigitTextYear', $this->twoDigitTextYear); $this->writeIgnoredError($objWriter, $started, 'evalError', $this->evalError); + $this->writeIgnoredError($objWriter, $started, 'misleadingFormat', $this->misleadingFormat); if ($started) { $objWriter->endElement(); } @@ -695,8 +708,7 @@ class Worksheet extends WriterPart $objWriter->writeAttribute($attrKey, $val); } $minCfvo = $dataBar->getMinimumConditionalFormatValueObject(); - // Phpstan is wrong about the next statement. - if ($minCfvo !== null) { // @phpstan-ignore-line + if ($minCfvo !== null) { // @phpstan-ignore notIdentical.alwaysTrue (phpstan is wrong) $objWriter->startElementNs($prefix, 'cfvo', null); $objWriter->writeAttribute('type', $minCfvo->getType()); if ($minCfvo->getCellFormula()) { @@ -706,8 +718,7 @@ class Worksheet extends WriterPart } $maxCfvo = $dataBar->getMaximumConditionalFormatValueObject(); - // Phpstan is wrong about the next statement. - if ($maxCfvo !== null) { // @phpstan-ignore-line + if ($maxCfvo !== null) { // @phpstan-ignore notIdentical.alwaysTrue (phpstan is wrong) $objWriter->startElementNs($prefix, 'cfvo', null); $objWriter->writeAttribute('type', $maxCfvo->getType()); if ($maxCfvo->getCellFormula()) { @@ -1085,6 +1096,13 @@ class Worksheet extends WriterPart if ($hyperlink->getTooltip() !== '') { $objWriter->writeAttribute('tooltip', $hyperlink->getTooltip()); + } + if ($hyperlink->getDisplay() !== '') { + $objWriter->writeAttribute('display', $hyperlink->getDisplay()); + } elseif ($hyperlink->getTooltip() !== '') { + // Probably shouldn't do this, + // but avoids a breaking change. + // This was introduced in PR 904 in 2019. $objWriter->writeAttribute('display', $hyperlink->getTooltip()); } @@ -1384,9 +1402,12 @@ class Worksheet extends WriterPart /** @var array $cellsByRow */ $cellsByRow = []; foreach ($worksheet->getCoordinates() as $coordinate) { - [$column, $row] = Coordinate::coordinateFromString($coordinate); + $column = ''; + $row = 0; + sscanf($coordinate, '%[A-Z]%d', $column, $row); + /** @var int $row */ if (!isset($cellsByRow[$row])) { - $pCell = $worksheet->getCell("$column$row"); + $pCell = $worksheet->getCell($coordinate); $xfi = $pCell->getXfIndex(); $cellValue = $pCell->getValue(); $writeValue = $cellValue !== '' && $cellValue !== null; @@ -1469,22 +1490,27 @@ class Worksheet extends WriterPart foreach ($columnsInRow as $column) { // Write cell $coord = "$column$currentRow"; - if ($worksheet->getCell($coord)->getIgnoredErrors()->getNumberStoredAsText()) { + $pCell = $worksheet->getCell($coord); + $ignoredErrors = $pCell->getIgnoredErrors(); + if ($ignoredErrors->getNumberStoredAsText()) { $this->numberStoredAsText .= " $coord"; } - if ($worksheet->getCell($coord)->getIgnoredErrors()->getFormula()) { + if ($ignoredErrors->getFormula()) { $this->formula .= " $coord"; } - if ($worksheet->getCell($coord)->getIgnoredErrors()->getFormulaRange()) { + if ($ignoredErrors->getFormulaRange()) { $this->formulaRange .= " $coord"; } - if ($worksheet->getCell($coord)->getIgnoredErrors()->getTwoDigitTextYear()) { + if ($ignoredErrors->getTwoDigitTextYear()) { $this->twoDigitTextYear .= " $coord"; } - if ($worksheet->getCell($coord)->getIgnoredErrors()->getEvalError()) { + if ($ignoredErrors->getEvalError()) { $this->evalError .= " $coord"; } - $this->writeCell($objWriter, $worksheet, $coord, $aFlippedStringTable); + if ($ignoredErrors->getMisleadingFormat()) { + $this->misleadingFormat .= " $coord"; + } + $this->writeCell($objWriter, $pCell, $coord, $aFlippedStringTable); } } @@ -1557,11 +1583,24 @@ class Worksheet extends WriterPart $objWriter->writeElement('v', $cellIsFormula ? $formulaerr : $cellValue); } + private function writeCellDrawing(XMLWriter $objWriter, int $index): void + { + $objWriter->writeAttribute('t', 'e'); + $objWriter->writeAttribute('vm', (string) $index); + $objWriter->writeElement('v', '#VALUE!'); + } + private function writeCellFormula(XMLWriter $objWriter, string $cellValue, Cell $cell): void { $attributes = $cell->getFormulaAttributes() ?? []; $coordinate = $cell->getCoordinate(); - $calculatedValue = $this->getParentWriter()->getPreCalculateFormulas() ? $cell->getCalculatedValue() : $cellValue; + $preCalc = $this->getParentWriter()->getPreCalculateFormulas(); + // When pre-calc is off we have no calculated value to infer the cell type from. The + // previous fall-back of $cellValue (the formula source) made every formula cell write + // t="str" because the source is always a string — misleading for formulas that resolve + // to numbers/booleans. Leave $calculatedValue/$calculatedValueString null so the + // type-inference branches below are skipped and no t attribute is written. + $calculatedValue = $preCalc ? $cell->getCalculatedValue() : null; if ($calculatedValue === ExcelError::SPILL()) { $objWriter->writeAttribute('t', 'e'); //$objWriter->writeAttribute('cm', '1'); // already added @@ -1577,7 +1616,10 @@ class Worksheet extends WriterPart return; } - $calculatedValueString = $this->getParentWriter()->getPreCalculateFormulas() ? $cell->getCalculatedValueString() : $cellValue; + // Empty string (not null) so str_starts_with($calculatedValueString, '#') below stays + // type-correct when pre-calc is off; the surrounding writeElementIf condition guards + // against actually emitting when there is no calculated value. + $calculatedValueString = $preCalc ? $cell->getCalculatedValueString() : ''; $result = $calculatedValue; while (is_array($result)) { $result = array_shift($result); @@ -1670,13 +1712,11 @@ class Worksheet extends WriterPart * @param string $cellAddress Cell Address * @param string[] $flippedStringTable String table (flipped), for faster index searching */ - private function writeCell(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet, string $cellAddress, array $flippedStringTable): void + private function writeCell(XMLWriter $objWriter, Cell $pCell, string $cellAddress, array $flippedStringTable): void { // Cell - $pCell = $worksheet->getCell($cellAddress); $xfi = $pCell->getXfIndex(); $cellValue = $pCell->getValue(); - $cellValueString = $pCell->getValueString(); $writeValue = $cellValue !== '' && $cellValue !== null; if (empty($xfi) && !$writeValue) { return; @@ -1693,7 +1733,7 @@ class Worksheet extends WriterPart $mappedType = $pCell->getDataType(); if ($mappedType === DataType::TYPE_FORMULA) { if ($this->useDynamicArrays) { - if (preg_match(PhpspreadsheetWorksheet::FUNCTION_LIKE_GROUPBY, $cellValueString) === 1) { + if (preg_match(PhpspreadsheetWorksheet::FUNCTION_LIKE_GROUPBY, $pCell->getValueString()) === 1) { $tempCalc = []; } else { $tempCalc = $pCell->getCalculatedValue(); @@ -1722,11 +1762,11 @@ class Worksheet extends WriterPart break; case 's': // String - $this->writeCellString($objWriter, $mappedType, ($cellValue instanceof RichText) ? $cellValue : $cellValueString, $flippedStringTable); + $this->writeCellString($objWriter, $mappedType, ($cellValue instanceof RichText) ? $cellValue : $pCell->getValueString(), $flippedStringTable); break; case 'f': // Formula - $this->writeCellFormula($objWriter, $cellValueString, $pCell); + $this->writeCellFormula($objWriter, $pCell->getValueString(), $pCell); break; case 'n': // Numeric @@ -1737,9 +1777,16 @@ class Worksheet extends WriterPart case 'b': // Boolean $this->writeCellBoolean($objWriter, $mappedType, (bool) $cellValue); + break; + case 'drawingcell': // DrawingInCell + if ($cellValue instanceof BaseDrawing) { + $index = $cellValue->getIndex(); + $this->writeCellDrawing($objWriter, $index); + } + break; case 'e': // Error - $this->writeCellError($objWriter, $mappedType, $cellValueString); + $this->writeCellError($objWriter, $mappedType, $pCell->getValueString()); } } @@ -1819,7 +1866,7 @@ class Worksheet extends WriterPart /** * write - * only implementation conditionalFormattings. + * implements conditionalFormattings and sparklineGroups. * * @url https://docs.microsoft.com/en-us/openspecs/office_standards/ms-xlsx/07d607af-5618-4ca2-b683-6a78dc0d9627 */ @@ -1836,18 +1883,128 @@ class Worksheet extends WriterPart } } + $sparklineGroups = $worksheet->getSparklineGroupCollection(); + + if (count($conditionalFormattingRuleExtList) === 0 && count($sparklineGroups) === 0) { + return; + } + + $objWriter->startElement('extLst'); + if (count($conditionalFormattingRuleExtList) > 0) { - $conditionalFormattingRuleExtNsPrefix = 'x14'; - $objWriter->startElement('extLst'); $objWriter->startElement('ext'); $objWriter->writeAttribute('uri', '{78C0D931-6437-407d-A8EE-F0AAD7539E65}'); - $objWriter->startElementNs($conditionalFormattingRuleExtNsPrefix, 'conditionalFormattings', null); + $objWriter->startElementNs('x14', 'conditionalFormattings', null); foreach ($conditionalFormattingRuleExtList as $extension) { self::writeExtConditionalFormattingElements($objWriter, $extension); } $objWriter->endElement(); //end conditionalFormattings $objWriter->endElement(); //end ext - $objWriter->endElement(); //end extLst + } + + if (count($sparklineGroups) > 0) { + $this->writeSparklineGroups($objWriter, $worksheet); + } + + $objWriter->endElement(); //end extLst + } + + /** + * Write the sparkline groups `ext` element. + */ + private function writeSparklineGroups(XMLWriter $objWriter, PhpspreadsheetWorksheet $worksheet): void + { + $objWriter->startElement('ext'); + $objWriter->writeAttribute('uri', Namespaces::SPARKLINE_URI); + $objWriter->startElementNs('x14', 'sparklineGroups', null); + $objWriter->writeAttribute('xmlns:xm', Namespaces::DATA_VALIDATIONS2); + + foreach ($worksheet->getSparklineGroupCollection() as $group) { + /** @var SparklineGroup $group */ + $this->writeSparklineGroup($objWriter, $group); + } + + $objWriter->endElement(); //end sparklineGroups + $objWriter->endElement(); //end ext + } + + private function writeSparklineGroup(XMLWriter $objWriter, SparklineGroup $group): void + { + $objWriter->startElementNs('x14', 'sparklineGroup', null); + + if ($group->getManualMax() !== null) { + $objWriter->writeAttribute('manualMax', (string) $group->getManualMax()); + } + if ($group->getManualMin() !== null) { + $objWriter->writeAttribute('manualMin', (string) $group->getManualMin()); + } + $objWriter->writeAttribute('lineWeight', self::formatNumber($group->getLineWeight())); + if ($group->getType() !== SparklineType::Line) { + $objWriter->writeAttribute('type', $group->getType()->value); + } + if ($group->getDisplayEmptyCellsAs() !== SparklineGroup::EMPTY_AS_ZERO) { + $objWriter->writeAttribute('displayEmptyCellsAs', $group->getDisplayEmptyCellsAs()); + } + + self::writeBoolAttr($objWriter, 'markers', $group->getDisplayMarkers()); + self::writeBoolAttr($objWriter, 'high', $group->getDisplayHigh()); + self::writeBoolAttr($objWriter, 'low', $group->getDisplayLow()); + self::writeBoolAttr($objWriter, 'first', $group->getDisplayFirst()); + self::writeBoolAttr($objWriter, 'last', $group->getDisplayLast()); + self::writeBoolAttr($objWriter, 'negative', $group->getDisplayNegative()); + self::writeBoolAttr($objWriter, 'displayXAxis', $group->getDisplayXAxis()); + self::writeBoolAttr($objWriter, 'displayHidden', $group->getDisplayHidden()); + + if ($group->getMinAxisType() !== SparklineGroup::AXIS_INDIVIDUAL) { + $objWriter->writeAttribute('minAxisType', $group->getMinAxisType()); + } + if ($group->getMaxAxisType() !== SparklineGroup::AXIS_INDIVIDUAL) { + $objWriter->writeAttribute('maxAxisType', $group->getMaxAxisType()); + } + self::writeBoolAttr($objWriter, 'rightToLeft', $group->getRightToLeft()); + + self::writeSparklineColor($objWriter, 'colorSeries', $group->getColorSeries()); + self::writeSparklineColor($objWriter, 'colorNegative', $group->getColorNegative()); + self::writeSparklineColor($objWriter, 'colorAxis', $group->getColorAxis()); + self::writeSparklineColor($objWriter, 'colorMarkers', $group->getColorMarkers()); + self::writeSparklineColor($objWriter, 'colorFirst', $group->getColorFirst()); + self::writeSparklineColor($objWriter, 'colorLast', $group->getColorLast()); + self::writeSparklineColor($objWriter, 'colorHigh', $group->getColorHigh()); + self::writeSparklineColor($objWriter, 'colorLow', $group->getColorLow()); + + $objWriter->startElementNs('x14', 'sparklines', null); + foreach ($group->getSparklines() as $sparkline) { + $objWriter->startElementNs('x14', 'sparkline', null); + $objWriter->writeElementNs('xm', 'f', null, $sparkline->getDataRange()); + $objWriter->writeElementNs('xm', 'sqref', null, $sparkline->getLocation()); + $objWriter->endElement(); //end sparkline + } + $objWriter->endElement(); //end sparklines + + $objWriter->endElement(); //end sparklineGroup + } + + private static function writeBoolAttr(XMLWriter $objWriter, string $name, bool $value): void + { + if ($value) { + $objWriter->writeAttribute($name, '1'); } } + + private static function writeSparklineColor(XMLWriter $objWriter, string $elementName, ?string $rgb): void + { + if ($rgb === null) { + return; + } + $objWriter->startElementNs('x14', $elementName, null); + $objWriter->writeAttribute('rgb', $rgb); + $objWriter->endElement(); + } + + private static function formatNumber(float $value): string + { + $formatted = rtrim(rtrim(sprintf('%.4F', $value), '0'), '.'); + + return ($formatted === '' || $formatted === '-0') ? '0' : $formatted; + } } diff --git a/vendor/psr/http-client/CHANGELOG.md b/vendor/psr/http-client/CHANGELOG.md deleted file mode 100644 index babba7c..0000000 --- a/vendor/psr/http-client/CHANGELOG.md +++ /dev/null @@ -1,31 +0,0 @@ -# Changelog - -All notable changes to this project will be documented in this file, in reverse chronological order by release. - -## 1.0.3 - -Add `source` link in composer.json. No code changes. - -## 1.0.2 - -Allow PSR-7 (psr/http-message) 2.0. No code changes. - -## 1.0.1 - -Allow installation with PHP 8. No code changes. - -## 1.0.0 - -First stable release. No changes since 0.3.0. - -## 0.3.0 - -Added Interface suffix on exceptions - -## 0.2.0 - -All exceptions are in `Psr\Http\Client` namespace - -## 0.1.0 - -First release diff --git a/vendor/psr/http-client/LICENSE b/vendor/psr/http-client/LICENSE deleted file mode 100644 index cd5e002..0000000 --- a/vendor/psr/http-client/LICENSE +++ /dev/null @@ -1,19 +0,0 @@ -Copyright (c) 2017 PHP Framework Interoperability Group - -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/vendor/psr/http-client/README.md b/vendor/psr/http-client/README.md deleted file mode 100644 index 84af5c5..0000000 --- a/vendor/psr/http-client/README.md +++ /dev/null @@ -1,12 +0,0 @@ -HTTP Client -=========== - -This repository holds all the common code related to [PSR-18 (HTTP Client)][psr-url]. - -Note that this is not a HTTP Client implementation of its own. It is merely abstractions that describe the components of a HTTP Client. - -The installable [package][package-url] and [implementations][implementation-url] are listed on Packagist. - -[psr-url]: https://www.php-fig.org/psr/psr-18 -[package-url]: https://packagist.org/packages/psr/http-client -[implementation-url]: https://packagist.org/providers/psr/http-client-implementation diff --git a/vendor/psr/http-client/composer.json b/vendor/psr/http-client/composer.json deleted file mode 100644 index 6fed350..0000000 --- a/vendor/psr/http-client/composer.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "psr/http-client", - "description": "Common interface for HTTP clients", - "keywords": ["psr", "psr-18", "http", "http-client"], - "homepage": "https://github.com/php-fig/http-client", - "license": "MIT", - "authors": [ - { - "name": "PHP-FIG", - "homepage": "https://www.php-fig.org/" - } - ], - "support": { - "source": "https://github.com/php-fig/http-client" - }, - "require": { - "php": "^7.0 || ^8.0", - "psr/http-message": "^1.0 || ^2.0" - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Client\\": "src/" - } - }, - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - } -} diff --git a/vendor/psr/http-client/src/ClientExceptionInterface.php b/vendor/psr/http-client/src/ClientExceptionInterface.php deleted file mode 100644 index aa0b9cf..0000000 --- a/vendor/psr/http-client/src/ClientExceptionInterface.php +++ /dev/null @@ -1,10 +0,0 @@ -=7.1", - "psr/http-message": "^1.0 || ^2.0" - }, - "autoload": { - "psr-4": { - "Psr\\Http\\Message\\": "src/" - } - }, - "extra": { - "branch-alias": { - "dev-master": "1.0.x-dev" - } - } -} diff --git a/vendor/psr/http-factory/src/RequestFactoryInterface.php b/vendor/psr/http-factory/src/RequestFactoryInterface.php deleted file mode 100644 index cb39a08..0000000 --- a/vendor/psr/http-factory/src/RequestFactoryInterface.php +++ /dev/null @@ -1,18 +0,0 @@ - `RequestInterface`, `ServerRequestInterface`, `ResponseInterface` extend `MessageInterface` because the `Request` and the `Response` are `HTTP Messages`. -> When using `ServerRequestInterface`, both `RequestInterface` and `Psr\Http\Message\MessageInterface` methods are considered. - diff --git a/vendor/psr/http-message/docs/PSR7-Usage.md b/vendor/psr/http-message/docs/PSR7-Usage.md deleted file mode 100644 index b6d048a..0000000 --- a/vendor/psr/http-message/docs/PSR7-Usage.md +++ /dev/null @@ -1,159 +0,0 @@ -### PSR-7 Usage - -All PSR-7 applications comply with these interfaces -They were created to establish a standard between middleware implementations. - -> `RequestInterface`, `ServerRequestInterface`, `ResponseInterface` extend `MessageInterface` because the `Request` and the `Response` are `HTTP Messages`. -> When using `ServerRequestInterface`, both `RequestInterface` and `Psr\Http\Message\MessageInterface` methods are considered. - - -The following examples will illustrate how basic operations are done in PSR-7. - -##### Examples - - -For this examples to work (at least) a PSR-7 implementation package is required. (eg: zendframework/zend-diactoros, guzzlehttp/psr7, slim/slim, etc) -All PSR-7 implementations should have the same behaviour. - -The following will be assumed: -`$request` is an object of `Psr\Http\Message\RequestInterface` and - -`$response` is an object implementing `Psr\Http\Message\RequestInterface` - - -### Working with HTTP Headers - -#### Adding headers to response: - -```php -$response->withHeader('My-Custom-Header', 'My Custom Message'); -``` - -#### Appending values to headers - -```php -$response->withAddedHeader('My-Custom-Header', 'The second message'); -``` - -#### Checking if header exists: - -```php -$request->hasHeader('My-Custom-Header'); // will return false -$response->hasHeader('My-Custom-Header'); // will return true -``` - -> Note: My-Custom-Header was only added in the Response - -#### Getting comma-separated values from a header (also applies to request) - -```php -// getting value from request headers -$request->getHeaderLine('Content-Type'); // will return: "text/html; charset=UTF-8" -// getting value from response headers -$response->getHeaderLine('My-Custom-Header'); // will return: "My Custom Message; The second message" -``` - -#### Getting array of value from a header (also applies to request) -```php -// getting value from request headers -$request->getHeader('Content-Type'); // will return: ["text/html", "charset=UTF-8"] -// getting value from response headers -$response->getHeader('My-Custom-Header'); // will return: ["My Custom Message", "The second message"] -``` - -#### Removing headers from HTTP Messages -```php -// removing a header from Request, removing deprecated "Content-MD5" header -$request->withoutHeader('Content-MD5'); - -// removing a header from Response -// effect: the browser won't know the size of the stream -// the browser will download the stream till it ends -$response->withoutHeader('Content-Length'); -``` - -### Working with HTTP Message Body - -When working with the PSR-7 there are two methods of implementation: -#### 1. Getting the body separately - -> This method makes the body handling easier to understand and is useful when repeatedly calling body methods. (You only call `getBody()` once). Using this method mistakes like `$response->write()` are also prevented. - -```php -$body = $response->getBody(); -// operations on body, eg. read, write, seek -// ... -// replacing the old body -$response->withBody($body); -// this last statement is optional as we working with objects -// in this case the "new" body is same with the "old" one -// the $body variable has the same value as the one in $request, only the reference is passed -``` - -#### 2. Working directly on response - -> This method is useful when only performing few operations as the `$request->getBody()` statement fragment is required - -```php -$response->getBody()->write('hello'); -``` - -### Getting the body contents - -The following snippet gets the contents of a stream contents. -> Note: Streams must be rewinded, if content was written into streams, it will be ignored when calling `getContents()` because the stream pointer is set to the last character, which is `\0` - meaning end of stream. -```php -$body = $response->getBody(); -$body->rewind(); // or $body->seek(0); -$bodyText = $body->getContents(); -``` -> Note: If `$body->seek(1)` is called before `$body->getContents()`, the first character will be ommited as the starting pointer is set to `1`, not `0`. This is why using `$body->rewind()` is recommended. - -### Append to body - -```php -$response->getBody()->write('Hello'); // writing directly -$body = $request->getBody(); // which is a `StreamInterface` -$body->write('xxxxx'); -``` - -### Prepend to body -Prepending is different when it comes to streams. The content must be copied before writing the content to be prepended. -The following example will explain the behaviour of streams. - -```php -// assuming our response is initially empty -$body = $repsonse->getBody(); -// writing the string "abcd" -$body->write('abcd'); - -// seeking to start of stream -$body->seek(0); -// writing 'ef' -$body->write('ef'); // at this point the stream contains "efcd" -``` - -#### Prepending by rewriting separately - -```php -// assuming our response body stream only contains: "abcd" -$body = $response->getBody(); -$body->rewind(); -$contents = $body->getContents(); // abcd -// seeking the stream to beginning -$body->rewind(); -$body->write('ef'); // stream contains "efcd" -$body->write($contents); // stream contains "efabcd" -``` - -> Note: `getContents()` seeks the stream while reading it, therefore if the second `rewind()` method call was not present the stream would have resulted in `abcdefabcd` because the `write()` method appends to stream if not preceeded by `rewind()` or `seek(0)`. - -#### Prepending by using contents as a string -```php -$body = $response->getBody(); -$body->rewind(); -$contents = $body->getContents(); // efabcd -$contents = 'ef'.$contents; -$body->rewind(); -$body->write($contents); -``` diff --git a/vendor/psr/http-message/src/MessageInterface.php b/vendor/psr/http-message/src/MessageInterface.php deleted file mode 100644 index a83c985..0000000 --- a/vendor/psr/http-message/src/MessageInterface.php +++ /dev/null @@ -1,187 +0,0 @@ -getHeaders() as $name => $values) { - * echo $name . ": " . implode(", ", $values); - * } - * - * // Emit headers iteratively: - * foreach ($message->getHeaders() as $name => $values) { - * foreach ($values as $value) { - * header(sprintf('%s: %s', $name, $value), false); - * } - * } - * - * While header names are not case-sensitive, getHeaders() will preserve the - * exact case in which headers were originally specified. - * - * @return string[][] Returns an associative array of the message's headers. Each - * key MUST be a header name, and each value MUST be an array of strings - * for that header. - */ - public function getHeaders(): array; - - /** - * Checks if a header exists by the given case-insensitive name. - * - * @param string $name Case-insensitive header field name. - * @return bool Returns true if any header names match the given header - * name using a case-insensitive string comparison. Returns false if - * no matching header name is found in the message. - */ - public function hasHeader(string $name): bool; - - /** - * Retrieves a message header value by the given case-insensitive name. - * - * This method returns an array of all the header values of the given - * case-insensitive header name. - * - * If the header does not appear in the message, this method MUST return an - * empty array. - * - * @param string $name Case-insensitive header field name. - * @return string[] An array of string values as provided for the given - * header. If the header does not appear in the message, this method MUST - * return an empty array. - */ - public function getHeader(string $name): array; - - /** - * Retrieves a comma-separated string of the values for a single header. - * - * This method returns all of the header values of the given - * case-insensitive header name as a string concatenated together using - * a comma. - * - * NOTE: Not all header values may be appropriately represented using - * comma concatenation. For such headers, use getHeader() instead - * and supply your own delimiter when concatenating. - * - * If the header does not appear in the message, this method MUST return - * an empty string. - * - * @param string $name Case-insensitive header field name. - * @return string A string of values as provided for the given header - * concatenated together using a comma. If the header does not appear in - * the message, this method MUST return an empty string. - */ - public function getHeaderLine(string $name): string; - - /** - * Return an instance with the provided value replacing the specified header. - * - * While header names are case-insensitive, the casing of the header will - * be preserved by this function, and returned from getHeaders(). - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * new and/or updated header and value. - * - * @param string $name Case-insensitive header field name. - * @param string|string[] $value Header value(s). - * @return static - * @throws \InvalidArgumentException for invalid header names or values. - */ - public function withHeader(string $name, $value): MessageInterface; - - /** - * Return an instance with the specified header appended with the given value. - * - * Existing values for the specified header will be maintained. The new - * value(s) will be appended to the existing list. If the header did not - * exist previously, it will be added. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * new header and/or value. - * - * @param string $name Case-insensitive header field name to add. - * @param string|string[] $value Header value(s). - * @return static - * @throws \InvalidArgumentException for invalid header names or values. - */ - public function withAddedHeader(string $name, $value): MessageInterface; - - /** - * Return an instance without the specified header. - * - * Header resolution MUST be done without case-sensitivity. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that removes - * the named header. - * - * @param string $name Case-insensitive header field name to remove. - * @return static - */ - public function withoutHeader(string $name): MessageInterface; - - /** - * Gets the body of the message. - * - * @return StreamInterface Returns the body as a stream. - */ - public function getBody(): StreamInterface; - - /** - * Return an instance with the specified message body. - * - * The body MUST be a StreamInterface object. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return a new instance that has the - * new body stream. - * - * @param StreamInterface $body Body. - * @return static - * @throws \InvalidArgumentException When the body is not valid. - */ - public function withBody(StreamInterface $body): MessageInterface; -} diff --git a/vendor/psr/http-message/src/RequestInterface.php b/vendor/psr/http-message/src/RequestInterface.php deleted file mode 100644 index 33f85e5..0000000 --- a/vendor/psr/http-message/src/RequestInterface.php +++ /dev/null @@ -1,130 +0,0 @@ -getQuery()` - * or from the `QUERY_STRING` server param. - * - * @return array - */ - public function getQueryParams(): array; - - /** - * Return an instance with the specified query string arguments. - * - * These values SHOULD remain immutable over the course of the incoming - * request. They MAY be injected during instantiation, such as from PHP's - * $_GET superglobal, or MAY be derived from some other value such as the - * URI. In cases where the arguments are parsed from the URI, the data - * MUST be compatible with what PHP's parse_str() would return for - * purposes of how duplicate query parameters are handled, and how nested - * sets are handled. - * - * Setting query string arguments MUST NOT change the URI stored by the - * request, nor the values in the server params. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated query string arguments. - * - * @param array $query Array of query string arguments, typically from - * $_GET. - * @return static - */ - public function withQueryParams(array $query): ServerRequestInterface; - - /** - * Retrieve normalized file upload data. - * - * This method returns upload metadata in a normalized tree, with each leaf - * an instance of Psr\Http\Message\UploadedFileInterface. - * - * These values MAY be prepared from $_FILES or the message body during - * instantiation, or MAY be injected via withUploadedFiles(). - * - * @return array An array tree of UploadedFileInterface instances; an empty - * array MUST be returned if no data is present. - */ - public function getUploadedFiles(): array; - - /** - * Create a new instance with the specified uploaded files. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated body parameters. - * - * @param array $uploadedFiles An array tree of UploadedFileInterface instances. - * @return static - * @throws \InvalidArgumentException if an invalid structure is provided. - */ - public function withUploadedFiles(array $uploadedFiles): ServerRequestInterface; - - /** - * Retrieve any parameters provided in the request body. - * - * If the request Content-Type is either application/x-www-form-urlencoded - * or multipart/form-data, and the request method is POST, this method MUST - * return the contents of $_POST. - * - * Otherwise, this method may return any results of deserializing - * the request body content; as parsing returns structured content, the - * potential types MUST be arrays or objects only. A null value indicates - * the absence of body content. - * - * @return null|array|object The deserialized body parameters, if any. - * These will typically be an array or object. - */ - public function getParsedBody(); - - /** - * Return an instance with the specified body parameters. - * - * These MAY be injected during instantiation. - * - * If the request Content-Type is either application/x-www-form-urlencoded - * or multipart/form-data, and the request method is POST, use this method - * ONLY to inject the contents of $_POST. - * - * The data IS NOT REQUIRED to come from $_POST, but MUST be the results of - * deserializing the request body content. Deserialization/parsing returns - * structured data, and, as such, this method ONLY accepts arrays or objects, - * or a null value if nothing was available to parse. - * - * As an example, if content negotiation determines that the request data - * is a JSON payload, this method could be used to create a request - * instance with the deserialized parameters. - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated body parameters. - * - * @param null|array|object $data The deserialized body data. This will - * typically be in an array or object. - * @return static - * @throws \InvalidArgumentException if an unsupported argument type is - * provided. - */ - public function withParsedBody($data): ServerRequestInterface; - - /** - * Retrieve attributes derived from the request. - * - * The request "attributes" may be used to allow injection of any - * parameters derived from the request: e.g., the results of path - * match operations; the results of decrypting cookies; the results of - * deserializing non-form-encoded message bodies; etc. Attributes - * will be application and request specific, and CAN be mutable. - * - * @return array Attributes derived from the request. - */ - public function getAttributes(): array; - - /** - * Retrieve a single derived request attribute. - * - * Retrieves a single derived request attribute as described in - * getAttributes(). If the attribute has not been previously set, returns - * the default value as provided. - * - * This method obviates the need for a hasAttribute() method, as it allows - * specifying a default value to return if the attribute is not found. - * - * @see getAttributes() - * @param string $name The attribute name. - * @param mixed $default Default value to return if the attribute does not exist. - * @return mixed - */ - public function getAttribute(string $name, $default = null); - - /** - * Return an instance with the specified derived request attribute. - * - * This method allows setting a single derived request attribute as - * described in getAttributes(). - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that has the - * updated attribute. - * - * @see getAttributes() - * @param string $name The attribute name. - * @param mixed $value The value of the attribute. - * @return static - */ - public function withAttribute(string $name, $value): ServerRequestInterface; - - /** - * Return an instance that removes the specified derived request attribute. - * - * This method allows removing a single derived request attribute as - * described in getAttributes(). - * - * This method MUST be implemented in such a way as to retain the - * immutability of the message, and MUST return an instance that removes - * the attribute. - * - * @see getAttributes() - * @param string $name The attribute name. - * @return static - */ - public function withoutAttribute(string $name): ServerRequestInterface; -} diff --git a/vendor/psr/http-message/src/StreamInterface.php b/vendor/psr/http-message/src/StreamInterface.php deleted file mode 100644 index a62aabb..0000000 --- a/vendor/psr/http-message/src/StreamInterface.php +++ /dev/null @@ -1,158 +0,0 @@ - - * [user-info@]host[:port] - * - * - * If the port component is not set or is the standard port for the current - * scheme, it SHOULD NOT be included. - * - * @see https://tools.ietf.org/html/rfc3986#section-3.2 - * @return string The URI authority, in "[user-info@]host[:port]" format. - */ - public function getAuthority(): string; - - /** - * Retrieve the user information component of the URI. - * - * If no user information is present, this method MUST return an empty - * string. - * - * If a user is present in the URI, this will return that value; - * additionally, if the password is also present, it will be appended to the - * user value, with a colon (":") separating the values. - * - * The trailing "@" character is not part of the user information and MUST - * NOT be added. - * - * @return string The URI user information, in "username[:password]" format. - */ - public function getUserInfo(): string; - - /** - * Retrieve the host component of the URI. - * - * If no host is present, this method MUST return an empty string. - * - * The value returned MUST be normalized to lowercase, per RFC 3986 - * Section 3.2.2. - * - * @see http://tools.ietf.org/html/rfc3986#section-3.2.2 - * @return string The URI host. - */ - public function getHost(): string; - - /** - * Retrieve the port component of the URI. - * - * If a port is present, and it is non-standard for the current scheme, - * this method MUST return it as an integer. If the port is the standard port - * used with the current scheme, this method SHOULD return null. - * - * If no port is present, and no scheme is present, this method MUST return - * a null value. - * - * If no port is present, but a scheme is present, this method MAY return - * the standard port for that scheme, but SHOULD return null. - * - * @return null|int The URI port. - */ - public function getPort(): ?int; - - /** - * Retrieve the path component of the URI. - * - * The path can either be empty or absolute (starting with a slash) or - * rootless (not starting with a slash). Implementations MUST support all - * three syntaxes. - * - * Normally, the empty path "" and absolute path "/" are considered equal as - * defined in RFC 7230 Section 2.7.3. But this method MUST NOT automatically - * do this normalization because in contexts with a trimmed base path, e.g. - * the front controller, this difference becomes significant. It's the task - * of the user to handle both "" and "/". - * - * The value returned MUST be percent-encoded, but MUST NOT double-encode - * any characters. To determine what characters to encode, please refer to - * RFC 3986, Sections 2 and 3.3. - * - * As an example, if the value should include a slash ("/") not intended as - * delimiter between path segments, that value MUST be passed in encoded - * form (e.g., "%2F") to the instance. - * - * @see https://tools.ietf.org/html/rfc3986#section-2 - * @see https://tools.ietf.org/html/rfc3986#section-3.3 - * @return string The URI path. - */ - public function getPath(): string; - - /** - * Retrieve the query string of the URI. - * - * If no query string is present, this method MUST return an empty string. - * - * The leading "?" character is not part of the query and MUST NOT be - * added. - * - * The value returned MUST be percent-encoded, but MUST NOT double-encode - * any characters. To determine what characters to encode, please refer to - * RFC 3986, Sections 2 and 3.4. - * - * As an example, if a value in a key/value pair of the query string should - * include an ampersand ("&") not intended as a delimiter between values, - * that value MUST be passed in encoded form (e.g., "%26") to the instance. - * - * @see https://tools.ietf.org/html/rfc3986#section-2 - * @see https://tools.ietf.org/html/rfc3986#section-3.4 - * @return string The URI query string. - */ - public function getQuery(): string; - - /** - * Retrieve the fragment component of the URI. - * - * If no fragment is present, this method MUST return an empty string. - * - * The leading "#" character is not part of the fragment and MUST NOT be - * added. - * - * The value returned MUST be percent-encoded, but MUST NOT double-encode - * any characters. To determine what characters to encode, please refer to - * RFC 3986, Sections 2 and 3.5. - * - * @see https://tools.ietf.org/html/rfc3986#section-2 - * @see https://tools.ietf.org/html/rfc3986#section-3.5 - * @return string The URI fragment. - */ - public function getFragment(): string; - - /** - * Return an instance with the specified scheme. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified scheme. - * - * Implementations MUST support the schemes "http" and "https" case - * insensitively, and MAY accommodate other schemes if required. - * - * An empty scheme is equivalent to removing the scheme. - * - * @param string $scheme The scheme to use with the new instance. - * @return static A new instance with the specified scheme. - * @throws \InvalidArgumentException for invalid or unsupported schemes. - */ - public function withScheme(string $scheme): UriInterface; - - /** - * Return an instance with the specified user information. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified user information. - * - * Password is optional, but the user information MUST include the - * user; an empty string for the user is equivalent to removing user - * information. - * - * @param string $user The user name to use for authority. - * @param null|string $password The password associated with $user. - * @return static A new instance with the specified user information. - */ - public function withUserInfo(string $user, ?string $password = null): UriInterface; - - /** - * Return an instance with the specified host. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified host. - * - * An empty host value is equivalent to removing the host. - * - * @param string $host The hostname to use with the new instance. - * @return static A new instance with the specified host. - * @throws \InvalidArgumentException for invalid hostnames. - */ - public function withHost(string $host): UriInterface; - - /** - * Return an instance with the specified port. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified port. - * - * Implementations MUST raise an exception for ports outside the - * established TCP and UDP port ranges. - * - * A null value provided for the port is equivalent to removing the port - * information. - * - * @param null|int $port The port to use with the new instance; a null value - * removes the port information. - * @return static A new instance with the specified port. - * @throws \InvalidArgumentException for invalid ports. - */ - public function withPort(?int $port): UriInterface; - - /** - * Return an instance with the specified path. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified path. - * - * The path can either be empty or absolute (starting with a slash) or - * rootless (not starting with a slash). Implementations MUST support all - * three syntaxes. - * - * If the path is intended to be domain-relative rather than path relative then - * it must begin with a slash ("/"). Paths not starting with a slash ("/") - * are assumed to be relative to some base path known to the application or - * consumer. - * - * Users can provide both encoded and decoded path characters. - * Implementations ensure the correct encoding as outlined in getPath(). - * - * @param string $path The path to use with the new instance. - * @return static A new instance with the specified path. - * @throws \InvalidArgumentException for invalid paths. - */ - public function withPath(string $path): UriInterface; - - /** - * Return an instance with the specified query string. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified query string. - * - * Users can provide both encoded and decoded query characters. - * Implementations ensure the correct encoding as outlined in getQuery(). - * - * An empty query string value is equivalent to removing the query string. - * - * @param string $query The query string to use with the new instance. - * @return static A new instance with the specified query string. - * @throws \InvalidArgumentException for invalid query strings. - */ - public function withQuery(string $query): UriInterface; - - /** - * Return an instance with the specified URI fragment. - * - * This method MUST retain the state of the current instance, and return - * an instance that contains the specified URI fragment. - * - * Users can provide both encoded and decoded fragment characters. - * Implementations ensure the correct encoding as outlined in getFragment(). - * - * An empty fragment value is equivalent to removing the fragment. - * - * @param string $fragment The fragment to use with the new instance. - * @return static A new instance with the specified fragment. - */ - public function withFragment(string $fragment): UriInterface; - - /** - * Return the string representation as a URI reference. - * - * Depending on which components of the URI are present, the resulting - * string is either a full URI or relative reference according to RFC 3986, - * Section 4.1. The method concatenates the various components of the URI, - * using the appropriate delimiters: - * - * - If a scheme is present, it MUST be suffixed by ":". - * - If an authority is present, it MUST be prefixed by "//". - * - The path can be concatenated without delimiters. But there are two - * cases where the path has to be adjusted to make the URI reference - * valid as PHP does not allow to throw an exception in __toString(): - * - If the path is rootless and an authority is present, the path MUST - * be prefixed by "/". - * - If the path is starting with more than one "/" and no authority is - * present, the starting slashes MUST be reduced to one. - * - If a query is present, it MUST be prefixed by "?". - * - If a fragment is present, it MUST be prefixed by "#". - * - * @see http://tools.ietf.org/html/rfc3986#section-4.1 - * @return string - */ - public function __toString(): string; -} diff --git a/views/createAllViews.php b/views/createAllViews.php index 17c41ff..eae0cc6 100644 --- a/views/createAllViews.php +++ b/views/createAllViews.php @@ -1,22 +1,6 @@ - - - body { - background-color: #333333; - color: white; - } - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/xml/settings.example.xml b/xml/settings.example.xml new file mode 100644 index 0000000..3f15cff --- /dev/null +++ b/xml/settings.example.xml @@ -0,0 +1,14 @@ + + + Production + localhost + huntingclubroster + configure-via-HCR_DB_USER + + smtp.gmail.com + configure-via-HCR_SMTP_USER + + 587 + noreply@example.com + https://example.com/huntingclubroster + diff --git a/xml/settings.xml b/xml/settings.xml index b1d7364..b7cbe0c 100644 --- a/xml/settings.xml +++ b/xml/settings.xml @@ -1,31 +1,14 @@ - - - Hunting Club Roster - - 192.168.1.190 + Production + localhost huntingclubroster - hcruser - 8EC0QxwRrHknUg - + configure-via-HCR_DB_USER + smtp.gmail.com - info@onesourceits.com - nuwe vuzr tyhs ebwb + configure-via-HCR_SMTP_USER + 587 - - - - https://discordapp.com/api/webhooks/1396804024942198794/zdT2wisqhAZuZ_UPKmAmtV0z_rX-PZnyHIXsjpPHQO3uakBO2xQ3R44kOxb9QZ4QxebL - - info@onesourceits.com - james.richie@onesourceits.com - - - jric11 - - - Development - Available - - \ No newline at end of file + noreply@myhuntingclubroster.com + https://app.myhuntingclubroster.com + diff --git a/xsl/addUser.xsl b/xsl/addUser.xsl index e9ad1c8..05188c2 100644 --- a/xsl/addUser.xsl +++ b/xsl/addUser.xsl @@ -41,8 +41,8 @@
- - + +
diff --git a/xsl/changePassword.xsl b/xsl/changePassword.xsl index 2900739..c68fa9e 100644 --- a/xsl/changePassword.xsl +++ b/xsl/changePassword.xsl @@ -5,63 +5,69 @@ + 1.0 Change Password changePassword - - - +
@@ -114,14 +115,14 @@
Enter your password again.
- +

Your club administrator manages club membership and access.

diff --git a/xsl/editUser.xsl b/xsl/editUser.xsl index d5b59d5..9458c79 100644 --- a/xsl/editUser.xsl +++ b/xsl/editUser.xsl @@ -24,7 +24,7 @@ | | - + @@ -58,8 +58,8 @@
- - + +
@@ -86,7 +86,7 @@
- + diff --git a/xsl/emailNewUserCredentials.xsl b/xsl/emailNewUserCredentials.xsl index 1010ad0..e8da8a9 100644 --- a/xsl/emailNewUserCredentials.xsl +++ b/xsl/emailNewUserCredentials.xsl @@ -1,41 +1,12 @@ - - - - - - - - - -

- Dear , -

- -

Thank you for being a customer of DEC International. Below are your account credentials.

- -

Username:

-

Password:

- -

You login in at the following web address: Atlanta Housing Report

- -

Thank you,

- -

DEC-International Support

- - -
-
-
- -
- + + + +

Hello ,

+

A password reset was requested for your account.

+

Choose a new password.

+

This link expires in 30 minutes and can be used once. If you did not request it, you can ignore this email. Your password has not changed.

+

support

+
diff --git a/xsl/emailPasswordResetLink.xsl b/xsl/emailPasswordResetLink.xsl index c0313ae..e8da8a9 100644 --- a/xsl/emailPasswordResetLink.xsl +++ b/xsl/emailPasswordResetLink.xsl @@ -1,53 +1,12 @@ - - - - - - - - - -

- Dear , -

- -

Your password has been reset. Below are your account credentials.

- -

Password:

- -

Click here to Login.

- -

Thank you,

- -

DEC-International Support

- - -
-
-
- -
- + + + +

Hello ,

+

A password reset was requested for your account.

+

Choose a new password.

+

This link expires in 30 minutes and can be used once. If you did not request it, you can ignore this email. Your password has not changed.

+

support

+
diff --git a/xsl/login.xsl b/xsl/login.xsl index 10b1343..9d9ba4f 100644 --- a/xsl/login.xsl +++ b/xsl/login.xsl @@ -10,6 +10,7 @@ login ./?Xaction=resetpassword + @@ -29,7 +30,7 @@ - + @@ -49,7 +50,7 @@
Less paperwork. More outdoors.

Your club.
Your people.
Your season.

-

A simpler way to keep your hunting club connected, organized, and ready for what’s next.

+

A simpler way to keep your hunting club connected, organized, and ready for what’s next.

+
@@ -94,11 +95,12 @@
- + +
- +
@@ -112,12 +114,13 @@
+
diff --git a/xsl/resetPassword.xsl b/xsl/resetPassword.xsl new file mode 100644 index 0000000..472605c --- /dev/null +++ b/xsl/resetPassword.xsl @@ -0,0 +1,67 @@ + + + + + + + + + + + + + + + + + + + Reset password + + + +
+

+ +

+

Reset password

+ +

+ +

+
+ + + + +
+ + + + + + + + + + + + + + + + + + + +
+

+ Sign in · Request a new link +

+
+ + +
+
diff --git a/xsl/setupUsers.xsl b/xsl/setupUsers.xsl index 90236f3..806ef57 100644 --- a/xsl/setupUsers.xsl +++ b/xsl/setupUsers.xsl @@ -116,7 +116,7 @@
 
- + @@ -149,8 +149,8 @@ -
UsernameClient NameFirst Name Email Role Active - + + @@ -160,7 +160,7 @@ - +