diff --git a/.scrutinizer.yml b/.scrutinizer.yml index c32d10407..c25695582 100644 --- a/.scrutinizer.yml +++ b/.scrutinizer.yml @@ -24,7 +24,7 @@ tools: timeout: 600 build_failure_conditions: - - 'elements.rating(<= C).new.exists' # No new classes/methods with a rating of C or worse allowed + - 'elements.rating(<= D).new.exists' # No new classes/methods with a rating of D or worse allowed - 'issues.severity(>= MAJOR).new.exists' # New issues of major or higher severity - 'project.metric_change("scrutinizer.test_coverage", < 0)' # Code Coverage decreased from previous inspection - 'patches.label("Unused Use Statements").new.exists' # No new unused imports patches allowed diff --git a/CHANGELOG.md b/CHANGELOG.md index 47f32b45c..ea76d3e16 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). ### Added - 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 @@ -33,6 +34,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). - Incorrect SUMPRODUCT Calculation [Issue #3909](https://github.com/PHPOffice/PhpSpreadsheet/issues/3909) [PR #3916](https://github.com/PHPOffice/PhpSpreadsheet/pull/3916) - Formula Misidentifying Text as Cell After Insertion/Deletion [Issue #3907](https://github.com/PHPOffice/PhpSpreadsheet/issues/3907) [PR #3915](https://github.com/PHPOffice/PhpSpreadsheet/pull/3915) - Unexpected Absolute Address in Xlsx Rels [Issue #3730](https://github.com/PHPOffice/PhpSpreadsheet/issues/3730) [PR #3923](https://github.com/PHPOffice/PhpSpreadsheet/pull/3923) +- Unallocated Cells Affected by Column/Row Insert/Delete [Issue #3933](https://github.com/PHPOffice/PhpSpreadsheet/issues/3933) [PR #3940](https://github.com/PHPOffice/PhpSpreadsheet/pull/3940) ## 2.0.0 - 2024-01-04 diff --git a/docs/topics/Behind the Mask.md b/docs/topics/Behind the Mask.md new file mode 100644 index 000000000..9648f52df --- /dev/null +++ b/docs/topics/Behind the Mask.md @@ -0,0 +1,834 @@ +# Behind the Mask + +When we look at a spreadsheet in MS Excel, we normally see it neatly formatted so that it is easy for a human to read. + +Internally, that spreadsheet comprises a set of values that are normally either numbers or text (occasionally boolean `TRUE` or `FALSE`); or a formula that results in a number, text or boolean value. Unlike PHP, MS Excel doesn't differentiate between `integer` or `float`; but all numbers can be presented as integer or with decimals, as dates or percentages, as currency, even made to look like telephone numbers. +A zero value can be made to look like `0` or `0.00`, like `-`, or even like a text string `zero`. Positive values can be displayed in one colour, negative values in another. + +![Stock Portfolio.png](images/Behind the Mask/Stock Portfolio.png) +Behind this Stock Portfolio table example, with the exception of the headings and the stock symbols, every value is a number; but each column is rendered in a manner that provides meaning to our human eye - +`Purchase Date` as a day-month-year date; `Purchase Price` and `Current Price` as monetary values with a currency code; `Purchase Quantity` as an integer value and `Difference` as a float with 2 decimals; `% Return` as a percentage; and `Profit/Loss` as a monetary value with a currency code, thousands separator, and negative values highlighted in red; `Stdev` with 3 decimals - +and all styled by using a Number Format Mask. + +## Reading a Cell Value + +PhpSpreadsheet provides three methods for reading a Cell value. +If we use the Cell's `getValue()` method, we are retrieving the underlying value (or the formula) for that cell. If the Cell contains a formula, then we can use the `getCalculatedValue()` method to see the result of evaluating that formula. If we want to see the value as it is displayed in MS Excel, then we need to use `getFormattedValue()`. + +Reading Cells from the Worksheet shown above: +```php +var_dump($worksheet->getCell('C4')->getValue()); +var_dump($worksheet->getCell('C4')->getCalculatedValue()); +var_dump($worksheet->getCell('C4')->getFormattedValue()); + +var_dump($worksheet->getCell('H4')->getValue()); +var_dump($worksheet->getCell('H4')->getCalculatedValue()); +var_dump($worksheet->getCell('H4')->getFormattedValue()); +``` +we see the different results for cell `C4` (a simple numeric value formatted as a Currency) and cell `H4` (a formula that evaluates to a numeric value, and formatted as a Currency): +``` +float(26.19) +float(26.19) +string(9) "€ 26.19" + +string(8) "=$F4*$D4" +float(-170) +string(11) "€ -170.00" +``` +Note that getting the formatted value will always evaluate a formula to render the result. + +### Reading a Cell's Formatting Mask + +PhpSpreadsheet also provides methods that allow us to look at the format mask itself: +```php +var_dump($worksheet->getCell('C4') + ->getStyle()->getNumberFormat()->getFormatCode()); + +var_dump($worksheet->getCell('H4') + ->getStyle()->getNumberFormat()->getFormatCode()); +``` +and we can see the Format Masks for those cells: +``` +string(20) "[$€-413]\ #,##0.00" + +string(48) "[$€-413]\ #,##0.00;[Red][$€-413]\ \-#,##0.00" +``` +> **Note**: that the space and sign in the mask are non-breaking characters, so they are rendered to output as "\ " and "\-" respectively when var_dumped. This prevents breaking the displayed value across two lines. + +## Setting a Cell's Formatting Mask + +When you are using a spreadsheet application like MS Excel, the application will try to decide what Format Mask should be used for a cell as you enter the value, based on that value and your locale settings; and with varying degrees of success. +If the value looks like a Currency, then it will be converted to a number and an appropriate Currency Mask set; similarly if you type something that looks like a percentage; and it is often a joke that Excel identifies many values as Dates (even if that was never the intent), and sets a Date Format Mask. +The default Mask if no specific type can be identified from the value is "General". + +PhpSpreadsheet doesn't do this by default. If you enter a value in a cell, then it will not convert that value from a string containing a currency symbol to a number: it will remain a string. Nor will it change any existing Format Mask: and if that value is a new cell, then it will be assigned a default Format Mask of "General". +It will convert a string value to a numeric if it looks like a number with or without decimals (but without leading zeroes), or in scientific format; but it still won't change the Format Mask. + +```php +// Set Cell C21 using a formatted string value +$worksheet->getCell('C20')->setValue('€ -1234.567'); + +// The Cell value should be the string that we set +var_dump($worksheet->getCell('C20')->getValue()); +// The Format Mask should be "General" +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); +// The formatted value should still be the string that we set +var_dump($worksheet->getCell('C20')->getFormattedValue()); + +// Set Cell C21 using a numeric value +$worksheet->getCell('C21')->setValue('-1234.567'); + +// The numeric string value should have been converted to a float +var_dump($worksheet->getCell('C21')->getValue()); +// The Format Mask should be "General" +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); +var_dump($worksheet->getCell('C21')->getFormattedValue()); + +// Change the Format Mask for C21 to a Currency mask +$worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->setFormatCode('€ #,##0;€ -#,##0'); + +// The float value should still be the same +var_dump($worksheet->getCell('C21')->getValue()); +// The Format Mask should be the new mask that we set +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); +// The value should now be formatted as a Currency +var_dump($worksheet->getCell('C21')->getFormattedValue()); +``` +giving +```php +string(13) "€ -1234.567" +string(7) "General" +string(13) "€ -1234.567" + +float(-1234.567) +string(7) "General" +string(9) "-1234.567" + +float(-1234.567) +string(20) "€ #,##0;€ -#,##0" +string(10) "€ -1,235" +``` + +If you wish to emulate the MS Excel behaviour, and automatically convert string values that represent Currency, Dates, Fractions, Percentages, etc. then the Advanced Value Binder attempts to identify these, to convert to a number, and to set an appropriate Format Mask. + +You can do this by changing the Value Binder, which will then apply every time you set a Cell value. +```php +Cell::setValueBinder(new AdvancedValueBinder()); + +// Set Cell C21 using a formatted string value +$worksheet->getCell('C20')->setValue('€ -12345.6789'); + +// The Cell value is a float of -12345.6789 +var_dump($worksheet->getCell('C20')->getValue()); +// The format code is "[$€]#,##0.00_-" +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); +// The formatted value is "€-12,345.68 " +var_dump($worksheet->getCell('C20')->getFormattedValue()); +``` + +Or (since version 1.28.0) you can specify a Value Binder to use just for that one call to set the Cell's value. + +```php +// Set Cell C21 using a formatted string value, but using a Value Binder +$worksheet->getCell('C20')->setValue('€ -12345.6789', new AdvancedValueBinder()); + +// The Cell value is a float of -12345.6789 +var_dump($worksheet->getCell('C20')->getValue()); +// The format code is "[$€]#,##0.00_-" +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); +// The formatted value is "€-12,345.68 " +var_dump($worksheet->getCell('C20')->getFormattedValue()); +``` +While PhpSpreadsheet's Advanced Value Binder isn't as "sophisticated" as MS Excel at recognising formats that should be converted to numbers, or at setting a mask that exactly matches the entered value, it can simplify entering data from formatted strings; and is particularly useful when reading untyped or loosely formatted files like a CSV. + +> **Warning**: Remember that setting a Cell value explicitly bypasses the Value Binder, so you will always have to set the Format Mask manually if you are using `setValueExplicit()` to set Cell values. + +## Using Formatting Masks in the TEXT() Function + +We can also use Number Formatting Masks directly in Excel's `TEXT()` Function, without setting the mask for a Cell. + +```php +$worksheet->getCell('A1')->setValue(12345.678); +$worksheet->getCell('B1') + ->setValue('#.00" Surplus";-#.00" Deficit";"Out of Stock"'); +$worksheet->getCell('C1')->setValue('=TEXT(A1,B1)'); + +var_dump($worksheet->getCell('C1')->getCalculatedValue()); // 12,345.68 Surplus + + +$worksheet->getCell('A2')->setValue(-12345.678); +$worksheet->getCell('C2') + ->setValue('=TEXT(A2,"#,##0.00"" Surplus"";-#,##0.00"" Deficit"";""Out of Stock""")'); + +var_dump($worksheet->getCell('C2')->getCalculatedValue()); // -12,345.68 Deficit +``` +Remember that you'll need to escape double quotes in the mask argument by double double-quoting them if you pass the mask directly as an string. +It's generally easier to read if you store the mask as text in a cell, and then pass the cell reference as the mask argument. + +## Changing a Cell's Formatting Mask + +In PhpSpreadsheet we can change a Cell's Formatting Mask at any time just by setting a new FormatCode for that Cell. +The library provides a number of "pre-defined" Masks as Constants in the `NumberFormat` class, prefixed with 'FORMAT_', but isn't limited to these values - the mask itself is just a string value - and the value passed to `setFormatCode()` can be any valid Excel Format Mask string. +> **Note**: The Mask value isn't validated: it's up to you, as the developer, to ensure that you set a meaningful Mask value. + +And while Excel applies an initial Mask to every Cell when we enter a value (even if it's just the default "General"), we can still always change that Mask. +This is managed through the "Number" block in the "Home" ribbon. + +![Excel Number Format.png](images/Behind the Mask/Excel Number Format.png) + +This provides us with some simple options for increasing or decreasing the number of decimals displayed, if we want a thousands separator, a currency code to use, etc. + +But if we use the "pull down" for that block, we access the "Number" tab of "Format Cells" that provides a lot more options. + +![Excel Number Format - General.png](images/Behind the Mask/Excel Number Format - General.png) + +This gives us access to a number of "Wizards" for different "Categories" of masking, as well as "Custom", which allows us to build our own masks. + +Since version 1.28.0, PhpSpreadsheet has also provided a set of "Wizards", allowing for the easier creation of Mask values for most Categories. + +## Mask Categories + +I'll describe "Custom" Mask values later in this article; but let's take a look at the "Wizard" options for each "Category" first. + +### General + +This is the default Mask, and is "adaptive". +Numbers will appear with as many decimals as have been entered for the value (to the limit of a 9 or 10 digit display; additional decimals will be rounded), while very large or very small values will display in Scientific format. + +### Number + +Excel's Number "Wizard" allows you to specify the number of decimals, and whether to use a thousands separator (or not). +It also offers a few ways to display negative values (with or without a sign, highlighted in red). + +![Excel Number Format - Number.png](images/Behind the Mask/Excel Number Format - Number.png) + +A typical mask will look something like '0.00' (2 decimals, with no thousands separator) or '#,##0.000' (3 decimals with a thousands separator). + +The PhpSpreadsheet Number "Wizard" allows you to specify the number of decimals, and the use of a thousands separator. +The defaults are 2 decimal places, and to use a thousands separator. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Number Wizard to build the Format Mask +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode((string) new Number(3, Number::WITH_THOUSANDS_SEPARATOR)); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); // "#,##0.000" +var_dump($worksheet->getCell('C20')->getFormattedValue()); // "-12,345.679" +``` + +PhpSpreadsheet's "Wizard" doesn't yet offer options for displaying negative values; they will simply be masked so that they always display the sign. +But alternative masking for negative values is an option that may be added in the future. + +### Currency + +The Currency "Wizard" in MS Excel has similar options to the Number "Wizard", but also requires that you specify a currency code. + +![Excel Number Format - Currency.png](images/Behind the Mask/Excel Number Format - Currency.png) + +The "Symbol" dropdown provides a lot of locale-specific variants of the same currencies - for example '€ Netherlands', where the currency symbol is displayed before the value, and any negative sign appears before the currency "-€ 12,345.68"; or '€ France', where the symbol is displayed after the value "-12,345.68 €". + +The PhpSpreadsheet Currency "Wizard" allows you to specify the currency code, number of decimals, and the use of a thousands separator. +In addition, optionally, you can also specify whether the currency symbol should be leading or trailing, and whether it should be separated from the value or not. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Currency Wizard to build the Format Mask +$currencyMask = new Currency( + '€', + 2, + Number::WITH_THOUSANDS_SEPARATOR, + Currency::TRAILING_SYMBOL, + Currency::SYMBOL_WITH_SPACING +); +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode($currencyMask); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); // #,##0.00 € +var_dump($worksheet->getCell('C20')->getFormattedValue()); // -12,345.68 € +``` +A typical Currency mask might look something like '#,##0.00 €', with the currency symbol as a literal. + +The Currency Code itself may be a literal character, as here with the `€` symbol; or it can be wrapped in square braces with a `$` symbol to indicate that this is a currency and the next character as the currency symbol to use, and then (optionally) a locale code or an LCID (Locale ID) like `[$€-de-DE]` or `[$€-1031]`. + +I wouldn't recommend using LCIDs in your code, a locale code is a lot easier to recognise and understand; but if you do need to reference LCIDs, then you can find a list [here](https://learn.microsoft.com/en-us/openspecs/office_standards/ms-oe376/6c085406-a698-4e12-9d4d-c3b0ee3dbc4a). + +Alternatively, if you have PHP's `Intl` extension installed, you can specify a currency code and a locale code. +If you use this option, then locale values must be a valid formatted locale string (e.g. `en-GB`, `fr`, `uz-Arab-AF`); and the Wizard will use the format defined in ICU (International Components for Unicode): any values that you provide for placement of the currency symbol, etc. will be ignored. +The only argument that won't be ignored is an explicit value of 0 for the decimals, which will create a mask to display only major currency units. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C21')->setValue(-12345.67890); + +// Set Cell Style using the Currency Wizard to build the Format Mask for a locale +$localeCurrencyMask = new Currency( + '€', + locale: 'de_DE' +); +$worksheet->getCell('C21') + ->getStyle()->getNumberFormat() + ->setFormatCode($localeCurrencyMask); + +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); // #,##0.00 [$€-de-DE] +var_dump($worksheet->getCell('C21')->getFormattedValue()); // -12,345.68 € +``` +If we use the locale in the "Wizard", then a typical mask might look like '#,##0.00 [$€-de-DE]', with the currency wrapped in braces, a `$` to indicate that this is a localised value, and the locale included. + > Note: The Wizard does not accept LCIDs. + +PhpSpreadsheet's "Wizard" doesn't yet offer options for displaying negative values; they will simply be masked so that they always display the sign. +But alternative masking for negative values is an option that may be added in the future. + +### Accounting + +Excel's Accounting "Wizard" is like the Currency "Wizard", but without the options for presenting negative values. +Presentation of zero and negative values is dependent on the currency and locale. + +![Excel Number Format - Accounting.png](images/Behind the Mask/Excel Number Format - Accounting.png) + +The options available for the PhpSpreadsheet Accounting "Wizard" are identical to those of the Currency "Wizard"; although the generated Mask is different. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Accounting; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Accounting Wizard to build the Format Mask +$currencyMask = new Accounting( + '€', + 2, + Number::WITH_THOUSANDS_SEPARATOR, + Currency::TRAILING_SYMBOL, + Currency::SYMBOL_WITH_SPACING +); +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode($currencyMask); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); // _-#,##0.00 €*_- +var_dump($worksheet->getCell('C20')->getFormattedValue()); // -12,345.68 € +``` +A typical Accounting mask might look something like '_-#,##0.00 €*_-', with the currency symbol as a literal; and with placement indicators like `_-`, that ensure the alignment of the currency symbols and decimal points of numbers in a column. + +At the moment, none of the PhpSpreadsheet Wizards provide different masks for zero and negative values; unless you have the PHP `Intl` extension enabled, and can use the locale to generate the Mask. +As with using a locale with the Currency "Wizard", when you use a locale with the Accounting "Wizard" the locale value must be valid, and any additional options will be ignored. +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Accounting; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C21')->setValue(-12345.67890); + +// Set Cell Style using the Accounting Wizard to build the Format Mask for a locale +$localeCurrencyMask = new Accounting( + '€', + locale: 'nl_NL' +); +$worksheet->getCell('C21') + ->getStyle()->getNumberFormat() + ->setFormatCode($localeCurrencyMask); + +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); // [$€-nl-NL] #,##0.00;([$€-nl-NL] #,##0.00) +var_dump($worksheet->getCell('C21')->getFormattedValue()); // (€ 12,345.68) +``` +If we use the locale in the "Wizard", then a typical mask might look like '[$€-nl-NL] #,##0.00;([$€-nl-NL] #,##0.00)', with the currency wrapped in braces, with a `$` to indicate that this is a localised value, and the locale included. +And in this case, there is masking for zero and for negative values, although without colour. An option to add colour to values is an option that may be added in a future release. + +> **Warning**: Not all versions of the ICU (International Components for Unicode) support Accounting formats, so even if your PHP does have 'Intl' enabled, it may still not allow the use of locale for generating an Accounting Mask. + +### Date + +When you use the Excel Date "Wizard", you can select a locale and you'll then be presented with a number of date format options that are appropriate for that locale. + +![Excel Number Format - Date.png](images/Behind the Mask/Excel Number Format - Date.png) + +I've written in detail about Date Format Masks elsewhere in "The Dating Game"; but to summarise, here are the Mask codes used for Date formatting. + +| Code | Description | Example (January 3, 2023) | +|-------|-------------------------------------|---------------------------------------| +| m | Month number without a leading zero | 1 | +| mm | Month number with a leading zero | 01 | +| mmm | Month name, short form | Jan | +| mmmm | Month name, full form | January | +| mmmmm | Month as the first letter | J (stands for January, June and July) | +| d | Day number without a leading zero | 3 | +| dd | Day number with a leading zero | 03 | +| ddd | Day of the week, short form | Tue | +| dddd | Day of the week, full form | Tuesday | +| yy | Year (last 2 digits) | 23 | +| yyyy | Year (4 digits) | 2023 | + + +There is currently no PhpSpreadsheet "Wizard" for Date Masks; but this will be introduced in the 1.29.0 release. + +### Time + +As with Dates, when you use the Excel Time "Wizard", you can select a locale and you'll then be presented with a number of time format options that are appropriate for that locale. + +![Excel Number Format - Time.png](images/Behind the Mask/Excel Number Format - Time.png) + +I've written in detail about Time Format Masks elsewhere in "The Dating Game"; but to summarise, here are the Mask codes used for Time formatting. + +| Code | Description | Displays as | +|--------|--------------------------------------------------------------------|-------------| +| h | Hours without a leading zero | 0-23 | +| hh | Hours with a leading zero | 00-23 | +| m | Minutes without a leading zero | 0-59 | +| mm | Minutes with a leading zero | 00-59 | +| s | Seconds without a leading zero | 0-59 | +| ss | Seconds with a leading zero | 00-59 | +| AM/PM | Periods of the day
(if omitted, 24-hour time format is used) | AM or PM | + +Excel also supports Masks for Time Durations, although there is no "Wizard" for this; but the following Mask codes can be used to display Durations. + +| Code | Description | Displays as | +|---------|----------------------------------------------------------------|-------------| +| [h]:mm | Elapsed time in hours | e.g. 25:02 | +| [hh]:mm | Elapsed time in hours
with a leading zero if less than 10 | e.g. 05:02 | +| [mm]:ss | Elapsed time in minutes | e.g. 63:46 | +| [m]:ss | Elapsed time in minutes
with a leading zero if less than 10 | e.g. 03:46 | +| [s] | Elapsed time in seconds | | +| [ss] | Elapsed time in seconds
with a leading zero if less than 10 | | + +There is currently no PhpSpreadsheet "Wizard" for Time Masks, or for Durations; but these will be introduced in the 1.29.0 release. + +### Percentage + +This is among the simplest of the Excel "Wizards", only allowing you to specify the number of decimals to be displayed. + +![Excel Number Format - Percentage.png](images/Behind the Mask/Excel Number Format - Percentage.png) + +The Percentage mask looks like '0.00%'. + +Using the `%` code in a mask will multiply the value by 100 before rendering it, and it will always also display the `%` sign. + +The PhpSpreadsheet "Wizard" replicates this simple option; but also provides a locale option that allows locale-specific formatting, because there are a few locales where the percentage sign appears before the value rather than after it. +As with all locale use for the PhpSpreadsheet "Wizard", this is dependent on having the `Intl` extension enabled. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Percentage; + +// Set Cell value +$worksheet->getCell('C21')->setValue(-12345.67890); + +// Set Cell Style using the Percentage Wizard to build the Format Mask for a locale +$localeCurrencyMask = new Percentage( + locale: 'tr_TR' +); +$worksheet->getCell('C21') + ->getStyle()->getNumberFormat() + ->setFormatCode($localeCurrencyMask); + +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); // %#,##0.00 +var_dump($worksheet->getCell('C21')->getFormattedValue()); // %-12,345.68 +``` + +### Fraction + +MS Excel presents two different options for fractions: the first where the denominator is calculated internally to either 1, 2 or 3 digits; and the second (introduced only recently) where the denominator is fixed as 2, 4, 8 or 16. + +![Excel Number Format - Fraction.png](images/Behind the Mask/Excel Number Format - Fraction.png) + +The Fraction mask looks like '# ?/???' where `#` indicates that the integer part of the value should be displayed, and then `?/???` to display the fraction with up to 3 digits in the denominator. +The mask using a fixed-denominator looks like '# ?/16', with the denominator value replacing the variable `?`. + +If you use digit placeholders (`/??`) for the denominator, then Excel will calculate the lowest denominator that it can use for the fractional value. If you specify a fixed denominator (e.g. `/8`) then Excel will calculate the fraction in eighths. + +> **Note:** The internal renderer in PhpSpreadsheet does not consider the number of digits for the denominator, but will simply try to identify the lowest value denominator that it can use. + +There is currently no PhpSpreadsheet "Wizard" for Fraction Masks. + +### Scientific + +This is among the simplest of the Excel "Wizards", only allowing you to specify the number of decimals to be displayed. + +![Excel Number Format - Scientific.png](images/Behind the Mask/Excel Number Format - Scientific.png) + +The Scientific mask looks like '0.00E+00'. + +> **Note**: The internal rendering used by PhpSpreadsheet will display only as many digits as necessary for the exponent; while the Excel mask specifies a minimum of 2 digits, with a leading zero if necessary. + +The PhpSpreadsheet "Wizard" replicates this simple option. +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Scientific; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Scientific Wizard to build the Format Mask +$scientificMask = new Scientific( + 4, +); +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode($scientificMask); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat()->getFormatCode()); // 0.0000E+00 +var_dump($worksheet->getCell('C20')->getFormattedValue()); // -1.2346E+4 + +// Set Cell value +$worksheet->getCell('C21')->setValue(-12345.67890); + +// Set Cell Style using the Scientific Wizard to build the Format Mask for a locale +$localeScientificMask = new Scientific( + 3, + locale: 'nl_NL' +); +$worksheet->getCell('C21') + ->getStyle()->getNumberFormat() + ->setFormatCode($localeScientificMask); + +var_dump($worksheet->getCell('C21') + ->getStyle()->getNumberFormat()->getFormatCode()); // 0.000E+00 +var_dump($worksheet->getCell('C21')->getFormattedValue()); // -1.235E+4 +``` + +If you specify a number of decimals to display, then the Scientific "Wizard" will apply that value, even when using a locale. + +### Text + +MS Excel's Text "Wizard" has no options, but simply sets a mask to `@`, meaning display the value exactly as it is entered. +Unlike `General`, which is adaptive, `@` will not change the displayed value in any way, except in one exceptional case (see note below). +Very large or very small values will not be displayed in Scientific format, and leading zeroes will be displayed. + +>**Note:** If your cell contains Rich Text, then using `@` in a format mask will display it using the basic cell styling, ignoring the Rich Text styling. + +![Excel Number Format - Text.png](images/Behind the Mask/Excel Number Format - Text.png) + +PhpSpreadsheet doesn't emulate this behaviour; it simply displays the value as PHP would render that value cast to a string, which mimics Excel's quirk with Rich Text values. + +There is no PhpSpreadsheet "Wizard" for Text Masks. + +### Special + +Excel's Special format "Wizard" is a recent introduction: select a locale, and then you may be offered a number of options for formatting values in a manner that is appropriate to that locale, such as US phone numbers, social security numbers, or zip codes; typically with separators between groups of digits. +At this time, most locales have no special formats defined. + +![Excel Number Format - Special.png](images/Behind the Mask/Excel Number Format - Special.png) + +There is no PhpSpreadsheet "Wizard" for Special Masks. + +## Custom Format Masks + +The Custom "Wizard" really isn't a Wizard at all, just an editing field that allows you to pre-populate from a list of common format masks before editing. + +![Excel Number Format - Custom.png](images/Behind the Mask/Excel Number Format - Custom.png) + +It does mean that you need to understand the rules for defining masks when you use this. + +When you create custom number formats, you can specify up to four sections of format code. +These sections of code must be separated by semicolons (`;`). + +### Sections for Composite Masks + +Sections of the mask define the formats for positive numbers, negative numbers, zero values, and text, in that order. + +![Mask Sections.png](images/Behind the Mask/Mask Sections.png) + 1. Format for Positive values + 2. Format for Negative values + 3. Format for Zero values + 4. Format for Text + +If you specify only one section of format code, the code in that section is used for all numbers. +If you specify two sections of format code, the first section of code is used for positive numbers and zeros, and the second section of code is used for negative numbers. +If you specify a third section, then the first applies to positive values, the second to negative values, and the third to zero values. +The fourth section only applies if the value is not numeric. + +If you skip code sections in the format mask, then you must include a semicolon for each of the missing sections. + +When you skip code sections in the format mask, then you must include a semicolon for each of the missing sections. Use `;` to indicate that a section exists, but with an empty mask; and that can be used to hide values that match the criteria for that section. + +![Hiding Values.png](images/Behind the Mask/Hiding Values.png) + +> **Note:** Negative values aren't shown with a sign when we use the negative value section. If we want the value to display with a sign, then we need to include an explicit '-' character in the Mask for that section (";-0;"). + +### Basic Masking Symbols + +Three basic masking symbols are used to display numbers, and they differ in the way that they display leading or trailing zeroes. +The fourth basic masking symbol is the text placeholder, which can be used to wrap the cell value within additional formatting. + +| Code | Description | Examples | +|------|---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------| +| 0 | Digit placeholder that displays insignificant zeros. | #.00 - always displays 2 decimal places.

If you type 5.5 in a cell, it will display as 5.50. | +| # | Digit placeholder that represents optional digits and does not display extra zeros.

That is, if a number doesn't need a certain digit, it won't be displayed. | #.## - displays up to 2 decimal places.

If you type 5.5 in a cell, it will display as 5.5.

If you type 5.555, it will display as 5.56. | +| ? | Digit placeholder that leaves a space for insignificant zeros on either side of the decimal point but doesn't display them. It is often used to align numbers in a column by decimal point. | #.??? - displays a maximum of 3 decimal places and aligns numbers in a column by decimal point. | +| @ | Text placeholder | 0.00; -0.00; 0; [Red]@ - applies the red font colour for text values. | + +If a number entered in a cell has more digits to the right of the decimal point than there are placeholders in the format, the number is "rounded" to as many decimal places as there are placeholders. +For example, if you have a value of `2.25` in a cell with '#.#' format, then the number will be rounded to 1 decimal, and will display as `2.3`. + +Digits to the left of the decimal point are always displayed regardless of the number of placeholders. +For example, if the value in a cell is `202.25` with '#.#' format, the number will display as `202.3`. + +![Digit Placeholders.png](images/Behind the Mask/Digit Placeholders.png) + +To display leading zeroes for a numeric value, you might create a mask like '0000', which will always display at least 4 digits, padding the value with leading zeroes if it is less than 1000. + +### Other Special Codes +In addition to the masking symbols listed above, the following codes also enable special rendering of the value. + +| Code | Description | Example | +|----------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|--------------------------------------------------| +| . (period) | Decimal point. | ##0.00?? | +| , (comma) | Thousands Separator.
A comma that follows a digit placeholder scales the number by a thousand. | #,##0
0.000,
Described below. | +| \ | Text Escape Character that displays the character that follows it. | ##0\°
Described below. | +| " " | Display any text that is enclosed in the double quotes. | ##0"°C"
Described below. | +| % | Multiplies the value stored in the cell by 100 and display it with the percentage sign. | Examples provided in the "Wizard" section above. | +| / | Display the value as a fraction. | Examples provided in the "Wizard" section above. | +| E | Display the value in Scientific Format. | Examples provided in the "Wizard" section above. | +| _ (underscore) | Space pad to the width of the next character in the mask.
It is commonly used in combination with parentheses to add left and right indents, `_(` and `_)` respectively. | Described below. | +| * (asterisk) | Repeats the character that follows it until the width of the cell is filled.
It is often used in combination with the space character to change alignment. | Described below. | + +Simple examples of using these codes for Percentages, Fractions and Scientific Format can be found in the descriptions of those "Wizards". +The other codes are described in detail below. + +### Thousands Separator and Scaling + +To create an Excel custom number format with a thousands separator, include a comma (`,`) in the format code. For example: + + - #,### - display a thousands separator and no decimal places. + - #,##0.00 - display a thousands separator and 2 decimal places. + +Microsoft Excel separates thousands by commas: if a comma is enclosed by any digit placeholders - the pound sign (`#`), question mark (`?`) or zero (`0`). +But if no digit placeholder follows a comma, it scales the number by a thousand, two consecutive commas scale the number by a million, and so on. + +For example, if a cell format is '#.00,' and the cell value is `5000`, then the number `5.00` is displayed. + +![Scaling Example.png](images/Behind the Mask/Scaling Example.png) + +### Formatting with Text + +If you want to include text in your format mask with numeric values, then there are two basic options. +To add a single character, then you can prefix that character with a backslash ('\'), e.g. '#.00,\K' or '#.00,,\M'. + +![Text Single Character Example.png](images/Behind the Mask/Text Single Character Example.png) + +You don't need the backslash ('\') prefix for the following list of characters: + +| Character | Description | +|-----------|----------------------------------| +| + and - | Plus and Minus Signs | +| ( and ) | Left and Right Parenthesis | +| : | Colon | +| ^ | Caret | +| ' | Apostrophe | +| { and } | Curly Braces | +| < and > | Less Than and Greater than Signs | +| = | Equals Sign | +| / | Forward Slash | +| ! | Exclamation Mark | +| & | Ampersand | +| ~ | Tilde | +| | Space Character | + +You can also use additional padding characters if you want to break a number into groups. For example, to display a number as a phone number, you might use a mask like '00-0000-0000' or '(++00) 00-0000-0000'. + +It's a common practise in Accounting formats to wrap negative values in brackets; so you might use '([$€-nl-NL] #,##0.00)' for that section of the mask. + +To add words or for phrases to the mask, you should wrap the text in quotes: e.g. '#.00" Surplus";-#.00" Deficit";"Out of Stock"'. + +![Text String Example.png](images/Behind the Mask/Text String Example.png) + +### Indents, Spacing and Alignment + +If you're wrapping negative values in brackets, you might like like to align your positive values with your negative values on the decimal, then you need to apply indents to the positive values to match the width of the bracket characters. +The underscore code (`_`) tells Excel to pad the display to the width of the next character in the mask, so '_)' would tell Excel to pad to the width of ')' character like '[$€-nl-NL] _(#,##0.00_);[$€-nl-NL] (#,##0.00)'. + +![Indent.png](images/Behind the Mask/Indent.png) + +Accountants might also appreciate if the Currency symbols were also all aligned; so we can use the `*` code followed by a repeated character; and that will pad the width of the cell with the specified character. Typically, although not always, this will be a space character, '* '. + +![Padding.png](images/Behind the Mask/Padding.png) + +Which aligns all our currency symbols to the left of the cell, while displaying the value right-aligned in the cell, and all the decimal points neatly aligned. + +The `*` code can also be used to change the alignment of a value in a cell. If we want text to be rendered right aligned, even though the cell is left aligned, we can use '* @' as the mask, and this will push the display to the right of the cell. + +![Right Align.png](images/Behind the Mask/Right Align.png) + +Note that padding is not supported by PhpSpreadsheet's internal renderer for the cell's `getFormattedValue()` method, because the renderer is unaware of font or cell width, so it will simply add a single space in the formatted result. Nor will it change alignment in any way. + +### Colours + +You can use format masks to change the font colour for a certain value with a custom number format. +e.g. '[Red]@' + +Masking supports the following 8 main colours. + + - Black + - Red + - Green + - Blue + - Cyan + - Yellow + - Magenta + - White + +To specify the colour, just type one of those colour names in the appropriate section of your number format code, wrapped in square braces (e.g. '[red]'). +This colour code must be at the very start of the section, before any other formatting characters or instructions. +The colour name is case-insensitive. + +If we wanted to show positive values in green and negative values in red we could apply different colours to the different sections of the mask, e.g. "[Green]#,##0.00;[Red]#,##0.00;0.00". + +> **Warning**: Colour masking doesn't apply when using Excel's `TEXT()` function; only when it is applied to a Cell's Format Mask. + +### Conditional Formatting + +Not to be confused with actual Conditional Formatting in MS Excel; but we can apply some limited condition checks in normal Cell Format Masks, and apply different masking based on those conditions. + +While by default the different sections are interpreted by Excel as positive, negative, zero and text, we can override these definitions with conditions. +We do this by defining the condition for matching against the value inside square braces. + +![Conditional 1.png](images/Behind the Mask/Conditional 1.png) + +The mask that we're using here is `[Red][<65]"Fail";[Blue]"Pass"`. +This tells Excel to display the text "Fail" if the value in the Cell is less than 65, otherwise to display "Pass". + +The symbols allowed for comparison in Conditions are the standard mathematical comparison operators: + +| Symbol | Meaning | +|--------|-----------------------| +| \> | Greater than | +| \>= | Greater than or Equal | +| < | Less than | +| <= | Less than or Equal | +| = | Equal | +| <\> | Not Equal | + +In this next example, we've created an additional condition, so we have three sections in the mask: the "Pass" grade is now any student that hasn't failed, but whose score is less than 85; the last section awards a grade of "Distinction" to any student whose score doesn't match the criteria for "Pass" or "Fail" (i.e. exceeds 85). +`[Red][<65]"Fail";[Blue][<85]"Pass";"Distinction"` + +![Conditional 2.png](images/Behind the Mask/Conditional 2.png) + +We do need to be careful about the order that we define the conditions, because Excel will apply the mask for the first condition that matches the Cell value. + +> **Note**: Conditional Formatting using the Number Mask will also work with the TEXT() Function, although not with colour highlighting. + +We can also use Conditional Formatting to display Lakh, the different grouping often found in India and the countries of that region. +While the thousands separator normally represents grouping to powers of 3 (103, 106, 109, etc), the Lakh is 103, 105, 107, etc. and is written as `1,00,000`. +After the first 1000, the comma separator is used to represent groups of 2 digits, not groups of 3 digits. +150,000 rupees is 1.5 lakh rupees, and is written as `₹1,50,000` or `INR 1,50,000`. + +The mask used to represent lakh is: +[>=10000000][$₹]##\,##\,##\,##0;[>=100000][$₹]##\,##\,##0;[$₹]##,##0 + +Note that we're only using the `,` for thousands grouping in the final block, to represent values less than 100,000 where we would only display a single `,` as a thousands separator. +For the `,` in higher values, we are displaying a string literal `,` identified as such by the '\' immediately before it in the mask. + +## Building Composite Masks using PhpSpreadsheet's Wizards + +Even though PhpSpreadsheet's Wizards don't yet support building sections with different masks for positive, negative, zero, and text, you can still create these using the Wizards as building blocks. + +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Number; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Currency Wizard to build the Format Mask +$currencyMask = new Currency( + '€', + 2, + Number::WITH_THOUSANDS_SEPARATOR, + Currency::TRAILING_SYMBOL, + Currency::SYMBOL_WITH_SPACING +); + +// Build the composite mask applying colours to the different sections +$compositeCurrencyMask = [ + '[Green]' . $currencyMask, + '[Red]' . $currencyMask, + $currencyMask, +]; + +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode(implode(';', $compositeCurrencyMask)); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->getFormatCode()); // [Green]#,##0.00 €;[Red]#,##0.00 €;#,##0.00 € +``` +We repeat the mask that's generated by the "Wizard" for each section of the composite mask, adding the required colour to each section. + +If we've used a locale to build a mask, then we may already have multiple sections: +```php +use PhpOffice\PhpSpreadsheet\Style\NumberFormat\Wizard\Currency; + +// Set Cell value +$worksheet->getCell('C20')->setValue(-12345.67890); + +// Set Cell Style using the Currency Wizard to build the Format Mask +$currencyMask = new Currency( + '€', + locale: 'nl_NL' +); + +// Split the generated mask into sections +// This particular mask already has positive and negative value sections, +// but does not have a zero value section +// Other locales may only have a positive section; or may already have a zero section +$currencyMaskSections = explode(';', $currencyMask); +// Recreate the modified mask applying colours to the different sections +$compositeCurrencyMask = [ + '[Green]' . $currencyMaskSections[0], + '[Red]' . $currencyMaskSections[1] ?? $currencyMaskSections[0], + $currencyMaskSections[2] ?? $currencyMaskSections[0], +]; + +$worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + ->setFormatCode(implode(';', $compositeCurrencyMask)); + +var_dump($worksheet->getCell('C20') + ->getStyle()->getNumberFormat() + // [Green][$€-nl-NL] #,##0.00;[Red][$€-nl-NL] -#,##0.00;[$€-nl-NL] #,##0.00 + ->getFormatCode()); +``` +If the locale-generated mask already has a section defined, then we use that, otherwise we use the positive section (section 0) that will always exist as the base for each section. +> **Warning:** You might need to add an explicit `-` for the negative section if that needs to be created. + +## Summary + +Even though Excel displays the formatted value in the grid, the underlying value in the cell is unchanged. +The value being displayed as a date is still an Excel Serialized Timestamp, even though it is being displayed as '2023-02-28'; the student grade is still a number, even though it is being displayed as "Distinction"/"Pass"/"Fail"; and the cell that looks empty may not be as empty as you think. +The edit bar still shows us the value in the cell, not the formatted value; and we can still use that underlying value in formulae. + +![Summary - Still a numeric value.png](images/Behind the Mask/Summary - Still a numeric value.png) + +And we can even format the cell containing the result of that formula. + +Number format masks allow us to present the spreadsheet data in a format that makes it easy for the user to interpret; so they add a lot of value to the spreadsheets that we create. +We just need to understand how to use them well to unlock that value. diff --git a/docs/topics/Looping the Loop.md b/docs/topics/Looping the Loop.md new file mode 100644 index 000000000..0b48d0976 --- /dev/null +++ b/docs/topics/Looping the Loop.md @@ -0,0 +1,479 @@ +# Looping the Loop + +PhpSpreadsheet uses a lot of memory to maintain the Spreadsheet model; but I regularly see developers loading a Spreadsheet file and then calling the `toArray()` methods so that they can loop through the rows and columns of a worksheet. +PHP arrays are also notoriously memory-intensive, so creating a large array duplicating the data that is already in the Spreadsheet model is a very inefficient use of memory. +Generally, unless we are always working with very small spreadsheets, we would want to avoid this. + +So in this article, I'm going to look at a number of different ways of iterating through a worksheet to access the row and cell data; at their limitations; and at some of the options that each provides. + +## Using `toArray()` + +Using a sample data file from Microsoft and available at https://go.microsoft.com/fwlink/?LinkID=521962, I can load up the file, call `toArray()` to return the worksheet data in an array, and iterate over the rows and cells of that array with the following code. + +```php +$inputFileType = 'Xlsx'; +$inputFileName = __DIR__ . '/../Financial Sample.xlsx'; + +$reader = IOFactory::createReader($inputFileType); +$spreadsheet = $reader->load($inputFileName); + + +$dataArray = $worksheet->toArray(); + +foreach ($dataArray as $row) { + foreach ($row as $cellValue) { + // Do something with the cell data here. + } +} +``` + +The Financial Sample spreadsheet is fairly small, 701 rows (including a heading row) and 16 columns; and is predominantly numeric data. +To simulate a larger worksheet, I've duplicated that data to give 7001 rows; but this is still a small worksheet as MS Excel xlsx files can contain 1 million rows in each of several worksheets. + +If we look at the timings and memory usage for this process with my extended MS Financial Sample spreadsheet: +``` +Current memory usage: 47104 KB + +Call time to build array was 0.6603 seconds + +Call time to iterate array rows was 0.0130 seconds +Current memory usage: 57344 KB + Peak memory usage: 57344 KB +``` +We can see that using `toArray()` increases memory usage by 10240KB (10MB): that is the memory used by the array for a relatively small worksheet (less than 120,000 cells). +For larger worksheets, with more rows and columns, and with more text than numeric values, this memory overhead can grow significantly larger. + +And while iteration over the array is very quick, it still takes 0.6603 seconds to build the array before we can start iterating over those rows and cells. + +--- + +The `toArray()` method is easy to use: but not only is it increasing the memory overhead, especially with larger worksheets; but it also lacks flexibility. +It provides limited control over how the data from each cell is returned in the array. +It can return the raw cell value (which isn't particularly useful if the cell contains a formula, or if the value should be interpreted as a date); it can force PhpSpreadsheet to calculate formulae and return the calculated value; or it can return the formatted cell value (which includes calculating any formulae) so that date values are presented in a human-readable format, but which will also format numeric values with thousand separators where the cell style applies that. + +| Argument Name | DataType | Default | Purpose | +|--------------------|----------|---------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| $nullValue | mixed | null | A value to be returned in the array if a cell doesn't exist. | +| $calculateFormulas | boolean | false | Flag to indicate if formula values should be calculated before returning. | +| $formatData | boolean | false | Flag to request that values should be formatting before returning. | +| $returnCellRef | boolean | false | False - Return a simple enumerated array of rows and columns (indexed by number counting from zero)
True - Return rows and columns indexed by their actual row and column IDs. | + +### Dealing with empty rows + +The example spreadsheet that I've used here is well behaved, with no empty rows. Empty rows can cause problems, even when working in Excel itself. + +Let's take a look at a worksheet of Sales Data, where we're missing the returns for a couple of months: + +![Empty Rows.png](images/Looping the Loop/Empty Rows.png) + +If we're using MS Excel, and we hit `Ctrl-T` to create a table from this data, we might expect Excel to build that table from all rows up to `Dec`; but it won't, it will only build the table up to the next empty row. + +![Table with Empty Rows.png](images/Looping the Loop/Table with Empty Rows.png) + +And unless we're careful, we might not even notice that the last few months of the year are omitted. +So empty rows can create problems even in MS Excel itself; and we certainly need to have some way of identifying them when we're processing a worksheet using PhpSpreadsheet. + +--- + +While we're iterating through a spreadsheet with PhpSpreadsheet, we might want to avoid trying to process blank data if we reach a row that is empty; or we might want to terminate iterating when we encounter an empty row. + +If we've created an array from a worksheet, then an empty row will be a row where every cell/column entry has a null value (unless called specifying an alternative value using the `$nullValue` argument). + +We can filter the row to eliminate null values, then count the length of that filtered array: if there are no remaining cells, then the row is empty. We can then skip that row in our code using `continue`. + +```php +foreach ($dataArray as $row) { + // Check for empty rows + if (count(array_filter($row, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + foreach ($row as $cellValue) { + // Do something with the cell data here. + } +} +``` + +Or if we want to terminate iterating when we encounter an empty row, use `break` rather than `continue`. + +```php +foreach ($dataArray as $row) { + // Check for empty rows + if (count(array_filter($row, function ($value) { + return $value !== null; + })) === 0) { + break; // Stop at the first empty row + } + + foreach ($row as $cellValue) { + // Do something with the cell data here. + } +} +``` + +Using `toArray()` won't change anything in the worksheet model: it won't modify values or create new cells. +It will return a row/column entry for every cell that can exist, even if that cell doesn't exist in the worksheet, or is part of a merge range. +> **Note**: It is possible for several cells in a merge range to have a value. Although MS Excel doesn't allow you to set data like this, the option is available in other Spreadsheet software, and if the file is saved as an xlsx file, then the data for those cells is retained in the file, even though only the top left cell of a merge range shows a value in MS Excel. In PhpSpreadsheet, accessing those cells (including using `toArray()`) will return the "hidden" value. + +One useful factor about returning an array of arrays is if we want to populate a data model from that row before persisting to a database, for example. We don't need to iterate the columns in that row, but can work directly with the column array for each row. + +```php +class MonthlySales { + private function __construct( + public readonly string $month, + public readonly array $sales, + ) {} + + public static function fromExcel(array $values, array $headers): self { + $month = array_shift($values); + return new self($month, array_combine($headers, $values)); + } +} + +$monthlySalesData = new MonthlySalesCollection(); + +// Read from the header row of the worksheet +$excelHeaderRow = ['Jones', 'Sorvino', 'Gupta', 'Choi']; + +$dataArray = $worksheet->toArray(formatData: true); +foreach ($dataArray as $excelDataRow) { + // Check for empty rows + if (count(array_filter($excelDataRow, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + $monthlySalesData->add(MonthlySales::fromExcel( + $excelDataRow, + $excelHeaderRow + )); +} +``` +We can pass the row array directly to the static `MonthlySales::fromExcel()` constructor for our data object, handling any validation and manipulation of the structure there, to map the Excel data row to that object properties, handling filtering within the business logic of the Monthly Sales object's static constructor. + +--- + +And remember that by default, `toArray()` returns a simple enumerated array of enumerated arrays, to reflect rows and columns. You can modify this using the `$returnCellRef` argument to the method: set `$returnCellRef` to true, and the array indexes will be the row number (with 1 as the first row) and the column address ('A', 'B', 'C', etc); which can be useful if you want to do some processing of the cells based on that column address. + +```php +class MonthlySales { + private function __construct( + public readonly string $month, + public readonly array $sales, + ) {} + + public static function fromExcel(array $values, array $headers): self { + $month = array_shift($values); + $salesValues = []; + foreach ($values as $column => $value) { + // Ignore salespeople who have null sales values + if ($value !== null) { + $salesValues[$headers[$column]] = $value; + } + } + return new self($month, $salesValues); + } +} + +$monthlySalesData = new MonthlySalesCollection(); + +// Read from the header row of the worksheet +$excelHeaderRow = [ + 'B' => 'Jones', + 'C' => 'Sorvino', + 'D' => 'Gupta', + 'E' => 'Choi' +]; + +$dataArray = $worksheet->toArray(formatData: true, returnCellRef: true); +foreach ($dataArray as $excelDataRow) { + // Check for empty rows + if (count(array_filter($excelDataRow, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + $monthlySalesData->add(MonthlySales::fromExcel( + $excelDataRow, + $excelHeaderRow + )); +} +``` + +## Using `rangeToArray()` + +The `toArray()` method uses information from the loaded file to identify the range of rows and columns to populate the array. +`toArray()` uses two cached values to identify the range of cells to populate the array, values that are returned by the `getHighestRow()` and `getHighestColumn()` methods. + +But many worksheets do have trailing empty rows (or columns); and it is quite common for an Excel spreadsheet to have only a few rows or columns of data, but for the spreadsheet itself to claim that the highest row is 1,048,576 or that the highest column is XFD. +Features such as Data Validation or Conditional Formatting that can also artificially inflate/increase the values returned by `getHighestRow()` and `getHighestColumn()` methods. + +If we use `toArray()` to build an array from the worksheet in this case, then it will build a 1,048,576x16,384 array, filled with null values, that will almost certainly exceed PHP's memory limits, and take a long time to do so. +And if we do have enough memory to build the array, we will still be iterating over a lot more array entries than we want. Even if we're skipping empty rows, we're still testing every row to see if it's empty. +Where possible, it's better to specify the limit of rows and columns that we want to use to build an array, and PhpSpreadsheet provides the `rangeToArray()` for that purpose. + +One option to reduce a large array using a lot of PHP memory would be to run a loop using `rangeToArray()` to build smaller arrays for blocks of perhaps 100 rows at a time. +This will still take a while to run if we have a lot of trailing empty rows, but it won't exceed PHP's memory limits. + +```php +$startRow = 1; +$batchSize = 100; +while ($startRow <= $maxRow) { + $endRow = min($startRow + $batchSize, $maxRow); + $dataArray = $worksheet->rangeToArray("A{$startRow}:{$maxColumn}{$endRow}"); + $startRow += $batchSize; + + foreach ($dataArray as $row) { + // Check for empty rows + if (count(array_filter($row, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + foreach ($row as $cellValue) { + // Do something with the cell data here. + } + } +} +``` + +Alternatively, we can identify the highest row and highest column that actually contain cell data using the `getHighestDataRow()` and `getHighestDataColumn()` methods. This allows us to specify a range to pass to the `rangeToArray()` method. + +```php +$maxDataRow = $worksheet->getHighestDataRow(); +$maxDataColumn = $worksheet->getHighestDataColumn(); + +$dataArray = $worksheet->rangeToArray("A1:{$maxDataColumn}{$maxDataRow}"); + +foreach ($dataArray as $row) { + // Check for empty rows + if (count(array_filter($row, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + foreach ($row as $cellValue) { + // Do something with the cell data here. + } +} +``` + +There might still be some empty rows in this array, so we still want to test for empty rows; but we won't have any trailing empty rows at the end of our data, so the iteration will terminate at the end of our specified row range, when we know we have no further data. + +And if there is a row or two of column headers that we want to ignore, we can modify the specified range to exclude them by adjusting the starting row of the range (e.g. `"A3:{$maxDataColumn}{$maxDataRow}"`); + +Because we're only building the array for a specified range of rows and columns, the array that the method builds will often be smaller than the array created by `toArray()`, using less memory; and it will often be faster iterating just the subset of rows that we've requested. + + +A combination of these approaches - batching and using the 'getHighestDataRow()' value, will be more memory-efficient, and possibly faster, than using 'toArray()'. +```php +$startRow = 1; +$batchSize = 100; +while ($startRow <= $maxDataRow) { + $endRow = min($startRow + $batchSize, $maxDataRow); + $dataArray = $worksheet->rangeToArray("A{$startRow}:{$maxDataColumn}{$endRow}"); + $startRow += $batchSize; + + foreach ($dataArray as $row) { + // Check for empty rows + if (count(array_filter($row, function ($value) { + return $value !== null; + })) === 0) { + continue; // Ignore empty rows + } + + foreach ($row as $cellValue) { + // Do something with the cell data here. + } + } +} +``` +How does this approach compare with using 'toArray()' in terms of memory and speed? Here are the details for using `rangeToArray()` with a batched approach: +``` +Current memory usage: 47104 KB + +Call time to batch iterate array rows was 0.6844 seconds + Current memory usage: 47104 KB + Peak memory usage: 49152 KB +``` +While the total time is 0.6844 seconds, that included building the smaller batched arrays, so its speed is comparable to the total time of 0.6733 seconds using `toArray()`. +But a peak memory usage of 49,152KB compared with the 57,344KB used by `toArray()` make this approach a lot more memory efficient. + +Like `toArray()`, `rangeToArray()` is easy to use, but it has the same limitations for flexibility. It provides the same limited control over how the data from each cell is returned in the array as `toArray()`. +The same additional arguments that can be provided for the `toArray()` method can also be provided to `rangeToArray()`. + +## Using Iterators + +You don't need to build an array from the worksheet to loop through the rows and columns and do whatever processing you need; you can loop through the rows and columns in the Worksheet directly and more efficiently using PhpSpreadsheet's built-in iterators. + +And this also gives a lot more flexibility in how you can present the cell values, because the iterator will return the Cell itself, not a representation of its value. You can read its value, check its style, identify if it is part of a merge range. + +```php +$rowIterator = $worksheet->getRowIterator(); +foreach ($rowIterator as $row) { + $columnIterator = $row->getCellIterator(); + foreach ($columnIterator as $cell) { + // Do something with the cell here. + } +} +``` + +Let's look at the memory usage and timings for using PhpSpreadsheet's Iterators, as we did for building and iterating through the array. + +``` +Current memory usage: 47104 KB + +Call time to iterate rows was 0.0930 seconds + Current memory usage: 47104 KB + Peak memory usage: 47104 KB +``` + +Using the PhpSpreadsheet Iterators isn't as fast as iterating over an array; but it doesn't have the time or memory overheads of actually building that array from the worksheet. +Using the same example spreadsheet, it only took 0.0130 seconds to iterate every cell of that array, but 0.6603 seconds to build the array, a total of 0.6733 seconds; but using the Iterator has performed the equivalent task in just 0.0930 seconds; about 7 times faster and without the increased memory overhead of the array. + +### Dealing with empty rows + +As, when we were iterating over the array, we might want to identify empty rows so that we can avoid trying to process them. The Row Iterator provides a method to identify empty rows. + +```php +$rowIterator = $worksheet->getRowIterator(); +foreach ($rowIterator as $row) { + if ($row->isEmpty()) { // Ignore empty rows + continue; + } + + $columnIterator = $row->getCellIterator(); + foreach ($columnIterator as $cell) { + // Do something with the cell here. + } +} +``` + +One additional feature when using the Iterator to determine whether a row is empty or not is that we can set our own rules to define "empty". +The default rule is if there is no entry for the Cell in the Cell Collection. +If we also want to skip any Cell that contains a null value, then we can pass an argument using the pre-defined constant `CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL`. +If we also want to skip any Cell that contains an empty string, then we can pass an argument using the pre-defined constant `CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL`. + +And we can combine those rules: +```php +if ($row->isEmpty( + CellIterator::TREAT_EMPTY_STRING_AS_EMPTY_CELL | + CellIterator::TREAT_NULL_VALUE_AS_EMPTY_CELL) + ) { // Ignore empty rows + continue; +} +``` + +Since PhpSpreadsheet 1.27.0, we can take this empty test one step further. +If we look at a slightly modified version of the Sales Data worksheet, rows 7 and 10 have no sales data, but they aren't empty because there is a month value in column A. + +![Empty Rows 2.png](images/Looping the Loop/Empty Rows 2.png) + +But we can tell the `isEmpty()` method to only check if a specified range of columns is empty in a row. + +```php +if ($row->isEmpty(startColumn: 'B', endColumn: 'E') { + // Ignore empty rows where columns B to E are empty values + // even though there is a value in Column A. + continue; +} +``` + +--- + +Like `toArray()`, the Iterators use the `getHighestRow()` and `getHighestColumn()` methods to identify the maximum row and column values for iterating; so it can still iterate over a lot of trailing empty rows and columns. +But that is just a default behaviour. +Similarly to `rangeToArray()` we can specify a range of rows, and a range of columns to iterate. + +```php +$maxDataRow = $worksheet->getHighestDataRow(); +$maxDataColumn = $worksheet->getHighestDataColumn(); + +$rowIterator = $worksheet->getRowIterator(1, $maxDataRow); +foreach ($rowIterator as $row) { + if ($row->isEmpty()) { // Ignore empty rows + continue; + } + + $columnIterator = $row->getCellIterator('A', $maxDataColumn); + foreach ($columnIterator as $cell) { + // Do something with the cell here. + } +} +``` + +and if we have heading rows at the top of the worksheet that we want to ignore: +```php +$rowIterator = $worksheet->getRowIterator(3, $maxDataRow); +``` + +And if we want to return only cells that aren't empty in the row, then we can tell the Cell Iterator to skip empty cells. + +```php +$rowIterator = $worksheet->getRowIterator(1, $maxDataRow); +foreach ($rowIterator as $row) { + $columnIterator = $row->getCellIterator(); + $columnIterator->setIterateOnlyExistingCells(true); + foreach ($columnIterator as $cell) { + // Do something with the cell here. + } +} +``` + +Alongside the speed and memory benefits, another big advantage of using the Iterators (Row and Cell, or Column and Cell) is that we get direct access to the cell itself. We can look at its value, its style; we can identify if it is a formula, or formatted as a date/time, whether it is part of a merge range: we can choose exactly what we want to do with it. + +--- + +One drawback of the Cell Iterator is that it will create a new cell if none exists (by default) unless we're skipping empty cells. +Until PhpSpreadsheet 1.27.0, the Cell Iterator would always create a new Cell if one didn't exist in the Cell Collection when it was required to access it. And this can lead to increasing memory consumption. + +PhpSpreadsheet 1.27.0 introduced the option to return a null value rather than to create and return a new Cell. + +```php +$rowIterator = $worksheet->getRowIterator(1, $maxDataRow); +foreach ($rowIterator as $row) { + $columnIterator = $row->getCellIterator(); + $columnIterator->setIfNotExists(CellIterator::IF_NOT_EXISTS_RETURN_NULL); + foreach ($columnIterator as $cell) { + if ($cell !== null) { + // Do something with the cell here. + } + } +} +``` + +The default Iterator behaviour is still to create a new Cell, to retain backward compatibility; but the option to return a null value instead of a new empty cell now exists. +> **Note**: This default behaviour is likely to be reversed with the eventual release of PhpSpreadsheet 2.0, with a null value as the default return, but with the option to create a new empty cell. + +## Summary of Performance and Benefits + +So here is a quick summary of timing and memory usage for the three different approaches to iterating through the rows and cells of the 7000 row sample worksheet. + +Memory usage is adjusted for the 47,104 KB baseline of the loaded file. + +| Approach | Total Time (s) | Memory (KB) | +|------------------------|----------------|-------------| +| toArray() | 0.6733 | 10,240 | +| rangeToArray (Batched) | 0.6844 | 2,048 | +| Iterators | 0.0930 | 0 | + +These figures are all based on a well-formed spreadsheet, with no trailing empty rows, or empty rows within the dataset. +Empty rows within a dataset can be identified by all three approaches, although only Iterators have a built-in method to identify an empty row. + +Using `toArray()` is not good for controlling memory usage, especially if there are trailing empty rows, so it should generally be avoided; but both `rangeToArray()` and the Iterators can exclude trailing rows by setting their scope based on the `getHighestDataRow()` and `getHighestDataColumn()` values. + +Batching rows with `rangeToArray()` is useful for keeping memory usage under control if you want to work with arrays. + +But using the Iterators is significantly faster than either "array" approach, with no memory overhead (especially if using the option of a null return for Cells that don't exist that was made available in PhpSpreadsheet 1.27.0); and as the Iterators return the Cell object rather than simply a cell value, they provide a lot more flexibility in how to process that cell. +The flexibility to configure the definition of an empty row may also be useful to developers using this approach to iterating through a worksheet. + +## Conclusions + +While it might require a little more userland code by developers using PhpSpreadsheet; because they are faster, have no additional memory overhead, and provide more flexibility, Iterators are the recommended approach for looping through the rows (and cells) of a worksheet. + diff --git a/docs/topics/The Dating Game.md b/docs/topics/The Dating Game.md new file mode 100644 index 000000000..75726614d --- /dev/null +++ b/docs/topics/The Dating Game.md @@ -0,0 +1,300 @@ +# The Dating Game + +Date and Time values are stored in different ways in spreadsheet files, depending on the file format; but internally when working in Excel the cell always contains a numeric value, representing the number of days since a baseline date, and formatted to appear as a human-readable date using a "Number Format Mask". This number is sometimes referred to as a serialized Excel date or timestamp. + +## Dates + +That baseline date is normally 1st January 1900, although it can be 1st January 1904 (if the original spreadsheet file was created using the Mac version of MS Excel). Excel maintains a flag in the file indicating which baseline should be used; for file formats that don't provide this flag (such as .slk or .csv), the calendar defaults to the 1900 baseline. +![Date as a number.png](images/The Dating Game/Date as a number.png) + +Note that the baseline date itself is day 1; so strictly speaking, the base date 0 is '1899-12-31': Excel considers any value between 0 and 1 as purely a time value, trying to display 0 using a date format mask like 'yyyy-mm-dd' will show an invalid date like '1900-01-00' rather than '1899-12-31', but when using a time format mask like 'hh:mm:ss' it will appear as '00:00:00' (midnight). Values less than 0 are invalid as dates or as times, so a negative value in a cell with a date "Number Format Mask" will display as '############' in Excel. + +Open/Libre Office and Gnumeric don't have this limitation, and negative date/timestamp values are recognised and formatted correctly; but it is recommended that you don't rely on this when working with PhpSpreadsheet. + +To write a date in a cell using PhpSpreadsheet, we need to calculate the serialized Excel datestamp for that date. Methods to do this are available in the Shared\Date class, which provides a number of methods for conversion between different date options typically used in PHP applications (Unix timestamp, PHP DateTime objects and some recognisable formatted strings) and the Excel serialized value; and vice versa. + + - Shared\Date::convertIsoDate() + - Converts a date/time in [ISO-8601 standard format](https://en.wikipedia.org/wiki/ISO_8601) to an Excel serialized timestamp + - Shared\Date::PHPToExcel() + - Converts a Unix timestamp, a PHP DateTime object, or a recognisable formatted string to an Excel serialized timestamp + - Shared\Date::dateTimeToExcel() + - Converts a Unix timestamp to an Excel serialized timestamp + - Shared\Date::timestampToExcel() + - Converts a PHP DateTime object to an Excel serialized timestamp + - Shared\Date::formattedPHPToExcel() + - Converts year, month, day, hour, minute, and second to an Excel serialized timestamp + - Shared\Date::excelToDateTimeObject() + - Converts an Excel serialized timestamp to a PHP DateTime object + - Shared\Date::excelToTimestamp() + - Converts an Excel serialized timestamp to a Unix timestamp. + - The use of Unix timestamps, and therefore this function, is discouraged: they are not Y2038-safe on a 32-bit system, and have no timezone info. + +We probably also want to set the number format mask for the cell so that it will be displayed as a human-readable date. +```php +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDate; + +// Create new Spreadsheet object +$spreadsheet = new Spreadsheet(); +$worksheet = $spreadsheet->getActiveSheet(); + +// Calculate today's date as an Excel serialized timestamp +$today = SharedDate::PHPToExcel(new DateTime('today')); + +$data = [ + ['Formatted Date', 'Numeric Value'], + ['=C2', 1], + ['=C3', 2], + ['=C4', $today], +]; + +// Write our data to the worksheet +$worksheet->fromArray($data, null, 'B1'); + +// Display values in column B as human-readable dates +$worksheet->getStyle('B2:B4')->getNumberFormat()->setFormatCode('yyyy-mm-dd'); +// Set some additional styling +$worksheet->getStyle('B1:C1')->getFont()->setBold(true); +$worksheet->getColumnDimension('B')->setAutoSize(true); +``` + +## Times + +Dates are always the integer part of the value (1, 2, 44943): the fractional part of the value is used to represent the time as a fraction of the day. So a value of 0.5 is 12:00 midday; 0.25 is 06:00 in the morning and 0.75 is 18:00 in the evening. +![Time as a number.png](images/The Dating Game/Time as a number.png) + +A float value greater than 1, like 44943.5 is considered as a datetime value: 12:00 (midday) on the 17th of January 2023. + +As with dates, to write a time value to a cell in PhpSpreadsheet, we write the numeric value for that time (or date/time) to the cell, and then apply a number format mask to the cell so that it will be displayed in a human-readable format. +```php +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Shared\Date as SharedDate; + +// Create new Spreadsheet object +$spreadsheet = new Spreadsheet(); +$worksheet = $spreadsheet->getActiveSheet(); + +// Calculate today's date as an Excel serialized timestamp +$today = SharedDate::PHPToExcel(new DateTime('today')); + +$data = [ + ['Formatted Time', 'Numeric Value'], + ['=C2', 0], + ['=C3', 0.25], + ['=C4', 0.5], + ['=C5', 0.75], + ['=C6', $today + 0.5], +]; + +// Write our data to the worksheet +$worksheet->fromArray($data, null, 'B1', true); + +// Display values in column B as human-readable dates +$worksheet->getStyle('B2:B5')->getNumberFormat()->setFormatCode('hh:mm:ss'); +$worksheet->getStyle('B6')->getNumberFormat()->setFormatCode('yyyy-mm-dd hh:mm:ss'); +// Set some additional styling +$worksheet->getStyle('B1:C1')->getFont()->setBold(true); +$worksheet->getColumnDimension('B')->setAutoSize(true); +``` + +The same `Shared\Date` functions that convert between Excel dates and Unix Timestamps or PHP DateTime objects can also be used to convert time values. + +## Dates/Times and Value Binders + +It is a standing joke that MS Excel tries to identify date and time values as you are entering data in Excel, and often treats any value as a possible date, converting it to an Excel serialized timestamp and applying date/time formatting, even though that may not have been what was intended. +![StringDateValues.jpg](images/The Dating Game/StringDateValues.jpg) + +Any change to a cell value in PhpSpreadsheet that isn't made using the setCellValueExplicit() method is passed through a "Value Binder", which triggers additional settings for that cell. + +> **Note**: dates and times maintained in true spreadsheet format files are always correctly identified in the file, and setCellValueExplicit() is used to store those values, with the formatting mask defined in that file. However, non-spreadsheet files such as .csv don't identify dates/times, so the Value Binder behaviour applies when loading those files. + + +The Default Value Binder attempts to identify the datatype of the value and sets the cell datatype accordingly: and if you pass a string value like '2023-01-18 12:15:00' then the Default Value Binder will simply treat it as a string, and set it accordingly. + +However, if you choose to use the Advanced Value Binder, then that contains logic which looks more closely at those string values, and tries to determine if they are likely to be formatted dates or times, and in that regard it emulates Excel's behaviour. If the string value looks like a date or time, then it is converted to a serialized Excel date/timestamp, and an appropriate format mask is set for that cell. + +While the logic of this isn't perfect, I'd like to believe that it is less error-prone than Excel's own logic; but it won't necessarily recognise ambiguous formats and values correctly like '1/7/2023' as dates. (Is that the 1st of July? Or the 7th of January?) + +## Reading Dates and Times from cells + +When we read the value of a cell that contains a Date/Time value, we are only reading a number (the Excel serialized timestamp); it is only the Number Format Mask that allows us to say whether that number is simply a number, or is meant to be a Date/Time value. + +PhpSpreadsheet provides a number of methods to identify whether a Number Format Mask represents a Date/Time format mask, or whether a Cell is formatted as a Date/Time. + +- Shared\Date::isDateTimeFormat() + - Identifies if a NumberFormat Style object is an Excel Date/Time style format mask. +- Shared\Date::isDateTimeFormatCode() + - Identifies if the string value of a Number Format mask is an Excel Date/Time style format mask. +- Shared\Date::isDateTime() + - Identifies if a Cell is styled with a Number Format mask is an Excel Date/Time style format mask.**** + +These functions allow you to identify whether a cell value should be converted to a Unix Timestamp or PHP DateTime object (using one of the conversion functions provided in `Shared\Date`) for processing in your script, or for formatting as a Date/Time value. + +## Date Arithmetic + +Because dates and times are just numeric values in MS Excel, this makes date/time arithmetic very easy to calculate: to count the number of days (or a duration) between two dates, we can use a simple subtraction. +![Date Arithmetic.png](images/The Dating Game/Date Arithmetic.png) + +Similarly, we can add date/time values like adding a duration to a start date to calculate an end date. +![Date Arithmetic 2.png](images/The Dating Game/Date Arithmetic 2.png) + +## Excel Date Functions + +MS Excel provides a number of functions that will return a date or a time value. It also recognises when one of these functions is the "outer" function in a formula, so the final result will always be a serialized date/time value, and sets an appropriate format mask for that value. + +This behaviour is not replicated in PhpSpreadsheet. If you set a formula for a cell through your code that will return a date or time value, then you will also need to set the format mask for that cell manually. + +## Formatting Options + +PhpSpreadsheet provides a number of built-in format code constants for dates and times in the NumberFormat class, matching those defined in the OfficeOpenXML specification, but you can always just set the format to any valid Excel formatting string, the equivalent of setting a custom date format in Excel. + +### Dates + +When setting up a custom date format in Excel, you can use the following codes. + +| Code | Description | Example (January 3, 2023) | +|-------|-------------------------------------|---------------------------------------| +| m | Month number without a leading zero | 1 | +| mm | Month number with a leading zero | 01 | +| mmm | Month name, short form | Jan | +| mmmm | Month name, full form | January | +| mmmmm | Month as the first letter | J (stands for January, June and July) | +| d | Day number without a leading zero | 3 | +| dd | Day number with a leading zero | 03 | +| ddd | Day of the week, short form | Tue | +| dddd | Day of the week, full form | Tuesday | +| yy | Year (last 2 digits) | 23 | +| yyyy | Year (4 digits) | 2023 | + +### Times + +When setting up a custom time format in Excel, you can use the following codes. + +| Code | Description | Displays as | +|--------|--------------------------------------------------------------------|-------------| +| h | Hours without a leading zero | 0-23 | +| hh | Hours with a leading zero | 00-23 | +| m | Minutes without a leading zero | 0-59 | +| mm | Minutes with a leading zero | 00-59 | +| s | Seconds without a leading zero | 0-59 | +| ss | Seconds with a leading zero | 00-59 | +| AM/PM | Periods of the day
(if omitted, 24-hour time format is used) | AM or PM | +> **Warning** +MS Excel allows any separator character between hours/minutes/seconds; PhpSpreadsheet currently requires a colon (`:`) to correctly distinguish minutes from months when rendering a time format within PHP code, although it will correctly write the format to file if any other separator character is used. + +### Duration (Elapsed Time) + +Excel also supports formatting a value as a duration; a total number of hours, minutes or seconds rather than a time of day. + +| Code | Description | Displays as | +|---------|----------------------------------------------------------------|-------------| +| [h]:mm | Elapsed time in hours | e.g. 25:02 | +| [hh]:mm | Elapsed time in hours
with a leading zero if less than 10 | e.g. 05:02 | +| [mm]:ss | Elapsed time in minutes | e.g. 63:46 | +| [m]:ss | Elapsed time in minutes
with a leading zero if less than 10 | e.g. 03:46 | +| [s] | Elapsed time in seconds | | +| [ss] | Elapsed time in seconds
with a leading zero if less than 10 | | + +If you want to display an elapsed time in days, then you can use a normal date mask like `d h:mm` (without month or year), with the limitation that it will not exceed 31 days. + +### Localisation + +#### Built-in Formats + +Some of the Excel built-in format masks are locale-aware. +![Locale.png](images/The Dating Game/Locale.png) + +Those locale-aware formats are highlighted with a * in Excel's drop-down lists for Date and Time, and will adapt when viewed in MS Excel, based on the locale settings of that local PC. + +PhpSpreadsheet is not locale-aware, so it will not render these formats as locale-formats, just as generic Date/Time formats; and localisation will be not be saved when writing to a spreadsheet file. + +#### Custom Formats + +When you're displaying a date/time using a format mask that isn't locale-aware in MS Excel, the locale settings for Excel are applied. So if you're displaying a month or day name, these will appear in the appropriate language for the locale of your PC. +![Locale1.png](images/The Dating Game/Locale1.png) + +You can force the display for a specific locale by prefixing the format mask with a locale setting, so that Excel will render it using the appropriate language. The locale code should be enclosed in [square brackets] and preceded with the dollar sign ($) and a dash (-). + +In this example, I'm forcing Dutch Netherlands by prefixing the mask with `[$-nl-NL]`. As an alternative, I could also have used the Windows LCID for that locale (`[$-413]`). +![Locale2.png](images/The Dating Game/Locale2.png) + +Because PhpSpreadsheet is not locale-aware, displaying the formatted value for that cell in your PHP script won't show the locale date/time, only a generic date/time; but it will still be written correctly when the file is saved (as a spreadsheet format) for Excel to render correctly and in the appropriate language. + +If you need this, then you can find a list of Windows LCID values [here](https://learn.microsoft.com/en-us/openspecs/windows_protocols/ms-adts/a29e5c28-9fb9-4c49-8e43-4b9b8e733a05) + +## Summary Example + +Let's summarise some of this information with a script that will build a project timesheet for a week of work on that project. + +I'll keep the basic data in arrays for simplicity. +```php +// In a real application, we might read this data from a database to build a project timesheet +$projectHeading = [['Project', 'PhpSpreadsheet - The Dating Game']]; +$weekHeading = [ + ['Week', '=ISOWEEKNUM(D3)', 'Start Date', '=DATE(2023,1,16)'], + ['Day', 'Start Time', 'End Time', 'Hours Worked'], +]; + +$timesheetData = [ + ['2023-01-16', '17:25', '20:15'], + ['2023-01-16', '20:50', '23:35'], + ['2023-01-17', '18:10', '19:35'], + ['2023-01-17', '20:10', '22:15'], + ['2023-01-17', '22:35', '23:45'], + ['2023-01-18', '09:15', '11:25'], +]; +``` +Because I've used string values for the dates and times, I'm going to use the Advanced Value Binder to populate the worksheet. +The Binder will also format the time values in columns B and C; but I want to override the Binder formatting for date values in column A; and I'm using an Excel formula for the "start date" value in cell D3, so I have to set the format manually for that. +```php +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Cell\Cell; +use PhpOffice\PhpSpreadsheet\Cell\AdvancedValueBinder; +use PhpOffice\PhpSpreadsheet\Style\Alignment; + +// Create new Spreadsheet object +$spreadsheet = new Spreadsheet(); +$worksheet = $spreadsheet->getActiveSheet(); +// Use the Advanced Value Binder so that our string date/time values will be automatically converted +// to Excel serialized date/timestamps +Cell::setValueBinder(new AdvancedValueBinder()); + +// Write our data to the worksheet +$worksheet->fromArray($projectHeading); +$worksheet->fromArray($weekHeading, null, 'A3'); + +// Let Excel calculate the duration for each timesheet entry +$row = 4; +foreach ($timesheetData as $timesheetEntry) { + ++$row; + $worksheet->fromArray($timesheetEntry, null, "A{$row}"); + $worksheet->setCellValue("D{$row}", "=C{$row} - B{$row}"); +} +$totalRow = $row + 2; +$worksheet->setCellValue("D{$totalRow}", "=SUM(D4:D{$row})"); +$worksheet->setCellValue("E{$totalRow}", 'Total Hours'); +``` +And then the final formatting: +```php +// Display values in column A as human-readable dates +$worksheet->getStyle("A5:A{$row}")->getNumberFormat()->setFormatCode('dd (ddd)'); +// Display values in column D as human-readable durations +$worksheet->getStyle("D5:D{$totalRow}")->getNumberFormat()->setFormatCode('[h]:mm'); + +// Set some additional styling +$worksheet->getStyle('D3')->getNumberFormat()->setFormatCode('yyyy-mm-dd'); +$worksheet->getStyle('A1')->getFont()->setBold(true); +$worksheet->getStyle('A3:D4')->getFont()->setBold(true); +$worksheet->getStyle("A5:A{$row}")->getAlignment()->setHorizontal(Alignment::HORIZONTAL_LEFT); +$worksheet->getColumnDimension('D')->setAutoSize(true); +$worksheet->getStyle("E{$totalRow}")->getFont()->setBold(true); +``` + +And the resulting spreadsheet will look something like: +![Timesheet.png](images/The Dating Game/Timesheet.png) + +## Final Notes + +MS Excel is not Timezone aware, nor does it have any logic for handling Daylight Savings. + +Excel functions like the NOW() function return the serialized timestamp of the current date and time. The date and time are provided by the operating system, and that determines, according to the time zone and date of the year if Daylight Saving Time is in effect. So functions like NOW() include any DST offset, because it is automatically included by the OS. diff --git a/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 1.png b/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 1.png new file mode 100644 index 000000000..34bbfb957 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 1.png differ diff --git a/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 2.png b/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 2.png new file mode 100644 index 000000000..77a898e65 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Accounting Format Wizard - Code 2.png differ diff --git a/docs/topics/images/Behind the Mask/Additional Masking Symbols.png b/docs/topics/images/Behind the Mask/Additional Masking Symbols.png new file mode 100644 index 000000000..b1f567b50 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Additional Masking Symbols.png differ diff --git a/docs/topics/images/Behind the Mask/Basic Masking Symbols.png b/docs/topics/images/Behind the Mask/Basic Masking Symbols.png new file mode 100644 index 000000000..3417d6be8 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Basic Masking Symbols.png differ diff --git a/docs/topics/images/Behind the Mask/Composite - Basic Wizard.png b/docs/topics/images/Behind the Mask/Composite - Basic Wizard.png new file mode 100644 index 000000000..f89da8670 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Composite - Basic Wizard.png differ diff --git a/docs/topics/images/Behind the Mask/Composite - Locale Wizard.png b/docs/topics/images/Behind the Mask/Composite - Locale Wizard.png new file mode 100644 index 000000000..d6691cd8d Binary files /dev/null and b/docs/topics/images/Behind the Mask/Composite - Locale Wizard.png differ diff --git a/docs/topics/images/Behind the Mask/Conditional 1.png b/docs/topics/images/Behind the Mask/Conditional 1.png new file mode 100644 index 000000000..39e885d52 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Conditional 1.png differ diff --git a/docs/topics/images/Behind the Mask/Conditional 2.png b/docs/topics/images/Behind the Mask/Conditional 2.png new file mode 100644 index 000000000..4c743c299 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Conditional 2.png differ diff --git a/docs/topics/images/Behind the Mask/Conditional Symbols.png b/docs/topics/images/Behind the Mask/Conditional Symbols.png new file mode 100644 index 000000000..52db87cbc Binary files /dev/null and b/docs/topics/images/Behind the Mask/Conditional Symbols.png differ diff --git a/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 1.png b/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 1.png new file mode 100644 index 000000000..8ce656f39 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 1.png differ diff --git a/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 2.png b/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 2.png new file mode 100644 index 000000000..65cd4e4d7 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Currency Format Wizard - Code 2.png differ diff --git a/docs/topics/images/Behind the Mask/Date Format Codes.png b/docs/topics/images/Behind the Mask/Date Format Codes.png new file mode 100644 index 000000000..068976ff6 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Date Format Codes.png differ diff --git a/docs/topics/images/Behind the Mask/Digit Placeholders.png b/docs/topics/images/Behind the Mask/Digit Placeholders.png new file mode 100644 index 000000000..357d1d335 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Digit Placeholders.png differ diff --git a/docs/topics/images/Behind the Mask/Duration Format Codes.png b/docs/topics/images/Behind the Mask/Duration Format Codes.png new file mode 100644 index 000000000..21f708dc7 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Duration Format Codes.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Accounting.png b/docs/topics/images/Behind the Mask/Excel Number Format - Accounting.png new file mode 100644 index 000000000..70d57e321 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Accounting.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Currency.png b/docs/topics/images/Behind the Mask/Excel Number Format - Currency.png new file mode 100644 index 000000000..8c097f097 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Currency.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Custom.png b/docs/topics/images/Behind the Mask/Excel Number Format - Custom.png new file mode 100644 index 000000000..50a540add Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Custom.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Date.png b/docs/topics/images/Behind the Mask/Excel Number Format - Date.png new file mode 100644 index 000000000..510d4b8b2 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Date.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Fraction.png b/docs/topics/images/Behind the Mask/Excel Number Format - Fraction.png new file mode 100644 index 000000000..49db29dea Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Fraction.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - General.png b/docs/topics/images/Behind the Mask/Excel Number Format - General.png new file mode 100644 index 000000000..eae21f660 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - General.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Number.png b/docs/topics/images/Behind the Mask/Excel Number Format - Number.png new file mode 100644 index 000000000..d6ad5ae26 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Number.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Percentage.png b/docs/topics/images/Behind the Mask/Excel Number Format - Percentage.png new file mode 100644 index 000000000..cafac8b18 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Percentage.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Scientific.png b/docs/topics/images/Behind the Mask/Excel Number Format - Scientific.png new file mode 100644 index 000000000..b1b572665 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Scientific.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Special.png b/docs/topics/images/Behind the Mask/Excel Number Format - Special.png new file mode 100644 index 000000000..1e0550e24 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Special.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Text.png b/docs/topics/images/Behind the Mask/Excel Number Format - Text.png new file mode 100644 index 000000000..b87e0fa81 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Text.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format - Time.png b/docs/topics/images/Behind the Mask/Excel Number Format - Time.png new file mode 100644 index 000000000..71137b67e Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format - Time.png differ diff --git a/docs/topics/images/Behind the Mask/Excel Number Format.png b/docs/topics/images/Behind the Mask/Excel Number Format.png new file mode 100644 index 000000000..6369bc533 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Excel Number Format.png differ diff --git a/docs/topics/images/Behind the Mask/Hiding Values.png b/docs/topics/images/Behind the Mask/Hiding Values.png new file mode 100644 index 000000000..ecafcc9c0 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Hiding Values.png differ diff --git a/docs/topics/images/Behind the Mask/Indent.png b/docs/topics/images/Behind the Mask/Indent.png new file mode 100644 index 000000000..527421523 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Indent.png differ diff --git a/docs/topics/images/Behind the Mask/Mask Sections.gif b/docs/topics/images/Behind the Mask/Mask Sections.gif new file mode 100644 index 000000000..724634d1f Binary files /dev/null and b/docs/topics/images/Behind the Mask/Mask Sections.gif differ diff --git a/docs/topics/images/Behind the Mask/Mask Sections.png b/docs/topics/images/Behind the Mask/Mask Sections.png new file mode 100644 index 000000000..04f1efbdb Binary files /dev/null and b/docs/topics/images/Behind the Mask/Mask Sections.png differ diff --git a/docs/topics/images/Behind the Mask/Number Format Wizard - Code.png b/docs/topics/images/Behind the Mask/Number Format Wizard - Code.png new file mode 100644 index 000000000..287ce4594 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Number Format Wizard - Code.png differ diff --git a/docs/topics/images/Behind the Mask/Padding.png b/docs/topics/images/Behind the Mask/Padding.png new file mode 100644 index 000000000..a5c854695 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Padding.png differ diff --git a/docs/topics/images/Behind the Mask/Percentage Format Wizard - Code.png b/docs/topics/images/Behind the Mask/Percentage Format Wizard - Code.png new file mode 100644 index 000000000..db7af15d2 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Percentage Format Wizard - Code.png differ diff --git a/docs/topics/images/Behind the Mask/Reading Cell Format - Code.png b/docs/topics/images/Behind the Mask/Reading Cell Format - Code.png new file mode 100644 index 000000000..ca5a0b5ad Binary files /dev/null and b/docs/topics/images/Behind the Mask/Reading Cell Format - Code.png differ diff --git a/docs/topics/images/Behind the Mask/Reading Cell Format - Output.png b/docs/topics/images/Behind the Mask/Reading Cell Format - Output.png new file mode 100644 index 000000000..2265cde7f Binary files /dev/null and b/docs/topics/images/Behind the Mask/Reading Cell Format - Output.png differ diff --git a/docs/topics/images/Behind the Mask/Reading Cell Values - Code.png b/docs/topics/images/Behind the Mask/Reading Cell Values - Code.png new file mode 100644 index 000000000..d0f60b59b Binary files /dev/null and b/docs/topics/images/Behind the Mask/Reading Cell Values - Code.png differ diff --git a/docs/topics/images/Behind the Mask/Reading Cell Values - Output.png b/docs/topics/images/Behind the Mask/Reading Cell Values - Output.png new file mode 100644 index 000000000..e4d4048b3 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Reading Cell Values - Output.png differ diff --git a/docs/topics/images/Behind the Mask/Right Align.png b/docs/topics/images/Behind the Mask/Right Align.png new file mode 100644 index 000000000..549b89f32 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Right Align.png differ diff --git a/docs/topics/images/Behind the Mask/Scaling Example.png b/docs/topics/images/Behind the Mask/Scaling Example.png new file mode 100644 index 000000000..4526bf62c Binary files /dev/null and b/docs/topics/images/Behind the Mask/Scaling Example.png differ diff --git a/docs/topics/images/Behind the Mask/Scientific Format Wizard - Code.png b/docs/topics/images/Behind the Mask/Scientific Format Wizard - Code.png new file mode 100644 index 000000000..898472528 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Scientific Format Wizard - Code.png differ diff --git a/docs/topics/images/Behind the Mask/Setting a Mask - Code 1.png b/docs/topics/images/Behind the Mask/Setting a Mask - Code 1.png new file mode 100644 index 000000000..104a8fe04 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Setting a Mask - Code 1.png differ diff --git a/docs/topics/images/Behind the Mask/Setting a Mask - Code 2.png b/docs/topics/images/Behind the Mask/Setting a Mask - Code 2.png new file mode 100644 index 000000000..e2f34d705 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Setting a Mask - Code 2.png differ diff --git a/docs/topics/images/Behind the Mask/Setting a Mask - Code 3.png b/docs/topics/images/Behind the Mask/Setting a Mask - Code 3.png new file mode 100644 index 000000000..eabc1e23b Binary files /dev/null and b/docs/topics/images/Behind the Mask/Setting a Mask - Code 3.png differ diff --git a/docs/topics/images/Behind the Mask/Setting a Mask - Output 1.png b/docs/topics/images/Behind the Mask/Setting a Mask - Output 1.png new file mode 100644 index 000000000..40ef6f134 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Setting a Mask - Output 1.png differ diff --git a/docs/topics/images/Behind the Mask/Stock Portfolio.png b/docs/topics/images/Behind the Mask/Stock Portfolio.png new file mode 100644 index 000000000..c364f81a0 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Stock Portfolio.png differ diff --git a/docs/topics/images/Behind the Mask/Stock Portfolio.xlsx b/docs/topics/images/Behind the Mask/Stock Portfolio.xlsx new file mode 100644 index 000000000..9b51e56f3 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Stock Portfolio.xlsx differ diff --git a/docs/topics/images/Behind the Mask/Summary - Still a numeric value.png b/docs/topics/images/Behind the Mask/Summary - Still a numeric value.png new file mode 100644 index 000000000..a2e6dec7b Binary files /dev/null and b/docs/topics/images/Behind the Mask/Summary - Still a numeric value.png differ diff --git a/docs/topics/images/Behind the Mask/TEXT Function.png b/docs/topics/images/Behind the Mask/TEXT Function.png new file mode 100644 index 000000000..1242a8c75 Binary files /dev/null and b/docs/topics/images/Behind the Mask/TEXT Function.png differ diff --git a/docs/topics/images/Behind the Mask/Text Single Character Example.png b/docs/topics/images/Behind the Mask/Text Single Character Example.png new file mode 100644 index 000000000..14356c88a Binary files /dev/null and b/docs/topics/images/Behind the Mask/Text Single Character Example.png differ diff --git a/docs/topics/images/Behind the Mask/Text Single Character Exceptions.png b/docs/topics/images/Behind the Mask/Text Single Character Exceptions.png new file mode 100644 index 000000000..99173ca48 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Text Single Character Exceptions.png differ diff --git a/docs/topics/images/Behind the Mask/Text String Example.png b/docs/topics/images/Behind the Mask/Text String Example.png new file mode 100644 index 000000000..2125f39da Binary files /dev/null and b/docs/topics/images/Behind the Mask/Text String Example.png differ diff --git a/docs/topics/images/Behind the Mask/Time Format Codes.png b/docs/topics/images/Behind the Mask/Time Format Codes.png new file mode 100644 index 000000000..321693160 Binary files /dev/null and b/docs/topics/images/Behind the Mask/Time Format Codes.png differ diff --git a/docs/topics/images/Looping the Loop/Empty Rows 2.png b/docs/topics/images/Looping the Loop/Empty Rows 2.png new file mode 100644 index 000000000..304f1cf3b Binary files /dev/null and b/docs/topics/images/Looping the Loop/Empty Rows 2.png differ diff --git a/docs/topics/images/Looping the Loop/Empty Rows.png b/docs/topics/images/Looping the Loop/Empty Rows.png new file mode 100644 index 000000000..bfc822eb8 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Empty Rows.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Basic Code.png b/docs/topics/images/Looping the Loop/Iterators Basic Code.png new file mode 100644 index 000000000..ad2748789 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Basic Code.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Empty Row 2.png b/docs/topics/images/Looping the Loop/Iterators Empty Row 2.png new file mode 100644 index 000000000..5ff772333 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Empty Row 2.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Empty Row 3.png b/docs/topics/images/Looping the Loop/Iterators Empty Row 3.png new file mode 100644 index 000000000..2e00a7777 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Empty Row 3.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Empty Row.png b/docs/topics/images/Looping the Loop/Iterators Empty Row.png new file mode 100644 index 000000000..56b642f94 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Empty Row.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Existing Only.png b/docs/topics/images/Looping the Loop/Iterators Existing Only.png new file mode 100644 index 000000000..13584e9f3 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Existing Only.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Memory and Timings.png b/docs/topics/images/Looping the Loop/Iterators Memory and Timings.png new file mode 100644 index 000000000..248ffae22 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Memory and Timings.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Range 1.png b/docs/topics/images/Looping the Loop/Iterators Range 1.png new file mode 100644 index 000000000..e67af5f1e Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Range 1.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Range 2.png b/docs/topics/images/Looping the Loop/Iterators Range 2.png new file mode 100644 index 000000000..20fd1fb14 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Range 2.png differ diff --git a/docs/topics/images/Looping the Loop/Iterators Return Null.png b/docs/topics/images/Looping the Loop/Iterators Return Null.png new file mode 100644 index 000000000..e5afc88cb Binary files /dev/null and b/docs/topics/images/Looping the Loop/Iterators Return Null.png differ diff --git a/docs/topics/images/Looping the Loop/Summary of Memory Usage and Timings.png b/docs/topics/images/Looping the Loop/Summary of Memory Usage and Timings.png new file mode 100644 index 000000000..c76ad94f0 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Summary of Memory Usage and Timings.png differ diff --git a/docs/topics/images/Looping the Loop/Table with Empty Rows.png b/docs/topics/images/Looping the Loop/Table with Empty Rows.png new file mode 100644 index 000000000..f3986f770 Binary files /dev/null and b/docs/topics/images/Looping the Loop/Table with Empty Rows.png differ diff --git a/docs/topics/images/Looping the Loop/rangeToArray Basic Code.png b/docs/topics/images/Looping the Loop/rangeToArray Basic Code.png new file mode 100644 index 000000000..005227ddf Binary files /dev/null and b/docs/topics/images/Looping the Loop/rangeToArray Basic Code.png differ diff --git a/docs/topics/images/Looping the Loop/rangeToArray Batch 2.png b/docs/topics/images/Looping the Loop/rangeToArray Batch 2.png new file mode 100644 index 000000000..adc1a3633 Binary files /dev/null and b/docs/topics/images/Looping the Loop/rangeToArray Batch 2.png differ diff --git a/docs/topics/images/Looping the Loop/rangeToArray Batch Memory and Timings.png b/docs/topics/images/Looping the Loop/rangeToArray Batch Memory and Timings.png new file mode 100644 index 000000000..4942918df Binary files /dev/null and b/docs/topics/images/Looping the Loop/rangeToArray Batch Memory and Timings.png differ diff --git a/docs/topics/images/Looping the Loop/rangeToArray Batch.png b/docs/topics/images/Looping the Loop/rangeToArray Batch.png new file mode 100644 index 000000000..1945b9bec Binary files /dev/null and b/docs/topics/images/Looping the Loop/rangeToArray Batch.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Arguments.png b/docs/topics/images/Looping the Loop/toArray Arguments.png new file mode 100644 index 000000000..c45c96614 Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Arguments.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Basic Code.png b/docs/topics/images/Looping the Loop/toArray Basic Code.png new file mode 100644 index 000000000..cd0eb716c Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Basic Code.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Break at Empty Row.png b/docs/topics/images/Looping the Loop/toArray Break at Empty Row.png new file mode 100644 index 000000000..5b25d39d0 Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Break at Empty Row.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Memory and Timings.png b/docs/topics/images/Looping the Loop/toArray Memory and Timings.png new file mode 100644 index 000000000..68934b944 Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Memory and Timings.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Monthly Sales 2.png b/docs/topics/images/Looping the Loop/toArray Monthly Sales 2.png new file mode 100644 index 000000000..5f6b7b838 Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Monthly Sales 2.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Monthly Sales.png b/docs/topics/images/Looping the Loop/toArray Monthly Sales.png new file mode 100644 index 000000000..52fd6999e Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Monthly Sales.png differ diff --git a/docs/topics/images/Looping the Loop/toArray Skip Empty Rows.png b/docs/topics/images/Looping the Loop/toArray Skip Empty Rows.png new file mode 100644 index 000000000..95683505d Binary files /dev/null and b/docs/topics/images/Looping the Loop/toArray Skip Empty Rows.png differ diff --git a/docs/topics/images/The Dating Game/Date Arithmetic 2.png b/docs/topics/images/The Dating Game/Date Arithmetic 2.png new file mode 100644 index 000000000..c6b73cee2 Binary files /dev/null and b/docs/topics/images/The Dating Game/Date Arithmetic 2.png differ diff --git a/docs/topics/images/The Dating Game/Date Arithmetic.png b/docs/topics/images/The Dating Game/Date Arithmetic.png new file mode 100644 index 000000000..9795be70d Binary files /dev/null and b/docs/topics/images/The Dating Game/Date Arithmetic.png differ diff --git a/docs/topics/images/The Dating Game/Date Code 1.png b/docs/topics/images/The Dating Game/Date Code 1.png new file mode 100644 index 000000000..02a9c2ec7 Binary files /dev/null and b/docs/topics/images/The Dating Game/Date Code 1.png differ diff --git a/docs/topics/images/The Dating Game/Date Format Codes.png b/docs/topics/images/The Dating Game/Date Format Codes.png new file mode 100644 index 000000000..068976ff6 Binary files /dev/null and b/docs/topics/images/The Dating Game/Date Format Codes.png differ diff --git a/docs/topics/images/The Dating Game/Date as a number.png b/docs/topics/images/The Dating Game/Date as a number.png new file mode 100644 index 000000000..99523ff9a Binary files /dev/null and b/docs/topics/images/The Dating Game/Date as a number.png differ diff --git a/docs/topics/images/The Dating Game/Duration Format Codes.png b/docs/topics/images/The Dating Game/Duration Format Codes.png new file mode 100644 index 000000000..21f708dc7 Binary files /dev/null and b/docs/topics/images/The Dating Game/Duration Format Codes.png differ diff --git a/docs/topics/images/The Dating Game/Locale.png b/docs/topics/images/The Dating Game/Locale.png new file mode 100644 index 000000000..0df2c5f5b Binary files /dev/null and b/docs/topics/images/The Dating Game/Locale.png differ diff --git a/docs/topics/images/The Dating Game/Locale1.png b/docs/topics/images/The Dating Game/Locale1.png new file mode 100644 index 000000000..276e22fb1 Binary files /dev/null and b/docs/topics/images/The Dating Game/Locale1.png differ diff --git a/docs/topics/images/The Dating Game/Locale2.png b/docs/topics/images/The Dating Game/Locale2.png new file mode 100644 index 000000000..42502106a Binary files /dev/null and b/docs/topics/images/The Dating Game/Locale2.png differ diff --git a/docs/topics/images/The Dating Game/StringDateValues.jpg b/docs/topics/images/The Dating Game/StringDateValues.jpg new file mode 100644 index 000000000..910645e01 Binary files /dev/null and b/docs/topics/images/The Dating Game/StringDateValues.jpg differ diff --git a/docs/topics/images/The Dating Game/Time Code 2.png b/docs/topics/images/The Dating Game/Time Code 2.png new file mode 100644 index 000000000..c308d64d0 Binary files /dev/null and b/docs/topics/images/The Dating Game/Time Code 2.png differ diff --git a/docs/topics/images/The Dating Game/Time Format Codes.png b/docs/topics/images/The Dating Game/Time Format Codes.png new file mode 100644 index 000000000..321693160 Binary files /dev/null and b/docs/topics/images/The Dating Game/Time Format Codes.png differ diff --git a/docs/topics/images/The Dating Game/Time as a number.png b/docs/topics/images/The Dating Game/Time as a number.png new file mode 100644 index 000000000..16901d87f Binary files /dev/null and b/docs/topics/images/The Dating Game/Time as a number.png differ diff --git a/docs/topics/images/The Dating Game/Timesheet Code 1.png b/docs/topics/images/The Dating Game/Timesheet Code 1.png new file mode 100644 index 000000000..99cfd8f2b Binary files /dev/null and b/docs/topics/images/The Dating Game/Timesheet Code 1.png differ diff --git a/docs/topics/images/The Dating Game/Timesheet Code 2.png b/docs/topics/images/The Dating Game/Timesheet Code 2.png new file mode 100644 index 000000000..b8e3a24c9 Binary files /dev/null and b/docs/topics/images/The Dating Game/Timesheet Code 2.png differ diff --git a/docs/topics/images/The Dating Game/Timesheet Code 3.png b/docs/topics/images/The Dating Game/Timesheet Code 3.png new file mode 100644 index 000000000..e1921e384 Binary files /dev/null and b/docs/topics/images/The Dating Game/Timesheet Code 3.png differ diff --git a/docs/topics/images/The Dating Game/Timesheet.png b/docs/topics/images/The Dating Game/Timesheet.png new file mode 100644 index 000000000..d843d7bc3 Binary files /dev/null and b/docs/topics/images/The Dating Game/Timesheet.png differ diff --git a/src/PhpSpreadsheet/Calculation/TextData/Format.php b/src/PhpSpreadsheet/Calculation/TextData/Format.php index 32df63eb5..0fa67d7bb 100644 --- a/src/PhpSpreadsheet/Calculation/TextData/Format.php +++ b/src/PhpSpreadsheet/Calculation/TextData/Format.php @@ -124,6 +124,7 @@ class Format $value = Helpers::extractString($value); $format = Helpers::extractString($format); + $format = (string) NumberFormat::convertSystemFormats($format); if (!is_numeric($value) && Date::isDateTimeFormatCode($format)) { $value1 = DateTimeExcel\DateValue::fromString($value); diff --git a/src/PhpSpreadsheet/Cell/Cell.php b/src/PhpSpreadsheet/Cell/Cell.php index 987e1a361..4cbdbbe92 100644 --- a/src/PhpSpreadsheet/Cell/Cell.php +++ b/src/PhpSpreadsheet/Cell/Cell.php @@ -182,7 +182,7 @@ class Cell implements Stringable { return (string) NumberFormat::toFormattedString( $this->getCalculatedValue(), - (string) $this->getStyle()->getNumberFormat()->getFormatCode() + (string) $this->getStyle()->getNumberFormat()->getFormatCode(true) ); } diff --git a/src/PhpSpreadsheet/Reader/Xls.php b/src/PhpSpreadsheet/Reader/Xls.php index da12ce8a8..5910ad4cc 100644 --- a/src/PhpSpreadsheet/Reader/Xls.php +++ b/src/PhpSpreadsheet/Reader/Xls.php @@ -2740,6 +2740,7 @@ class Xls extends BaseReader $formula = $this->getFormulaFromStructure($formulaStructure); } catch (PhpSpreadsheetException) { $formula = ''; + $isBuiltInName = 0; } $this->definedname[] = [ diff --git a/src/PhpSpreadsheet/ReferenceHelper.php b/src/PhpSpreadsheet/ReferenceHelper.php index c12f6dea9..de9c3f25d 100644 --- a/src/PhpSpreadsheet/ReferenceHelper.php +++ b/src/PhpSpreadsheet/ReferenceHelper.php @@ -383,7 +383,7 @@ class ReferenceHelper } // Get coordinate of $beforeCellAddress - [$beforeColumn, $beforeRow] = Coordinate::indexesFromString($beforeCellAddress); + [$beforeColumn, $beforeRow, $beforeColumnString] = Coordinate::indexesFromString($beforeCellAddress); // Clear cells if we are removing columns or rows $highestColumn = $worksheet->getHighestColumn(); @@ -401,17 +401,19 @@ class ReferenceHelper $this->clearRowStrips($highestColumn, $beforeColumn, $beforeRow, $numberOfRows, $worksheet); } - // Find missing coordinates. This is important when inserting column before the last column - $cellCollection = $worksheet->getCellCollection(); - $missingCoordinates = array_filter( - array_map(fn ($row): string => "{$highestDataColumn}{$row}", range(1, $highestDataRow)), - fn ($coordinate): bool => $cellCollection->has($coordinate) === false - ); - - // Create missing cells with null values - if (!empty($missingCoordinates)) { - foreach ($missingCoordinates as $coordinate) { - $worksheet->createNewCell($coordinate); + // Find missing coordinates. This is important when inserting or deleting column before the last column + $startRow = $startCol = 1; + $startColString = 'A'; + if ($numberOfRows === 0) { + $startCol = $beforeColumn; + $startColString = $beforeColumnString; + } elseif ($numberOfColumns === 0) { + $startRow = $beforeRow; + } + $highColumn = Coordinate::columnIndexFromString($highestDataColumn); + for ($row = $startRow; $row <= $highestDataRow; ++$row) { + for ($col = $startCol, $colString = $startColString; $col <= $highColumn; ++$col, ++$colString) { + $worksheet->getCell("$colString$row"); // create cell if it doesn't exist } } diff --git a/src/PhpSpreadsheet/Shared/Date.php b/src/PhpSpreadsheet/Shared/Date.php index 55fe77c37..ed19534de 100644 --- a/src/PhpSpreadsheet/Shared/Date.php +++ b/src/PhpSpreadsheet/Shared/Date.php @@ -411,6 +411,7 @@ class Date } // Switch on formatcode + $excelFormatCode = (string) NumberFormat::convertSystemFormats($excelFormatCode); if (in_array($excelFormatCode, NumberFormat::DATE_TIME_OR_DATETIME_ARRAY, true)) { return $dateWithoutTimeOkay || in_array($excelFormatCode, NumberFormat::TIME_OR_DATETIME_ARRAY); } diff --git a/src/PhpSpreadsheet/Style/NumberFormat.php b/src/PhpSpreadsheet/Style/NumberFormat.php index 5153d84de..b344da64b 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat.php +++ b/src/PhpSpreadsheet/Style/NumberFormat.php @@ -26,10 +26,12 @@ class NumberFormat extends Supervisor 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_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_TIME1 = 'h:mm AM/PM'; const FORMAT_DATE_TIME2 = 'h:mm:ss AM/PM'; @@ -40,6 +42,7 @@ class NumberFormat extends Supervisor const FORMAT_DATE_TIME7 = 'i:s.S'; const FORMAT_DATE_TIME8 = 'h:mm:ss;@'; const FORMAT_DATE_YYYYMMDDSLASH = 'yyyy/mm/dd;@'; + const FORMAT_DATE_LONG_DATE = 'dddd, mmmm d, yyyy'; const DATE_TIME_OR_DATETIME_ARRAY = [ self::FORMAT_DATE_YYYYMMDD, @@ -49,10 +52,12 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_DMMINUS, self::FORMAT_DATE_MYMINUS, self::FORMAT_DATE_XLSX14, + self::FORMAT_DATE_XLSX14_ACTUAL, self::FORMAT_DATE_XLSX15, self::FORMAT_DATE_XLSX16, self::FORMAT_DATE_XLSX17, self::FORMAT_DATE_XLSX22, + self::FORMAT_DATE_XLSX22_ACTUAL, self::FORMAT_DATE_DATETIME, self::FORMAT_DATE_TIME1, self::FORMAT_DATE_TIME2, @@ -63,6 +68,7 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_TIME7, self::FORMAT_DATE_TIME8, self::FORMAT_DATE_YYYYMMDDSLASH, + self::FORMAT_DATE_LONG_DATE, ]; const TIME_OR_DATETIME_ARRAY = [ self::FORMAT_DATE_XLSX22, @@ -84,6 +90,21 @@ class NumberFormat extends Supervisor const FORMAT_ACCOUNTING_USD = '_("$"* #,##0.00_);_("$"* \(#,##0.00\);_("$"* "-"??_);_(@_)'; const FORMAT_ACCOUNTING_EUR = '_("€"* #,##0.00_);_("€"* \(#,##0.00\);_("€"* "-"??_);_(@_)'; + const SHORT_DATE_INDEX = 14; + const DATE_TIME_INDEX = 22; + const FORMAT_SYSDATE_X = '[$-x-sysdate]'; + const FORMAT_SYSDATE_F800 = '[$-F800]'; + const FORMAT_SYSTIME_X = '[$-x-systime]'; + const FORMAT_SYSTIME_F400 = '[$-F400]'; + + protected static string $shortDateFormat = self::FORMAT_DATE_XLSX14_ACTUAL; + + protected static string $longDateFormat = self::FORMAT_DATE_LONG_DATE; + + protected static string $dateTimeFormat = self::FORMAT_DATE_XLSX22_ACTUAL; + + protected static string $timeFormat = self::FORMAT_DATE_TIME2; + /** * Excel built-in number formats. */ @@ -178,16 +199,40 @@ class NumberFormat extends Supervisor /** * Get Format Code. */ - public function getFormatCode(): ?string + public function getFormatCode(bool $extended = false): ?string { if ($this->isSupervisor) { - return $this->getSharedComponent()->getFormatCode(); + return $this->getSharedComponent()->getFormatCode($extended); } - if (is_int($this->builtInFormatCode)) { - return self::builtInFormatCode($this->builtInFormatCode); + $builtin = $this->getBuiltInFormatCode(); + if (is_int($builtin)) { + if ($extended) { + if ($builtin === self::SHORT_DATE_INDEX) { + return self::$shortDateFormat; + } + if ($builtin === self::DATE_TIME_INDEX) { + return self::$dateTimeFormat; + } + } + + return self::builtInFormatCode($builtin); } - return $this->formatCode; + return $extended ? self::convertSystemFormats($this->formatCode) : $this->formatCode; + } + + public static function convertSystemFormats(?string $formatCode): ?string + { + if (is_string($formatCode)) { + if (stripos($formatCode, self::FORMAT_SYSDATE_F800) !== false || stripos($formatCode, self::FORMAT_SYSDATE_X) !== false) { + return self::$longDateFormat; + } + if (stripos($formatCode, self::FORMAT_SYSTIME_F400) !== false || stripos($formatCode, self::FORMAT_SYSTIME_X) !== false) { + return self::$timeFormat; + } + } + + return $formatCode; } /** @@ -290,15 +335,15 @@ class NumberFormat extends Supervisor self::$builtInFormats[11] = '0.00E+00'; self::$builtInFormats[12] = '# ?/?'; self::$builtInFormats[13] = '# ??/??'; - self::$builtInFormats[14] = 'm/d/yyyy'; // Despite ECMA 'mm-dd-yy'; - self::$builtInFormats[15] = 'd-mmm-yy'; + self::$builtInFormats[14] = self::FORMAT_DATE_XLSX14_ACTUAL; // Despite ECMA 'mm-dd-yy'; + self::$builtInFormats[15] = self::FORMAT_DATE_XLSX15; self::$builtInFormats[16] = 'd-mmm'; self::$builtInFormats[17] = 'mmm-yy'; self::$builtInFormats[18] = 'h:mm AM/PM'; self::$builtInFormats[19] = 'h:mm:ss AM/PM'; self::$builtInFormats[20] = 'h:mm'; self::$builtInFormats[21] = 'h:mm:ss'; - self::$builtInFormats[22] = 'm/d/yyyy h:mm'; // Despite ECMA 'm/d/yy h:mm'; + self::$builtInFormats[22] = self::FORMAT_DATE_XLSX22_ACTUAL; // Despite ECMA 'm/d/yy h:mm'; self::$builtInFormats[37] = '#,##0_);(#,##0)'; // Despite ECMA '#,##0 ;(#,##0)'; self::$builtInFormats[38] = '#,##0_);[Red](#,##0)'; // Despite ECMA '#,##0 ;[Red](#,##0)'; @@ -427,4 +472,44 @@ class NumberFormat extends Supervisor return $exportedArray; } + + public static function getShortDateFormat(): string + { + return self::$shortDateFormat; + } + + public static function setShortDateFormat(string $shortDateFormat): void + { + self::$shortDateFormat = $shortDateFormat; + } + + public static function getLongDateFormat(): string + { + return self::$longDateFormat; + } + + public static function setLongDateFormat(string $longDateFormat): void + { + self::$longDateFormat = $longDateFormat; + } + + public static function getDateTimeFormat(): string + { + return self::$dateTimeFormat; + } + + public static function setDateTimeFormat(string $dateTimeFormat): void + { + self::$dateTimeFormat = $dateTimeFormat; + } + + public static function getTimeFormat(): string + { + return self::$timeFormat; + } + + public static function setTimeFormat(string $timeFormat): void + { + self::$timeFormat = $timeFormat; + } } diff --git a/src/PhpSpreadsheet/Worksheet/Worksheet.php b/src/PhpSpreadsheet/Worksheet/Worksheet.php index 7afb95b79..ccf305b48 100644 --- a/src/PhpSpreadsheet/Worksheet/Worksheet.php +++ b/src/PhpSpreadsheet/Worksheet/Worksheet.php @@ -747,7 +747,7 @@ class Worksheet implements IComparable $cellValue = NumberFormat::toFormattedString( $cell->getCalculatedValue(), (string) $this->getParentOrThrow()->getCellXfByIndex($cell->getXfIndex()) - ->getNumberFormat()->getFormatCode() + ->getNumberFormat()->getFormatCode(true) ); if ($cellValue !== null && $cellValue !== '') { diff --git a/src/PhpSpreadsheet/Writer/Html.php b/src/PhpSpreadsheet/Writer/Html.php index 4dcc3d665..dd94738e9 100644 --- a/src/PhpSpreadsheet/Writer/Html.php +++ b/src/PhpSpreadsheet/Writer/Html.php @@ -103,6 +103,8 @@ class Html extends BaseWriter /** * Is the current writer creating mPDF? + * + * @deprecated 2.0.1 use instanceof Mpdf instead */ protected bool $isMPdf = false; @@ -454,7 +456,7 @@ class Html extends BaseWriter // Get worksheet dimension [$min, $max] = explode(':', $sheet->calculateWorksheetDataDimension()); - [$minCol, $minRow] = Coordinate::indexesFromString($min); + [$minCol, $minRow, $minColString] = Coordinate::indexesFromString($min); [$maxCol, $maxRow] = Coordinate::indexesFromString($max); $this->extendRowsAndColumns($sheet, $maxCol, $maxRow); @@ -467,16 +469,20 @@ class Html extends BaseWriter $html .= $startTag; // Write row if there are HTML table cells in it - $mpdfInvisible = $this->isMPdf && !$sheet->isRowVisible($row); - if (!$mpdfInvisible && !isset($this->isSpannedRow[$sheet->getParent()->getIndex($sheet)][$row])) { + if ($this->shouldGenerateRow($sheet, $row) && !isset($this->isSpannedRow[$sheet->getParent()->getIndex($sheet)][$row])) { // Start a new rowData $rowData = []; // Loop through columns $column = $minCol; + $colStr = $minColString; while ($column <= $maxCol) { // Cell exists? $cellAddress = Coordinate::stringFromColumnIndex($column) . $row; - $rowData[$column++] = ($sheet->getCellCollection()->has($cellAddress)) ? $cellAddress : ''; + if ($this->shouldGenerateColumn($sheet, $colStr)) { + $rowData[$column] = ($sheet->getCellCollection()->has($cellAddress)) ? $cellAddress : ''; + } + ++$column; + ++$colStr; } $html .= $this->generateRow($sheet, $rowData, $row - 1, $cellType); } @@ -610,7 +616,7 @@ class Html extends BaseWriter $filename = htmlspecialchars($filename, Settings::htmlEntityFlags()); $html .= PHP_EOL; - $imageData = self::winFileToUrl($filename, $this->isMPdf); + $imageData = self::winFileToUrl($filename, $this instanceof Pdf\Mpdf); if ($this->embedImages || str_starts_with($imageData, 'zip://')) { $picture = @file_get_contents($filename); @@ -822,9 +828,13 @@ class Html extends BaseWriter // col elements, initialize $highestColumnIndex = Coordinate::columnIndexFromString($sheet->getHighestColumn()) - 1; $column = -1; + $colStr = 'A'; while ($column++ < $highestColumnIndex) { $this->columnWidths[$sheetIndex][$column] = self::DEFAULT_CELL_WIDTH_POINTS; // approximation - $css['table.sheet' . $sheetIndex . ' col.col' . $column]['width'] = self::DEFAULT_CELL_WIDTH_POINTS . 'pt'; + if ($this->shouldGenerateColumn($sheet, $colStr)) { + $css['table.sheet' . $sheetIndex . ' col.col' . $column]['width'] = self::DEFAULT_CELL_WIDTH_POINTS . 'pt'; + } + ++$colStr; } // col elements, loop through columnDimensions and set width @@ -834,6 +844,9 @@ class Html extends BaseWriter $width = SharedDrawing::pixelsToPoints($width); if ($columnDimension->getVisible() === false) { $css['table.sheet' . $sheetIndex . ' .column' . $column]['display'] = 'none'; + // This would be better but Firefox has an 11-year-old bug. + // https://bugzilla.mozilla.org/show_bug.cgi?id=819045 + //$css['table.sheet' . $sheetIndex . ' col.col' . $column]['visibility'] = 'collapse'; } if ($width >= 0) { $this->columnWidths[$sheetIndex][$column] = $width; @@ -991,7 +1004,7 @@ class Html extends BaseWriter } $rotation = $alignment->getTextRotation(); if ($rotation !== 0 && $rotation !== Alignment::TEXTROTATION_STACK_PHPSPREADSHEET) { - if ($this->isMPdf) { + if ($this instanceof Pdf\Mpdf) { $css['text-rotate'] = "$rotation"; } else { $css['transform'] = "rotate({$rotation}deg)"; @@ -1782,4 +1795,25 @@ class Html extends BaseWriter return $htmlPage; } + + private function shouldGenerateRow(Worksheet $sheet, int $row): bool + { + if (!($this instanceof Pdf\Mpdf || $this instanceof Pdf\Tcpdf)) { + return true; + } + + return $sheet->isRowVisible($row); + } + + private function shouldGenerateColumn(Worksheet $sheet, string $colStr): bool + { + if (!($this instanceof Pdf\Mpdf || $this instanceof Pdf\Tcpdf)) { + return true; + } + if (!$sheet->columnDimensionExists($colStr)) { + return true; + } + + return $sheet->getColumnDimension($colStr)->getVisible(); + } } diff --git a/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php b/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php index 955cfba59..ca031b45e 100644 --- a/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php +++ b/src/PhpSpreadsheet/Writer/Pdf/Mpdf.php @@ -10,6 +10,11 @@ class Mpdf extends Pdf public const SIMULATED_BODY_START = ''; private const BODY_TAG = ''; + /** + * Is the current writer creating mPDF? + * + * @deprecated 2.0.1 use instanceof Mpdf instead + */ protected bool $isMPdf = true; /** diff --git a/tests/PhpSpreadsheetTests/Style/NumberFormatSystemDateTimeTest.php b/tests/PhpSpreadsheetTests/Style/NumberFormatSystemDateTimeTest.php new file mode 100644 index 000000000..52799b33e --- /dev/null +++ b/tests/PhpSpreadsheetTests/Style/NumberFormatSystemDateTimeTest.php @@ -0,0 +1,120 @@ +shortDateFormat = NumberFormat::getShortDateFormat(); + $this->longDateFormat = NumberFormat::getLongDateFormat(); + $this->dateTimeFormat = NumberFormat::getDateTimeFormat(); + $this->timeFormat = NumberFormat::getTimeFormat(); + } + + protected function tearDown(): void + { + NumberFormat::setShortDateFormat($this->shortDateFormat); + NumberFormat::setLongDateFormat($this->longDateFormat); + NumberFormat::setDateTimeFormat($this->dateTimeFormat); + NumberFormat::setTimeFormat($this->timeFormat); + } + + public function testOverrides(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $formula = '=DATEVALUE("2024-02-29")+TIMEVALUE("8:12:15 AM")'; + $sheet->getCell('A1')->setValue($formula); + $sheet->getCell('A2')->setValue($formula); + $sheet->getStyle('A2')->getNumberFormat() + ->setBuiltinFormatCode(14); + $sheet->getCell('A3')->setValue($formula); + $sheet->getStyle('A3')->getNumberFormat() + ->setBuiltinFormatCode(15); + $sheet->getCell('A4')->setValue($formula); + $sheet->getStyle('A4')->getNumberFormat() + ->setBuiltinFormatCode(22); + $sheet->getCell('A5')->setValue($formula); + $sheet->getStyle('A5')->getNumberFormat() + ->setFormatCode('[$-F800]'); + $sheet->getCell('A6')->setValue($formula); + $sheet->getStyle('A6')->getNumberFormat() + ->setFormatCode('[$-F400]'); + $sheet->getCell('A7')->setValue($formula); + $sheet->getStyle('A7')->getNumberFormat() + ->setFormatCode('[$-x-sysdate]'); + $sheet->getCell('A8')->setValue($formula); + $sheet->getStyle('A8')->getNumberFormat() + ->setFormatCode('[$-x-systime]'); + $sheet->getCell('A9')->setValue($formula); + $sheet->getStyle('A9')->getNumberFormat() + ->setFormatCode('hello' . NumberFormat::FORMAT_SYSDATE_F800 . 'goodbye'); + NumberFormat::setShortDateFormat('yyyy/mm/dd'); + NumberFormat::setDateTimeFormat('yyyy/mm/dd hh:mm AM/PM'); + NumberFormat::setLongDateFormat('dddd d mmm yyyy'); + NumberFormat::setTimeFormat('h:mm'); + self::assertSame('2024/02/29', $sheet->getCell('A2')->getformattedValue()); + self::assertSame('2024/02/29 08:12 AM', $sheet->getCell('A4')->getformattedValue()); + self::assertSame('Thursday 29 Feb 2024', $sheet->getCell('A5')->getformattedValue()); + self::assertSame('8:12', $sheet->getCell('A6')->getformattedValue()); + self::assertSame('Thursday 29 Feb 2024', $sheet->getCell('A7')->getformattedValue()); + self::assertSame('8:12', $sheet->getCell('A8')->getformattedValue()); + self::assertSame('Thursday 29 Feb 2024', $sheet->getCell('A9')->getformattedValue()); + $spreadsheet->disconnectWorksheets(); + } + + public function testDefaults(): void + { + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + $formula = '=DATEVALUE("2024-02-29")+TIMEVALUE("8:12:15 AM")'; + $sheet->getCell('A1')->setValue($formula); + $sheet->getCell('A2')->setValue($formula); + $sheet->getStyle('A2')->getNumberFormat() + ->setBuiltinFormatCode(14); + $sheet->getCell('A3')->setValue($formula); + $sheet->getStyle('A3')->getNumberFormat() + ->setBuiltinFormatCode(15); + $sheet->getCell('A4')->setValue($formula); + $sheet->getStyle('A4')->getNumberFormat() + ->setBuiltinFormatCode(22); + $sheet->getCell('A5')->setValue($formula); + $sheet->getStyle('A5')->getNumberFormat() + ->setFormatCode('[$-F800]'); + $sheet->getCell('A6')->setValue($formula); + $sheet->getStyle('A6')->getNumberFormat() + ->setFormatCode('[$-F400]'); + $sheet->getCell('A7')->setValue($formula); + $sheet->getStyle('A7')->getNumberFormat() + ->setFormatCode('[$-x-sysdate]'); + $sheet->getCell('A8')->setValue($formula); + $sheet->getStyle('A8')->getNumberFormat() + ->setFormatCode('[$-x-systime]'); + $sheet->getCell('A9')->setValue($formula); + $sheet->getStyle('A9')->getNumberFormat() + ->setFormatCode('hello' . NumberFormat::FORMAT_SYSDATE_F800 . 'goodbye'); + self::assertSame('2/29/2024', $sheet->getCell('A2')->getformattedValue()); + self::assertSame('2/29/2024 8:12', $sheet->getCell('A4')->getformattedValue()); + self::assertSame('Thursday, February 29, 2024', $sheet->getCell('A5')->getformattedValue()); + self::assertSame('8:12:15 AM', $sheet->getCell('A6')->getformattedValue()); + self::assertSame('Thursday, February 29, 2024', $sheet->getCell('A7')->getformattedValue()); + self::assertSame('8:12:15 AM', $sheet->getCell('A8')->getformattedValue()); + self::assertSame('Thursday, February 29, 2024', $sheet->getCell('A9')->getformattedValue()); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Worksheet/WorksheetTest.php b/tests/PhpSpreadsheetTests/Worksheet/WorksheetTest.php index 87e7eab61..3966c7f0f 100644 --- a/tests/PhpSpreadsheetTests/Worksheet/WorksheetTest.php +++ b/tests/PhpSpreadsheetTests/Worksheet/WorksheetTest.php @@ -252,6 +252,27 @@ class WorksheetTest extends TestCase ], 'A', ], + 'Data includes nulls' => [ + [ + ['A1', 'B1', 'C1', 'D1', 'E1'], + [null, 'B2', 'C2', 'D2', 'E2'], + ['A3', null, 'C3', 'D3', 'E3'], + ['A4', 'B4', null, 'D4', 'E4'], + ['A5', 'B5', 'C5', null, 'E5'], + ['A6', 'B6', 'C6', 'D6', null], + ], + 'B', + 2, + [ + ['A1', 'D1', 'E1'], + [null, 'D2', 'E2'], + ['A3', 'D3', 'E3'], + ['A4', 'D4', 'E4'], + ['A5', null, 'E5'], + ['A6', 'D6', null], + ], + 'C', + ], ]; } @@ -384,6 +405,25 @@ class WorksheetTest extends TestCase ], 4, ], + 'Data includes nulls' => [ + [ + ['A1', 'B1', 'C1', 'D1', 'E1'], + [null, 'B2', 'C2', 'D2', 'E2'], + ['A3', null, 'C3', 'D3', 'E3'], + ['A4', 'B4', null, 'D4', 'E4'], + ['A5', 'B5', 'C5', null, 'E5'], + ['A6', 'B6', 'C6', 'D6', null], + ], + 1, + 2, + [ + ['A3', null, 'C3', 'D3', 'E3'], + ['A4', 'B4', null, 'D4', 'E4'], + ['A5', 'B5', 'C5', null, 'E5'], + ['A6', 'B6', 'C6', 'D6', null], + ], + 4, + ], ]; } diff --git a/tests/PhpSpreadsheetTests/Writer/Dompdf/HideTest.php b/tests/PhpSpreadsheetTests/Writer/Dompdf/HideTest.php new file mode 100644 index 000000000..af4008f8a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Dompdf/HideTest.php @@ -0,0 +1,36 @@ +getActiveSheet(); + $sheet->fromArray([ + ['a1', 'b1', 'c1', 'd1', 'e1', 'f1'], + ['a2', 'b2', 'c2', 'd2', 'e2', 'f2'], + ['a3', 'b3', 'c3', 'd3', 'e3', 'f3'], + ['a4', 'b4', 'c4', 'd4', 'e4', 'f4'], + ['a5', 'b5', 'c5', 'd5', 'e5', 'f5'], + ['a6', 'b6', 'c6', 'd6', 'e6', 'f6'], + ]); + $sheet->getColumnDimension('B')->setVisible(false); + $sheet->getRowDimension(3)->setVisible(false); + $writer = new Dompdf($spreadsheet); + $html = $writer->generateHtmlAll(); + self::assertStringContainsString('table.sheet0 .column1 { display:none }', $html); + self::assertStringContainsString('table.sheet0 tr.row2 { display:none; visibility:hidden }', $html); + self::assertStringContainsString('.navigation {display: none;}', $html); + $count = substr_count($html, 'display:none'); + self::assertSame(3, $count); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Html/HideTest.php b/tests/PhpSpreadsheetTests/Writer/Html/HideTest.php new file mode 100644 index 000000000..61e274bd9 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Html/HideTest.php @@ -0,0 +1,36 @@ +getActiveSheet(); + $sheet->fromArray([ + ['a1', 'b1', 'c1', 'd1', 'e1', 'f1'], + ['a2', 'b2', 'c2', 'd2', 'e2', 'f2'], + ['a3', 'b3', 'c3', 'd3', 'e3', 'f3'], + ['a4', 'b4', 'c4', 'd4', 'e4', 'f4'], + ['a5', 'b5', 'c5', 'd5', 'e5', 'f5'], + ['a6', 'b6', 'c6', 'd6', 'e6', 'f6'], + ]); + $sheet->getColumnDimension('B')->setVisible(false); + $sheet->getRowDimension(3)->setVisible(false); + $writer = new Html($spreadsheet); + $html = $writer->generateHtmlAll(); + self::assertStringContainsString('table.sheet0 .column1 { display:none }', $html); + self::assertStringContainsString('table.sheet0 tr.row2 { display:none; visibility:hidden }', $html); + self::assertStringContainsString('.navigation {display: none;}', $html); + $count = substr_count($html, 'display:none'); + self::assertSame(3, $count); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Mpdf/HideTest.php b/tests/PhpSpreadsheetTests/Writer/Mpdf/HideTest.php new file mode 100644 index 000000000..d2b55784e --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Mpdf/HideTest.php @@ -0,0 +1,34 @@ +getActiveSheet(); + $sheet->fromArray([ + ['a1', 'b1', 'c1', 'd1', 'e1', 'f1'], + ['a2', 'b2', 'c2', 'd2', 'e2', 'f2'], + ['a3', 'b3', 'c3', 'd3', 'e3', 'f3'], + ['a4', 'b4', 'c4', 'd4', 'e4', 'f4'], + ['a5', 'b5', 'c5', 'd5', 'e5', 'f5'], + ['a6', 'b6', 'c6', 'd6', 'e6', 'f6'], + ]); + $sheet->getColumnDimension('B')->setVisible(false); + $sheet->getRowDimension(3)->setVisible(false); + $writer = new Mpdf($spreadsheet); + $html = $writer->generateHtmlAll(); + self::assertStringNotContainsString('a3', $html); + self::assertStringNotContainsString('b1', $html); + self::assertStringContainsString('a1', $html); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Tcpdf/HideTest.php b/tests/PhpSpreadsheetTests/Writer/Tcpdf/HideTest.php new file mode 100644 index 000000000..ea3244616 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Tcpdf/HideTest.php @@ -0,0 +1,34 @@ +getActiveSheet(); + $sheet->fromArray([ + ['a1', 'b1', 'c1', 'd1', 'e1', 'f1'], + ['a2', 'b2', 'c2', 'd2', 'e2', 'f2'], + ['a3', 'b3', 'c3', 'd3', 'e3', 'f3'], + ['a4', 'b4', 'c4', 'd4', 'e4', 'f4'], + ['a5', 'b5', 'c5', 'd5', 'e5', 'f5'], + ['a6', 'b6', 'c6', 'd6', 'e6', 'f6'], + ]); + $sheet->getColumnDimension('B')->setVisible(false); + $sheet->getRowDimension(3)->setVisible(false); + $writer = new Tcpdf($spreadsheet); + $html = $writer->generateHtmlAll(); + self::assertStringNotContainsString('a3', $html); + self::assertStringNotContainsString('b1', $html); + self::assertStringContainsString('a1', $html); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Calculation/TextData/TEXT.php b/tests/data/Calculation/TextData/TEXT.php index 19032d006..bf6474a28 100644 --- a/tests/data/Calculation/TextData/TEXT.php +++ b/tests/data/Calculation/TextData/TEXT.php @@ -81,4 +81,5 @@ return [ 'no arguments' => ['exception'], 'one argument' => ['exception', 1.75], 'boolean in lieu of string' => ['TRUE', true, '@'], + 'system long date format' => ['Sunday, January 1, 2012', '1-Jan-2012', '[$-x-sysdate]'], ]; diff --git a/tests/data/Shared/Date/FormatCodes.php b/tests/data/Shared/Date/FormatCodes.php index 8cd09172c..7e2c62668 100644 --- a/tests/data/Shared/Date/FormatCodes.php +++ b/tests/data/Shared/Date/FormatCodes.php @@ -160,4 +160,10 @@ return [ false, '\D-00000', ], + [true, '[$-F800]'], + [true, 'hello[$-F400]goodbye'], + [false, '[$-F401]'], + [true, '[$-x-sysdate]'], + [true, '[$-x-systime]'], + [false, '[$-x-systim]'], ];