From fb55d1b6e4a19aaa505df0b9cfa9e1f4b893e7e9 Mon Sep 17 00:00:00 2001 From: oleibman <10341515+oleibman@users.noreply.github.com> Date: Tue, 10 Feb 2026 23:48:49 -0800 Subject: [PATCH] Ods Writer Support Some Number Formats Redo of PR #4799 due to failed attempt to resolve merge conflict. Fix #4798. Partially address #3961. Ods Reader supports very little related to styling. Ods Writer supports many styling details, but has not heretofore supported Number Formatting. 3961 addresses both issues; I created 4798 specifically for the Writer side. Writing number formats to Excel is pretty simple - you just supply a string and that is used directly in the Xml. Ods is much more complicated - it requires Xml nodes that give a complete description of the styling. For that reason, it is difficult and painstaking to convert from the string that Excel (and PhpSpreadsheet) uses to what Ods requires. This PR provides code to support almost all the styles defined as constants in Style/NumberFormat. It also allows the user to add code to handle otherwise unhandled styles. New Sample55_DefinedStyles demonstrates the use of all the constant styles, plus the addition of a couple of custom styles. I may be amenable to adding some unsupported styles to the built-in list, but the custom style option will always be around in case I am being slow or unreasonable. This PR does not fully support Ods Reader handling of styles. However, based on the new Writer output, it will often be able to guess the true type of numeric items and assign an appropriate style for that type. So, for example, if it can identify the field as a date, it will assign a date style. It will not always match the style in the sheet being read, but it is a big advance from just formatting the data as a generic number. --- docs/references/features-cross-reference.md | 10 +- .../50_xlsverticalbreak.php | 0 .../{Basic4 => Basic5}/51_ProtectedSort.php | 0 samples/{Basic4 => Basic5}/52_Currency.php | 0 .../{Basic4 => Basic5}/53_ImageOpacity.php | 0 .../{Basic4 => Basic5}/54_ImagesInAndOut.php | 0 samples/Basic5/55_DefinedStyles.php | 136 ++++ samples/Basic5/56_OdsToISO8601.php | 91 +++ samples/Header.php | 22 +- samples/templates/56_MixedDateFormats.ods | Bin 0 -> 19279 bytes src/PhpSpreadsheet/Helper/Downloader.php | 5 +- src/PhpSpreadsheet/Helper/Sample.php | 15 +- src/PhpSpreadsheet/Reader/Ods.php | 159 ++-- src/PhpSpreadsheet/Spreadsheet.php | 34 +- src/PhpSpreadsheet/Style/NumberFormat.php | 11 +- .../Style/NumberFormat/DateFormatter.php | 22 +- src/PhpSpreadsheet/Writer/Ods.php | 6 + src/PhpSpreadsheet/Writer/Ods/Cell/Style.php | 713 +++++++++++++++++- src/PhpSpreadsheet/Writer/Ods/Content.php | 109 ++- .../Reader/Ods/OdsTest.php | 19 +- .../Xlsx/ReplaceBuiltinNumberFormatTest.php | 69 -- .../Style/ReplaceBuiltinNumberFormatTest.php | 101 +++ .../Writer/Ods/DurationTest.php | 50 ++ .../Writer/Ods/Issue4798Test.php | 37 + tests/data/Writer/Ods/content-with-data.xml | 17 +- 25 files changed, 1439 insertions(+), 187 deletions(-) rename samples/{Basic4 => Basic5}/50_xlsverticalbreak.php (100%) rename samples/{Basic4 => Basic5}/51_ProtectedSort.php (100%) rename samples/{Basic4 => Basic5}/52_Currency.php (100%) rename samples/{Basic4 => Basic5}/53_ImageOpacity.php (100%) rename samples/{Basic4 => Basic5}/54_ImagesInAndOut.php (100%) create mode 100644 samples/Basic5/55_DefinedStyles.php create mode 100644 samples/Basic5/56_OdsToISO8601.php create mode 100644 samples/templates/56_MixedDateFormats.ods delete mode 100644 tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php create mode 100644 tests/PhpSpreadsheetTests/Style/ReplaceBuiltinNumberFormatTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Ods/DurationTest.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Ods/Issue4798Test.php diff --git a/docs/references/features-cross-reference.md b/docs/references/features-cross-reference.md index fae240d38..a3d568ddd 100644 --- a/docs/references/features-cross-reference.md +++ b/docs/references/features-cross-reference.md @@ -28,7 +28,7 @@ ✔ ✔ ● - ● 6 + ● ✔ N/A N/A @@ -513,7 +513,7 @@ ✔ ✔ ● - ● + ✖ ✔ N/A ● @@ -524,7 +524,7 @@ ✔ ✔ ✔ - ● + ● 6 ✔ N/A ✔ @@ -1025,7 +1025,7 @@ 3. Only BIFF8 files support alignment and rotation. Prior to that, comments could only be unformatted text 4. Xlsx forms and controls can be read and written but not otherwise manipulated 5. Xlsx macros can be read and written; their values can be retrieved and changed, but only in a binary form which is unlikely to be useful -6. There is very limited support for reading styles from an Ods spreadsheet. Writing styles has better support, although Number Format is incomplete. +6. There is very limited support for reading styles from an Ods spreadsheet. Writing styles has much better support. Starting with release 5.5.0, Number Format Writer supports many common styles, and users may extend support to additional styles; Reader support is improved but still imperfect. 7. In most cases, Html reader processes only inline styles; styles provided by Css classes may be ignored. 8. Code must [opt in](../topics/recipes.md#array-formulas) to array output. 9. Use with caution - allowing external images may can subject the caller to security exploits. Starting with release 4.5.0 (also earlier releases 3.9.3, 2.3.10, 2.1.11, and 1.29.12), code can allow or not external images. In those starting releases, and in earlier releases which do not offer an option, default is to allow it. In release 5+ (and earlier supported versions 1.30+, 2.1.12+, 2.4+, and 3.10+), the default is to not allow it. @@ -1293,7 +1293,7 @@ Number Format Mask ✔ ✔ - ● + ✔6 N/A ✔ ✔ diff --git a/samples/Basic4/50_xlsverticalbreak.php b/samples/Basic5/50_xlsverticalbreak.php similarity index 100% rename from samples/Basic4/50_xlsverticalbreak.php rename to samples/Basic5/50_xlsverticalbreak.php diff --git a/samples/Basic4/51_ProtectedSort.php b/samples/Basic5/51_ProtectedSort.php similarity index 100% rename from samples/Basic4/51_ProtectedSort.php rename to samples/Basic5/51_ProtectedSort.php diff --git a/samples/Basic4/52_Currency.php b/samples/Basic5/52_Currency.php similarity index 100% rename from samples/Basic4/52_Currency.php rename to samples/Basic5/52_Currency.php diff --git a/samples/Basic4/53_ImageOpacity.php b/samples/Basic5/53_ImageOpacity.php similarity index 100% rename from samples/Basic4/53_ImageOpacity.php rename to samples/Basic5/53_ImageOpacity.php diff --git a/samples/Basic4/54_ImagesInAndOut.php b/samples/Basic5/54_ImagesInAndOut.php similarity index 100% rename from samples/Basic4/54_ImagesInAndOut.php rename to samples/Basic5/54_ImagesInAndOut.php diff --git a/samples/Basic5/55_DefinedStyles.php b/samples/Basic5/55_DefinedStyles.php new file mode 100644 index 000000000..3f70cf0de --- /dev/null +++ b/samples/Basic5/55_DefinedStyles.php @@ -0,0 +1,136 @@ +getActiveSheet(); +$array = [ + ['NUMBER', 1, NumberFormat::FORMAT_NUMBER], + ['NUMBER_0', 1, NumberFormat::FORMAT_NUMBER_0], + ['NUMBER_00', 1, NumberFormat::FORMAT_NUMBER_00], + ['NUMBER_COMMA_SEPARATED1', 1234, NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1], + ['NUMBER_COMMA_SEPARATED2', 1234, NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED2], + ['PERCENTAGE', 0.98, NumberFormat::FORMAT_PERCENTAGE], + ['PERCENTAGE_0', 0.985, NumberFormat::FORMAT_PERCENTAGE_0], + ['PERCENTAGE_00', 0.9856, NumberFormat::FORMAT_PERCENTAGE_00], + ['DATE_YYYYMMDD', 46000, NumberFormat::FORMAT_DATE_YYYYMMDD], + ['DATE_DDMMYYYY', 46000, NumberFormat::FORMAT_DATE_DDMMYYYY], + ['DATE_DMYSLASH', 46000, NumberFormat::FORMAT_DATE_DMYSLASH], + ['DATE_DMYMINUS', 46000, NumberFormat::FORMAT_DATE_DMYMINUS], + ['DATE_DMMINUS', 46000, NumberFormat::FORMAT_DATE_DMMINUS], + ['DATE_MYMINUS', 46000, NumberFormat::FORMAT_DATE_MYMINUS], + ['DATE_XLSX14', 46000, NumberFormat::FORMAT_DATE_XLSX14], + ['DATE_XLSX14_ACTUAL', 46000, NumberFormat::FORMAT_DATE_XLSX14_ACTUAL], + ['DATE_XLSX15', 46000, NumberFormat::FORMAT_DATE_XLSX15], + ['DATE_XLSX15_YYYY', 46000, NumberFormat::FORMAT_DATE_XLSX15_YYYY], + ['DATE_XLSX16', 46000, NumberFormat::FORMAT_DATE_XLSX16], + ['DATE_XLSX17', 46000, NumberFormat::FORMAT_DATE_XLSX17], + ['DATE_XLSX22', 46000, NumberFormat::FORMAT_DATE_XLSX22], + ['DATE_XLSX22_ACTUAL', 46000, NumberFormat::FORMAT_DATE_XLSX22_ACTUAL], + ['DATE_DATETIME', 46000.25, NumberFormat::FORMAT_DATE_DATETIME], + ['DATE_DATETIME_BETTER', 46000.25, NumberFormat::FORMAT_DATE_DATETIME_BETTER], + ['DATE_TIME1', 46000.25, NumberFormat::FORMAT_DATE_TIME1], + ['DATE_TIME2', 46000.25, NumberFormat::FORMAT_DATE_TIME2], + ['DATE_TIME3', 46000.25, NumberFormat::FORMAT_DATE_TIME3], + ['DATE_TIME4', 46000.25, NumberFormat::FORMAT_DATE_TIME4], + ['DATE_TIME5', 46000.25, NumberFormat::FORMAT_DATE_TIME5], + ['DATE_TIME6', 46000.25, NumberFormat::FORMAT_DATE_TIME6], + ['DATE_TIME7', 46000.25, NumberFormat::FORMAT_DATE_TIME7], + ['DATE_TIME8', 46000.25, NumberFormat::FORMAT_DATE_TIME8], + [' DATE_TIME_INTERVAL_HMS', 0.0087731481481482, NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS], + ['DATE_YYYYMMDDSLASH', 46000, NumberFormat::FORMAT_DATE_YYYYMMDDSLASH], + ['DATE_LONG_DATE', 46000, NumberFormat::FORMAT_DATE_LONG_DATE], + ['CURRENCY_USD_INTEGER', 1234.56, NumberFormat::FORMAT_CURRENCY_USD_INTEGER], + ['CURRENCY_USD', 1234.56, NumberFormat::FORMAT_CURRENCY_USD], + ['CURRENCY_EUR_INTEGER', 1234.56, NumberFormat::FORMAT_CURRENCY_EUR_INTEGER], + ['CURRENCY_EUR', 1234.56, NumberFormat::FORMAT_CURRENCY_EUR], + ['ACCOUNTING_USD', 1234.56, NumberFormat::FORMAT_CURRENCY_USD], + ['ACCOUNTING_EUR', 1234.56, NumberFormat::FORMAT_CURRENCY_EUR], + ['CUSTOM1', 1234.56, '0.000'], + ['CUSTOM2', 1234.56, '"$"#,##0.00_);[Red]\("$"#,##0.00\)'], +]; +$row = 0; +$helper->log('Populate spreadsheet'); +foreach ($array as $cells) { + ++$row; + $sheet->getCell("A$row")->setValue($cells[0]); + $sheet->getCell("B$row")->setValue($cells[1]); + if (!str_starts_with($cells[0], 'DATE')) { + $sheet->getCell("C$row")->setValue(-$cells[1]); + } + $sheet->getStyle("B$row:C$row") + ->getNumberFormat() + ->setFormatCode($cells[2]); +} +$sheet->getColumnDimension('A')->setAutoSize(true); +$sheet->getColumnDimension('B')->setAutoSize(true); +$sheet->getColumnDimension('C')->setAutoSize(true); +$sheet->setSelectedCells('A1'); + +function threeDecimalPlaces(Style $obj, string $name): void +{ + $writer = $obj->getWriter(); + $writer->startElement('number:number-style'); + $writer->writeAttribute('style:name', $name); + $writer->startElement('number:number'); + $writer->writeAttribute('number:decimal-places', '3'); + $writer->writeAttribute('number:min-decimal-places', '3'); + $writer->writeAttribute('number:min-integer-digits', '1'); + $writer->endElement(); // number:number + $writer->endElement(); // number:number-style +} + +function redBrackets(Style $obj, string $name): void +{ + $writer = $obj->getWriter(); + $writer->startElement('number:currency-style'); + $writer->writeAttribute('style:name', $name . 'P0'); + $writer->writeElement('number:currency-symbol', '$'); + $writer->startElement('number:number'); + $writer->writeAttribute('number:decimal-places', '2'); + $writer->writeAttribute('number:min-decimal-places', '2'); + $writer->writeAttribute('number:min-integer-digits', '1'); + $writer->writeAttribute('number:grouping', 'true'); + $writer->endElement(); // number:number + $writer->writeElement('number:text', ' '); + $writer->endElement(); // number:currency-style + + $writer->startElement('number:currency-style'); + $writer->writeAttribute('style:name', $name); + $writer->startElement('style:text-properties'); + $writer->writeAttribute('fo:color', '#FF0000'); + $writer->endElement(); // style:text-properties + $writer->writeElement('number:text', '('); + $writer->startElement('number:number'); + $writer->writeAttribute('number:decimal-places', '2'); + $writer->writeAttribute('number:min-decimal-places', '2'); + $writer->writeAttribute('number:min-integer-digits', '1'); + $writer->writeAttribute('number:grouping', 'true'); + $writer->endElement(); // number:number + $writer->writeElement('number:text', ')'); + $writer->startElement('style:map'); + $writer->writeAttribute('style:condition', 'value()>=0'); + $writer->writeAttribute('style:apply-style-name', $name . 'P0'); + $writer->endElement(); // style:map + $writer->endElement(); // number:currency-style +} + +function writeAdditional(BaseWriter $writer): void +{ + if (method_exists($writer, 'useAdditionalNumberFormats')) { + $array = [ + '0.000' => threeDecimalPlaces(...), + '"$"#,##0.00_);[Red]\("$"#,##0.00\)' => redBrackets(...), + ]; + $writer->useAdditionalNumberFormats($array); + } +} + +$helper->write($spreadsheet, __FILE__, ['Xlsx', 'Ods'], writerCallback: writeAdditional(...)); +$spreadsheet->disconnectWorksheets(); diff --git a/samples/Basic5/56_OdsToISO8601.php b/samples/Basic5/56_OdsToISO8601.php new file mode 100644 index 000000000..2ff43e31b --- /dev/null +++ b/samples/Basic5/56_OdsToISO8601.php @@ -0,0 +1,91 @@ +open($infile) !== true) { + throw new Exception("unable to open zip $infile"); + } + $files = ['content.xml', 'styles.xml']; + $modified = false; + foreach ($files as $file) { + $data = $zip->getFromName($file); + if ($data === false) { + throw new Exception("unable to read member $file"); + } + $newData = str_replace('', '', $data); + $newData = preg_replace( + '~' + . '()' + . '[/-]' + . '()' + . '[/-]' + . '()' + . '~', + '$3-$2' + . '-$1', + $newData + ) ?? $newData; + $newData = preg_replace( + '~' + . '()' + . '[/-]' + . '()' + . '[/-]' + . '()' + . '~', + '$3-$1' + . '-$2', + $newData + ) ?? $newData; + $newData = preg_replace( + '~' + . '(]*)' + . ' number:automatic-order="true"' + . '~', + '$1', + $newData + ) ?? $newData; + if ($data === $newData) { + $helper->log("no changes needed for $file"); + } else { + $zip->deleteName($file); + $zip->addFromString($file, $newData); + $helper->log("modified $file"); + $modified = true; + } + } + $zip->close(); + if ($modified) { + $helper->log("Modified $infile"); + } else { + $helper->log("No modifications to $infile"); + } +} + +$infileBase = '56_MixedDateFormats.ods'; +$infileBase1 = __DIR__ . '/../templates/' . $infileBase; +$infile = realpath($infileBase1); +if ($infile === false) { + throw new Exception("Unable to locate $infileBase1"); +} + +/** @var Sample $helper */ +$helper->log("Infile is $infile"); +$outDirectory = $helper->getTemporaryFolder(); +$helper->log("outDirectory is $outDirectory"); +$outfile = $outDirectory . '/56_OdsToISO8601.ods'; +$helper->log("Outfile is $outfile"); +$helper->log('Attempting copy'); +if (!copy($infile, $outfile)) { + throw new Exception('Copy failed'); +} + +$helper->log('Update date formatting xml'); +datesToIso8601($outfile, $helper); + +$helper->addDownloadLink($outfile); diff --git a/samples/Header.php b/samples/Header.php index 670776851..1516243ea 100644 --- a/samples/Header.php +++ b/samples/Header.php @@ -45,6 +45,14 @@ if ($helper->isCli()) { PHPSpreadsheet +       + +   + +       + +   + diff --git a/samples/templates/56_MixedDateFormats.ods b/samples/templates/56_MixedDateFormats.ods new file mode 100644 index 0000000000000000000000000000000000000000..8646387dfe768ec5e7dd4ebdb7facf7e287366da GIT binary patch literal 19279 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 z_VBAy>r8+format('D, d M Y H:i:s') . ' GMT'); // always modified header('Cache-Control: cache, must-revalidate'); // HTTP/1.1 header('Pragma: public'); // HTTP/1.0 } diff --git a/src/PhpSpreadsheet/Helper/Sample.php b/src/PhpSpreadsheet/Helper/Sample.php index b1283bb0b..8cdfe41c0 100644 --- a/src/PhpSpreadsheet/Helper/Sample.php +++ b/src/PhpSpreadsheet/Helper/Sample.php @@ -131,16 +131,21 @@ class Sample $callStartTime = microtime(true); $writer->save($path); $this->logWrite($writer, $path, $callStartTime); - if ($this->isCli() === false) { - // @codeCoverageIgnoreStart - echo 'Download ' . basename($path) . '
'; - // @codeCoverageIgnoreEnd - } + $this->addDownloadLink($path); } $this->logEndingNotes(); } + public function addDownloadLink(string $path): void + { + if ($this->isCli() === false) { + // @codeCoverageIgnoreStart + echo 'Download ' . basename($path) . '
'; + // @codeCoverageIgnoreEnd + } + } + protected function isDirOrMkdir(string $folder): bool { return \is_dir($folder) || \mkdir($folder); diff --git a/src/PhpSpreadsheet/Reader/Ods.php b/src/PhpSpreadsheet/Reader/Ods.php index 825b84d45..da85ecdf9 100644 --- a/src/PhpSpreadsheet/Reader/Ods.php +++ b/src/PhpSpreadsheet/Reader/Ods.php @@ -2,6 +2,9 @@ namespace PhpOffice\PhpSpreadsheet\Reader; +use Composer\Pcre\Preg; +use DateTime; +use DateTimeZone; use DOMAttr; use DOMDocument; use DOMElement; @@ -684,6 +687,7 @@ class Ods extends BaseReader } if (count($paragraphs) > 0) { + $dataValue = null; // Consolidate if there are multiple p records (maybe with spans as well) $dataArray = []; @@ -718,33 +722,49 @@ class Ods extends BaseReader break; case 'percentage': + if (!str_contains($allCellDataText, '.')) { + $formatting = NumberFormat::FORMAT_PERCENTAGE; + } elseif (substr($allCellDataText, -3, 1) === '.') { + $formatting = NumberFormat::FORMAT_PERCENTAGE_0; + } else { + $formatting = NumberFormat::FORMAT_PERCENTAGE_00; + } $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - // percentage should always be float - //if (floor($dataValue) == $dataValue) { - // $dataValue = (int) $dataValue; - //} - $formatting = NumberFormat::FORMAT_PERCENTAGE_00; - break; case 'currency': $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - if (floor($dataValue) == $dataValue) { - $dataValue = (int) $dataValue; + $currency = $cellData->getAttributeNS($officeNs, 'currency'); + if ($currency === 'USD') { + $typeValue = 'currency'; + $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER; + } elseif ($currency === 'EUR' || str_contains($allCellDataText, '€')) { + $typeValue = 'currency'; + $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_EUR : NumberFormat::FORMAT_CURRENCY_EUR_INTEGER; } - $formatting = NumberFormat::FORMAT_CURRENCY_USD_INTEGER; break; case 'float': $type = DataType::TYPE_NUMERIC; $dataValue = (float) $cellData->getAttributeNS($officeNs, 'value'); - if (floor($dataValue) == $dataValue) { - if ($dataValue == (int) $dataValue) { - $dataValue = (int) $dataValue; + if (str_starts_with($allCellDataText, '$')) { + $formatting = str_contains($allCellDataText, '.') ? NumberFormat::FORMAT_CURRENCY_USD : NumberFormat::FORMAT_CURRENCY_USD_INTEGER; + } else { + if ($dataValue !== floor($dataValue)) { + // do nothing + } elseif (substr($allCellDataText, -2, 1) === '.') { + $formatting = NumberFormat::FORMAT_NUMBER_0; + } elseif (substr($allCellDataText, -3, 1) === '.') { + $formatting = NumberFormat::FORMAT_NUMBER_00; + } + if (floor($dataValue) == $dataValue) { + if ($dataValue == (int) $dataValue) { + $dataValue = (int) $dataValue; + } } } @@ -754,7 +774,11 @@ class Ods extends BaseReader $value = $cellData->getAttributeNS($officeNs, 'date-value'); $dataValue = Date::convertIsoDate($value); - if ($dataValue != floor($dataValue)) { + if (Preg::isMatch('/^\d\d\d\d-\d\d-\d\d$/', $allCellDataText)) { + $formatting = 'yyyy-mm-dd'; + } elseif (Preg::isMatch('/^\d\d?-[a-zA-Z]+-\d\d\d\d$/', $allCellDataText)) { + $formatting = 'd-mmm-yyyy'; + } elseif ($dataValue != floor($dataValue)) { $formatting = NumberFormat::FORMAT_DATE_XLSX15 . ' ' . NumberFormat::FORMAT_DATE_TIME4; @@ -767,13 +791,26 @@ class Ods extends BaseReader $type = DataType::TYPE_NUMERIC; $timeValue = $cellData->getAttributeNS($officeNs, 'time-value'); - - $dataValue = Date::PHPToExcel( - strtotime( - '01-01-1970 ' . implode(':', sscanf($timeValue, 'PT%dH%dM%dS') ?? []) - ) - ); - $formatting = NumberFormat::FORMAT_DATE_TIME4; + $minus = ''; + if (str_starts_with($timeValue, '-')) { + $minus = '-'; + $timeValue = substr($timeValue, 1); + } + $timeArray = sscanf($timeValue, 'PT%dH%dM%dS'); + if (is_array($timeArray)) { + /** @var array{int, int, int} $timeArray */ + $days = intdiv($timeArray[0], 24); + $hours = $timeArray[0] % 24; + $dt = new DateTime("1899-12-30 $hours:{$timeArray[1]}:{$timeArray[2]}", new DateTimeZone('UTC')); + $dt->modify("+$days days"); + $dataValue = Date::PHPToExcel($dt); + if ($minus === '-') { + $dataValue *= -1; + $formatting = '[hh]:mm:ss'; + } else { + $formatting = NumberFormat::FORMAT_DATE_TIME4; + } + } break; default: @@ -790,57 +827,55 @@ class Ods extends BaseReader $cellDataFormula = FormulaTranslator::convertToExcelFormulaValue($cellDataFormula); } - if ($type !== null) { // @phpstan-ignore-line - for ($i = 0; $i < $colRepeats; ++$i) { - if ($i > 0) { - StringHelper::stringIncrement($columnID); - } + for ($i = 0; $i < $colRepeats; ++$i) { + if ($i > 0) { + StringHelper::stringIncrement($columnID); + } - if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { - continue; - } + if (!$this->getReadFilter()->readCell($columnID, $rowID, $worksheetName)) { + continue; + } - if ($type !== DataType::TYPE_NULL) { - for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { - $rID = $rowID + $rowAdjust; + if ($type !== DataType::TYPE_NULL) { + for ($rowAdjust = 0; $rowAdjust < $rowRepeats; ++$rowAdjust) { + $rID = $rowID + $rowAdjust; - $cell = $spreadsheet->getActiveSheet() - ->getCell($columnID . $rID); + $cell = $spreadsheet->getActiveSheet() + ->getCell($columnID . $rID); - // Set value - if ($hasCalculatedValue) { - $cell->setValueExplicit($cellDataFormula, $type); - if ($cellDataType === 'array') { - $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); - } - } elseif ($type !== '' || $dataValue !== null) { - $cell->setValueExplicit($dataValue, $type); + // Set value + if ($hasCalculatedValue) { + $cell->setValueExplicit($cellDataFormula, $type); + if ($cellDataType === 'array') { + $cell->setFormulaAttributes(['t' => 'array', 'ref' => $cellDataRef]); } + } elseif ($type !== '' || $dataValue !== null) { + $cell->setValueExplicit($dataValue, $type); + } - if ($hasCalculatedValue) { - $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); - } + if ($hasCalculatedValue) { + $cell->setCalculatedValue($dataValue, $type === DataType::TYPE_NUMERIC); + } - // Set other properties - if ($formatting !== null) { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode($formatting); - } else { - $spreadsheet->getActiveSheet() - ->getStyle($columnID . $rID) - ->getNumberFormat() - ->setFormatCode(NumberFormat::FORMAT_GENERAL); - } + // Set other properties + if ($formatting !== null) { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode($formatting); + } else { + $spreadsheet->getActiveSheet() + ->getStyle($columnID . $rID) + ->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_GENERAL); + } - if ($hyperlink !== null) { - if ($hyperlink[0] === '#') { - $hyperlink = 'sheet://' . substr($hyperlink, 1); - } - $cell->getHyperlink() - ->setUrl($hyperlink); + if ($hyperlink !== null) { + if ($hyperlink[0] === '#') { + $hyperlink = 'sheet://' . substr($hyperlink, 1); } + $cell->getHyperlink() + ->setUrl($hyperlink); } } } diff --git a/src/PhpSpreadsheet/Spreadsheet.php b/src/PhpSpreadsheet/Spreadsheet.php index 28be160d8..ae0a386aa 100644 --- a/src/PhpSpreadsheet/Spreadsheet.php +++ b/src/PhpSpreadsheet/Spreadsheet.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet; +use Composer\Pcre\Preg; use JsonSerializable; use PhpOffice\PhpSpreadsheet\Calculation\Calculation; use PhpOffice\PhpSpreadsheet\Cell\IValueBinder; @@ -1060,7 +1061,7 @@ class Spreadsheet implements JsonSerializable // then look for local defined name (has priority over global defined name if both names exist) if ($worksheet !== null) { $wsTitle = StringHelper::strToUpper($worksheet->getTitle()); - $definedName = (string) preg_replace('/^.*!/', '', $definedName); + $definedName = Preg::replace('/^.*!/', '', $definedName); foreach ($this->definedNames as $dn) { $sheet = $dn->getScope() ?? $dn->getWorksheet(); $upper = StringHelper::strToUpper($dn->getName()); @@ -1863,6 +1864,37 @@ class Spreadsheet implements JsonSerializable } } + /** + * Change all 2-digit-year date styles to use 4-digit year; + * change all dd-mm-yyyy and mm-dd-yyyy styles to yyyy-mm-dd; + * dd-mmm-yyyy is unambiguous and left unchanged. + */ + public function disambiguateDateStyles(): void + { + foreach ($this->cellXfCollection as $style) { + $numberFormat = $style->getNumberFormat(); + $oldFormat = (string) $numberFormat->getFormatCode(); + $newFormat = Preg::replace('/\byy\b/i', 'yyyy', $oldFormat); + $newFormat = Preg::replace( + '~\bdd?(-|/|"-"|"/")' + . 'mm?(-|/|"-"|"/")' + . 'yyyy~', + 'yyyy-mm-dd', + $newFormat + ); + $newFormat = Preg::replace( + '~\bmm?(-|/|"-"|"/")' + . 'dd?(-|/|"-"|"/")' + . 'yyyy~', + 'yyyy-mm-dd', + $newFormat + ); + if ($newFormat !== $oldFormat) { + $numberFormat->setFormatCode($newFormat); + } + } + } + public function returnArrayAsArray(): void { $this->calculationEngine->setInstanceArrayReturnType( diff --git a/src/PhpSpreadsheet/Style/NumberFormat.php b/src/PhpSpreadsheet/Style/NumberFormat.php index 07d0017ee..008990640 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat.php +++ b/src/PhpSpreadsheet/Style/NumberFormat.php @@ -23,18 +23,20 @@ class NumberFormat extends Supervisor const FORMAT_DATE_YYYYMMDD = 'yyyy-mm-dd'; const FORMAT_DATE_DDMMYYYY = 'dd/mm/yyyy'; - const FORMAT_DATE_DMYSLASH = 'd/m/yy'; + const FORMAT_DATE_DMYSLASH = 'd"/"m"/"yy'; const FORMAT_DATE_DMYMINUS = 'd-m-yy'; const FORMAT_DATE_DMMINUS = 'd-m'; const FORMAT_DATE_MYMINUS = 'm-yy'; const FORMAT_DATE_XLSX14 = 'mm-dd-yy'; const FORMAT_DATE_XLSX14_ACTUAL = 'm/d/yyyy'; const FORMAT_DATE_XLSX15 = 'd-mmm-yy'; + const FORMAT_DATE_XLSX15_YYYY = 'd-mmm-yyyy'; const FORMAT_DATE_XLSX16 = 'd-mmm'; const FORMAT_DATE_XLSX17 = 'mmm-yy'; const FORMAT_DATE_XLSX22 = 'm/d/yy h:mm'; const FORMAT_DATE_XLSX22_ACTUAL = 'm/d/yyyy h:mm'; const FORMAT_DATE_DATETIME = 'd/m/yy h:mm'; + const FORMAT_DATE_DATETIME_BETTER = 'yyyy-mm-dd hh:mm'; const FORMAT_DATE_TIME1 = 'h:mm AM/PM'; const FORMAT_DATE_TIME2 = 'h:mm:ss AM/PM'; const FORMAT_DATE_TIME3 = 'h:mm'; @@ -43,7 +45,8 @@ class NumberFormat extends Supervisor const FORMAT_DATE_TIME6 = 'h:mm:ss'; const FORMAT_DATE_TIME7 = 'i:s.S'; const FORMAT_DATE_TIME8 = 'h:mm:ss;@'; - const FORMAT_DATE_YYYYMMDDSLASH = 'yyyy/mm/dd;@'; + const FORMAT_DATE_TIME_INTERVAL_HMS = '[hh]:mm:ss'; + const FORMAT_DATE_YYYYMMDDSLASH = 'yyyy"/"mm"/"dd;@'; const FORMAT_DATE_LONG_DATE = 'dddd, mmmm d, yyyy'; const DATE_TIME_OR_DATETIME_ARRAY = [ @@ -61,6 +64,7 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_XLSX22, self::FORMAT_DATE_XLSX22_ACTUAL, self::FORMAT_DATE_DATETIME, + self::FORMAT_DATE_DATETIME_BETTER, self::FORMAT_DATE_TIME1, self::FORMAT_DATE_TIME2, self::FORMAT_DATE_TIME3, @@ -69,12 +73,14 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_TIME6, self::FORMAT_DATE_TIME7, self::FORMAT_DATE_TIME8, + self::FORMAT_DATE_TIME_INTERVAL_HMS, self::FORMAT_DATE_YYYYMMDDSLASH, self::FORMAT_DATE_LONG_DATE, ]; const TIME_OR_DATETIME_ARRAY = [ self::FORMAT_DATE_XLSX22, self::FORMAT_DATE_DATETIME, + self::FORMAT_DATE_DATETIME_BETTER, self::FORMAT_DATE_TIME1, self::FORMAT_DATE_TIME2, self::FORMAT_DATE_TIME3, @@ -83,6 +89,7 @@ class NumberFormat extends Supervisor self::FORMAT_DATE_TIME6, self::FORMAT_DATE_TIME7, self::FORMAT_DATE_TIME8, + self::FORMAT_DATE_TIME_INTERVAL_HMS, ]; const FORMAT_CURRENCY_USD_INTEGER = '$#,##0_-'; diff --git a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php index 8b87062aa..213894d72 100644 --- a/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php +++ b/src/PhpSpreadsheet/Style/NumberFormat/DateFormatter.php @@ -2,6 +2,7 @@ namespace PhpOffice\PhpSpreadsheet\Style\NumberFormat; +use Composer\Pcre\Preg; use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use Throwable; @@ -121,17 +122,18 @@ class DateFormatter /** @param float|int $value value to be formatted */ public static function format(mixed $value, string $format): string { + if ($value < 0 && Preg::isMatch('/^\[?[hms]/i', $format)) { + return '-' . self::format(-$value, $format); + } // strip off first part containing e.g. [$-F800] or [$USD-409] // general syntax: [$-] // language info is in hexadecimal // strip off chinese part like [DBNum1][$-804] - $format = (string) preg_replace('/^(\[DBNum\d\])*(\[\$[^\]]*\])/i', '', $format); + $format = Preg::replace('/^(\[DBNum\d\])*(\[\$[^\]]*\])/i', '', $format); // OpenOffice.org uses upper-case number formats, e.g. 'YYYY', convert to lower-case; // but we don't want to change any quoted strings - /** @var callable $callable */ - $callable = [self::class, 'setLowercaseCallback']; - $format = (string) preg_replace_callback('/(?:^|")([^"]*)(?:$|")/', $callable, $format); + $format = Preg::replaceCallback('/(?:^|")([^"]*)(?:$|")/', self::setLowerCaseCallback(...), $format); // Only process the non-quoted blocks for date format characters @@ -159,9 +161,7 @@ class DateFormatter $format = implode('"', $blocks); // escape any quoted characters so that DateTime format() will render them correctly - /** @var callable $callback */ - $callback = [self::class, 'escapeQuotesCallback']; - $format = (string) preg_replace_callback('/"(.*)"/U', $callback, $format); + $format = Preg::replaceCallback('/"(.*)"/U', self::escapeQuotesCallback(...), $format); try { $dateObj = Date::excelToDateTimeObject($value); @@ -171,7 +171,7 @@ class DateFormatter // If the colon preceding minute had been quoted, as happens in // Excel 2003 XML formats, m will not have been changed to i above. // Change it now. - $format = (string) \preg_replace('/\\\:m/', ':i', $format); + $format = Preg::replace('/\\\:m/', ':i', $format); $microseconds = (int) $dateObj->format('u'); if (str_contains($format, ':s.000')) { $milliseconds = (int) round($microseconds / 1000.0); @@ -207,15 +207,17 @@ class DateFormatter return $dateObj->format($format); } - /** @param string[] $matches */ + /** @param array $matches */ private static function setLowercaseCallback(array $matches): string { + /** @var string[] $matches */ return mb_strtolower($matches[0]); } - /** @param string[] $matches */ + /** @param array $matches */ private static function escapeQuotesCallback(array $matches): string { + /** @var string[] $matches */ return '\\' . implode('\\', mb_str_split($matches[1], 1, 'UTF-8')); } } diff --git a/src/PhpSpreadsheet/Writer/Ods.php b/src/PhpSpreadsheet/Writer/Ods.php index 638c16f1e..13f7c0634 100644 --- a/src/PhpSpreadsheet/Writer/Ods.php +++ b/src/PhpSpreadsheet/Writer/Ods.php @@ -86,6 +86,12 @@ class Ods extends BaseWriter return $this->writerPartThumbnails; } + /** @param array $additionalNumberFormats */ + public function useAdditionalNumberFormats(array $additionalNumberFormats): void + { + $this->writerPartContent->additionalNumberFormats = $additionalNumberFormats; + } + /** * Save PhpSpreadsheet to file. * diff --git a/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php b/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php index 5c3cf5277..0f1fb6faf 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php +++ b/src/PhpSpreadsheet/Writer/Ods/Cell/Style.php @@ -9,6 +9,7 @@ use PhpOffice\PhpSpreadsheet\Style\Border; use PhpOffice\PhpSpreadsheet\Style\Borders; use PhpOffice\PhpSpreadsheet\Style\Fill; use PhpOffice\PhpSpreadsheet\Style\Font; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Style\Style as CellStyle; use PhpOffice\PhpSpreadsheet\Worksheet\ColumnDimension; use PhpOffice\PhpSpreadsheet\Worksheet\RowDimension; @@ -24,9 +25,19 @@ class Style private XMLWriter $writer; - public function __construct(XMLWriter $writer) + /** @var array */ + private array $additionalNumberFormats; + + /** @param array $additionalNumberFormats */ + public function __construct(XMLWriter $writer, array $additionalNumberFormats = []) { $this->writer = $writer; + $this->additionalNumberFormats = $additionalNumberFormats; + } + + public function getWriter(): XMLWriter + { + return $this->writer; } private function mapHorizontalAlignment(?string $horizontalAlignment): string @@ -344,12 +355,40 @@ class Style $this->writer->endElement(); // Close style:style } + private int $numFmtIndex = 199; + + /** @var array */ + private array $numFmtIndexes = []; + + private function writeNumFmt(string $numFmt): void + { + if (array_key_exists($numFmt, $this->numFmtIndexes)) { + return; + } + $method = self::NUMBER_FORMAT_METHODS[$numFmt] ?? $this->additionalNumberFormats[$numFmt] ?? null; + if ($method === null) { + return; + } + ++$this->numFmtIndex; + $name = 'N' . $this->numFmtIndex; + $this->numFmtIndexes[$numFmt] = $name; + $method($this, $name); + } + public function write(CellStyle $style): void { + $numFmt = (string) $style->getNumberFormat()->getFormatCode(); + $this->writeNumFmt($numFmt); $this->writer->startElement('style:style'); $this->writer->writeAttribute('style:name', self::CELL_STYLE_PREFIX . $style->getIndex()); $this->writer->writeAttribute('style:family', 'table-cell'); $this->writer->writeAttribute('style:parent-style-name', 'Default'); + if (array_key_exists($numFmt, $this->numFmtIndexes)) { + $this->writer->writeAttribute( + 'style:data-style-name', + $this->numFmtIndexes[$numFmt] + ); + } // Alignment, fill colour, etc $this->writeCellProperties($style); @@ -360,4 +399,676 @@ class Style // End $this->writer->endElement(); // Close style:style } + + private const NUMBER_FORMAT_METHODS = [ + NumberFormat::FORMAT_NUMBER => [self::class, 'formatNumber'], + NumberFormat::FORMAT_NUMBER_0 => [self::class, 'formatNumber0'], + NumberFormat::FORMAT_NUMBER_00 => [self::class, 'formatNumber00'], + NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED1 => [self::class, 'formatNumberCommaSeparated1'], + NumberFormat::FORMAT_NUMBER_COMMA_SEPARATED2 => [self::class, 'formatNumberCommaSeparated2'], + NumberFormat::FORMAT_PERCENTAGE => [self::class, 'formatPercentage'], + NumberFormat::FORMAT_PERCENTAGE_0 => [self::class, 'formatPercentage0'], + NumberFormat::FORMAT_PERCENTAGE_00 => [self::class, 'formatPercentage00'], + NumberFormat::FORMAT_DATE_YYYYMMDD => [self::class, 'formatDateYyyymmdd'], + NumberFormat::FORMAT_DATE_DDMMYYYY => [self::class, 'formatDateDdmmyyyy'], + NumberFormat::FORMAT_DATE_DMYSLASH => [self::class, 'formatDateDmyslash'], + NumberFormat::FORMAT_DATE_DMYMINUS => [self::class, 'formatDateDmyminus'], + NumberFormat::FORMAT_DATE_DMMINUS => [self::class, 'formatDateDmminus'], + NumberFormat::FORMAT_DATE_MYMINUS => [self::class, 'formatDateMyminus'], + NumberFormat::FORMAT_DATE_XLSX14 => [self::class, 'formatDateXlsx14'], + NumberFormat::FORMAT_DATE_XLSX14_ACTUAL => [self::class, 'formatDateXlsx14Actual'], + NumberFormat::FORMAT_DATE_XLSX15 => [self::class, 'formatDateXlsx15'], + NumberFormat::FORMAT_DATE_XLSX15_YYYY => [self::class, 'formatDateXlsx15Yyyy'], + NumberFormat::FORMAT_DATE_XLSX16 => [self::class, 'formatDateXlsx16'], + NumberFormat::FORMAT_DATE_XLSX17 => [self::class, 'formatDateXlsx17'], + NumberFormat::FORMAT_DATE_XLSX22 => [self::class, 'formatDateXlsx22'], + NumberFormat::FORMAT_DATE_XLSX22_ACTUAL => [self::class, 'formatDateXlsx22Actual'], + NumberFormat::FORMAT_DATE_DATETIME => [self::class, 'formatDateDatetime'], + NumberFormat::FORMAT_DATE_DATETIME_BETTER => [self::class, 'formatDateDatetimeBetter'], + NumberFormat::FORMAT_DATE_TIME1 => [self::class, 'formatDateTime1'], + NumberFormat::FORMAT_DATE_TIME2 => [self::class, 'formatDateTime2'], + NumberFormat::FORMAT_DATE_TIME3 => [self::class, 'formatDateTime3'], + NumberFormat::FORMAT_DATE_TIME4 => [self::class, 'formatDateTime4'], + NumberFormat::FORMAT_DATE_TIME5 => [self::class, 'formatDateTime5'], + //NumberFormat::FORMAT_DATE_TIME6 => [self::class, 'formatDateTime6'], // FORMAT_DATE_TIME6 is identical to TIME4 + NumberFormat::FORMAT_DATE_TIME7 => [self::class, 'formatDateTime7'], // constant is probably mis-coded + NumberFormat::FORMAT_DATE_TIME8 => [self::class, 'formatDateTime8'], + NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS => [self::class, 'formatDateTimeIntervalHms'], + NumberFormat::FORMAT_DATE_YYYYMMDDSLASH => [self::class, 'formatDateYyyymmddslash'], + NumberFormat::FORMAT_DATE_LONG_DATE => [self::class, 'formatDateLongDate'], + NumberFormat::FORMAT_CURRENCY_USD_INTEGER => [self::class, 'formatCurrencyUsdInteger'], + NumberFormat::FORMAT_CURRENCY_USD => [self::class, 'formatCurrencyUsd'], + NumberFormat::FORMAT_ACCOUNTING_USD => [self::class, 'formatCurrencyUsd'], // ACCOUNTING and CURRENCY are same in Ods + NumberFormat::FORMAT_CURRENCY_EUR_INTEGER => [self::class, 'formatCurrencyEurInteger'], + NumberFormat::FORMAT_CURRENCY_EUR => [self::class, 'formatCurrencyEur'], + NumberFormat::FORMAT_ACCOUNTING_EUR => [self::class, 'formatCurrencyEur'], // ACCOUNTING and CURRENCY are same in Ods + ]; + + protected static function formatNumber(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumber0(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '1'); + $obj->writer->writeAttribute('number:min-decimal-places', '1'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumber00(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumberCommaSeparated1(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatNumberCommaSeparated2(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + //$obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->endElement(); // number:number-style + } + + protected static function formatPercentage(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '0'); + $obj->writer->writeAttribute('number:min-decimal-places', '0'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatPercentage0(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '1'); + $obj->writer->writeAttribute('number:min-decimal-places', '1'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatPercentage00(self $obj, string $name): void + { + $obj->writer->startElement('number:percentage-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $obj->writer->writeAttribute('number:decimal-places', '2'); + $obj->writer->writeAttribute('number:min-decimal-places', '2'); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', '%'); + $obj->writer->endElement(); // number:percentage-style + } + + protected static function formatDateYyyymmdd(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDdmmyyyy(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmyslash(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmyminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDmminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:month'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateMyminus(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx14(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx14Actual(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx15(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx15Yyyy(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx16(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx17(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->writeElement('number:year'); + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx22(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateXlsx22Actual(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDatetime(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->writeElement('number:month'); + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:year'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateDatetimeBetter(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '-'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->writeElement('number:text', ' '); + $obj->writer->startElement('number:hours'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:hours + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:date-style + } + + protected static function formatDateTime1(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:am-pm'); + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime2(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:am-pm'); + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime3(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime4(self $obj, string $name): void + { + // TIME4 and TIME6 are identical + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime5(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime7(self $obj, string $name): void + { + // constant is probably mis-coded + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateTime8(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name . 'P0'); + $obj->writer->writeElement('number:hours'); + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + $obj->writer->startElement('number:text-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:text-content'); + $obj->writer->startElement('style:map'); + $obj->writer->writeAttribute('style:condition', 'value()>=0'); + $obj->writer->writeAttribute('style:apply-style-name', $name . 'P0'); + $obj->writer->endElement(); // number:style-map + $obj->writer->endElement(); // number:text-style + } + + protected static function formatDateTimeIntervalHms(self $obj, string $name): void + { + $obj->writer->startElement('number:time-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeAttribute( + 'number:truncate-on-overflow', + 'false' + ); + $obj->writer->startElement('number:hours'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:hours + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:minutes'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:minutes + $obj->writer->writeElement('number:text', ':'); + $obj->writer->startElement('number:seconds'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:seconds + $obj->writer->endElement(); // number:time-style + } + + protected static function formatDateYyyymmddslash(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name . 'P0'); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', '/'); + $obj->writer->startElement('number:day'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day + $obj->writer->endElement(); // number:date-style + $obj->writer->startElement('number:text-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->writeElement('number:text-content'); + $obj->writer->startElement('style:map'); + $obj->writer->writeAttribute('style:condition', 'value()>=0'); + $obj->writer->writeAttribute('style:apply-style-name', $name . 'P0'); + $obj->writer->endElement(); // number:style-map + $obj->writer->endElement(); // number:text-style + } + + protected static function formatDateLongDate(self $obj, string $name): void + { + $obj->writer->startElement('number:date-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:day-of-week'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:day-of-week + $obj->writer->writeElement('number:text', ', '); + $obj->writer->startElement('number:month'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->writeAttribute('number:textual', 'true'); + $obj->writer->endElement(); // number:month + $obj->writer->writeElement('number:text', ' '); + $obj->writer->writeElement('number:day'); + $obj->writer->writeElement('number:text', ', '); + $obj->writer->startElement('number:year'); + $obj->writer->writeAttribute('number:style', 'long'); + $obj->writer->endElement(); // number:year + $obj->writer->endElement(); // number:date-style + } + + protected static function formatCurrencyUsdInteger(self $obj, string $name): void + { + $obj->writer->startElement('number:number-style'); // not currency-style + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->WriteElement('number:text', '$'); + $obj->writer->startElement('number:number'); + $decimals = '0'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->startElement('number:embedded-text'); + $obj->writer->writeAttribute('number-position', '0'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:embedded-text + $obj->writer->endElement(); // number:number + $obj->writer->endElement(); // number:number-style + } + + protected static function formatCurrencyUsd(self $obj, string $name): void + { + // Ods uses same format for Currency and Accounting + $obj->writer->startElement('number:number-style'); // NOT currency-style + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->WriteElement('number:text', '$'); + $obj->writer->startElement('number:number'); + $decimals = '2'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->writeElement('number:text', ' '); + $obj->writer->endElement(); // number:currency-style + } + + protected static function formatCurrencyEurInteger(self $obj, string $name): void + { + $obj->writer->startElement('number:currency-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $decimals = '0'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->startElement('number:embedded-text'); + $obj->writer->writeAttribute('number:position', '0'); + $obj->writer->endElement(); // number:embedded-text + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + // $obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->startElement('number:currency-symbol'); + $obj->writer->writeAttribute('number:language', 'en'); + $obj->writer->writeAttribute('number:country', 'us'); + $obj->writer->text('€'); + $obj->writer->endElement(); // number:currency-symbol + + $obj->writer->endElement(); // number:currency-style + } + + protected static function formatCurrencyEur(self $obj, string $name): void + { + // Ods uses same format for Currency and Accounting + $obj->writer->startElement('number:currency-style'); + $obj->writer->writeAttribute('style:name', $name); + $obj->writer->startElement('number:number'); + $decimals = '2'; + $obj->writer->writeAttribute('number:decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-decimal-places', $decimals); + $obj->writer->writeAttribute('number:min-integer-digits', '1'); + $obj->writer->writeAttribute('number:grouping', 'true'); + $obj->writer->endElement(); // number:number + $obj->writer->startElement('number:text'); + // $obj->writer->writeAttribute('loext:blank-width-char', '-'); + $obj->writer->text(' '); + $obj->writer->endElement(); // number:text + $obj->writer->startElement('number:currency-symbol'); + $obj->writer->writeAttribute('number:language', 'en'); + $obj->writer->writeAttribute('number:country', 'us'); + $obj->writer->text('€'); + $obj->writer->endElement(); // number:currency-symbol + + $obj->writer->endElement(); // number:currency-style + } } diff --git a/src/PhpSpreadsheet/Writer/Ods/Content.php b/src/PhpSpreadsheet/Writer/Ods/Content.php index 5aea6c2a4..6364cb440 100644 --- a/src/PhpSpreadsheet/Writer/Ods/Content.php +++ b/src/PhpSpreadsheet/Writer/Ods/Content.php @@ -8,8 +8,10 @@ use PhpOffice\PhpSpreadsheet\Calculation\Exception as CalculationException; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; +use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\XMLWriter; use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Worksheet\RowCellIterator; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use PhpOffice\PhpSpreadsheet\Writer\Ods; @@ -247,19 +249,99 @@ class Content extends WriterPart } } $objWriter->writeAttribute('table:formula', $this->formulaConvertor->convertFormula($cell->getValueString())); - if (is_numeric($formulaValue)) { - $objWriter->writeAttribute('office:value-type', 'float'); - } else { - $objWriter->writeAttribute('office:value-type', 'string'); - } - $objWriter->writeAttribute('office:value', $formulaValue); - $objWriter->writeElement('text:p', $formulaValue); + if (!is_numeric($formulaValue)) { + $objWriter->writeAttribute( + 'office:value-type', + 'string' + ); + $objWriter->writeAttribute('office:value', $formulaValue); + $objWriter->writeElement('text:p', $formulaValue); - break; + break; + } + // no break case DataType::TYPE_NUMERIC: - $objWriter->writeAttribute('office:value-type', 'float'); - $objWriter->writeAttribute('office:value', $cell->getValueString()); - $objWriter->writeElement('text:p', $cell->getValueString()); + $holdWorksheet = $cell->getWorksheet(); + $holdSelected = $holdWorksheet->getSelectedCells(); + $holdSpreadsheet = $holdWorksheet->getParent(); + $holdActiveSheetIndex = $holdSpreadsheet?->getActiveSheetIndex(); + $formatted = $cell->getFormattedValue(); + $type = 'float'; + $valueType = 'value'; + $value = $cell->getCalculatedValueString(); + $numFmt = $cell->getStyle() + ->getNumberFormat() + ->getFormatCode() ?? ''; + $holdWorksheet->setSelectedCells($holdSelected); + if (isset($holdSpreadsheet, $holdActiveSheetIndex)) { + $holdSpreadsheet->setActiveSheetIndex( + $holdActiveSheetIndex + ); + } + if (Date::isDateTimeFormatCode($numFmt, true)) { + $valueCalc = $cell->getCalculatedValueString(); + if (Preg::isMatch('/[HhSs]/', $numFmt) && !Preg::isMatch('/[YyDd]/', $numFmt)) { + $minus = ''; + $type = 'time'; + $valueType = 'time-value'; + if (str_starts_with($valueCalc, '-')) { + $minus = '-'; + $absVal = fmod(abs((float) $value), 1.0); + $hms = (int) round(86400 * $absVal); + $hours = intdiv($hms, 3600); + $hms -= $hours * 3600; + $minutes = intdiv($hms, 60); + $seconds = $hms % 60; + $value = sprintf('-PT%02dH%02dM%02dS', $hours, $minutes, $seconds); + $formatted = sprintf('-%02d:%02d:%02d', $hours, $minutes, $seconds); + } else { + $hhmmss = NumberFormat::toFormattedString( + $value, + NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS + ); + $daysAndHours = 24 * (int) $valueCalc + (int) substr($hhmmss, 0, 2); + $value = "PT$daysAndHours" + . 'H' + . substr($hhmmss, 3, 2) + . 'M' + . substr($hhmmss, 6, 2) + . 'S'; + } + } else { + $type = 'date'; + $valueType = 'date-value'; + $value = NumberFormat::toFormattedString( + $value, + 'yyyy-mm-dd"T"hh:mm:ss' + ); + } + } elseif (str_ends_with($numFmt, '%')) { + $type = 'percentage'; + } + if ($numFmt === NumberFormat::FORMAT_CURRENCY_EUR || $numFmt === NumberFormat::FORMAT_CURRENCY_EUR_INTEGER) { + $objWriter->writeAttribute( + 'office:value-type', + 'currency' + ); + $objWriter->writeAttribute( + 'office:currency', + 'EUR' + ); + $objWriter->writeAttribute( + 'office:value', + $value + ); + } else { + $objWriter->writeAttribute( + 'office:value-type', + $type + ); + $objWriter->writeAttribute( + "office:$valueType", + $value + ); + } + $objWriter->writeElement('text:p', $formatted); break; case DataType::TYPE_INLINE: @@ -307,12 +389,15 @@ class Content extends WriterPart } } + /** @var array */ + public array $additionalNumberFormats = []; + /** * Write XF cell styles. */ private function writeXfStyles(XMLWriter $writer, Spreadsheet $spreadsheet): void { - $styleWriter = new Style($writer); + $styleWriter = new Style($writer, $this->additionalNumberFormats); $sheetCount = $spreadsheet->getSheetCount(); for ($i = 0; $i < $sheetCount; ++$i) { diff --git a/tests/PhpSpreadsheetTests/Reader/Ods/OdsTest.php b/tests/PhpSpreadsheetTests/Reader/Ods/OdsTest.php index 01b6e01a0..b64848c3e 100644 --- a/tests/PhpSpreadsheetTests/Reader/Ods/OdsTest.php +++ b/tests/PhpSpreadsheetTests/Reader/Ods/OdsTest.php @@ -175,15 +175,30 @@ class OdsTest extends TestCase self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A1')->getDataType()); // Percentage (10%) self::assertEquals(0.1, $firstSheet->getCell('A1')->getValue()); + self::assertSame('10%', $firstSheet->getCell('A1')->getFormattedValue()); + self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A2')->getDataType()); // Percentage (10%) + self::assertEquals(0.1, $firstSheet->getCell('A2')->getValue()); + self::assertSame('10.00%', $firstSheet->getCell('A2')->getFormattedValue()); self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A2')->getDataType()); // Percentage (10.00%) self::assertEquals(0.1, $firstSheet->getCell('A2')->getValue()); self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A4')->getDataType()); // Currency (€10.00) - self::assertEquals(10, $firstSheet->getCell('A4')->getValue()); + self::assertSame(10.00, $firstSheet->getCell('A4')->getValue()); + self::assertSame('10.00 €', $firstSheet->getCell('A4')->getFormattedValue()); + self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('B4')->getDataType()); // Currency ($10) + self::assertSame(10.00, $firstSheet->getCell('B4')->getValue()); + self::assertSame('$10 ', $firstSheet->getCell('B4')->getFormattedValue()); + self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('C4')->getDataType()); // Currency (€10.12) + self::assertEquals(10.12, $firstSheet->getCell('C4')->getValue()); + self::assertSame('10.12 €', $firstSheet->getCell('C4')->getFormattedValue()); self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('A5')->getDataType()); // Currency ($20) - self::assertEquals(20, $firstSheet->getCell('A5')->getValue()); + self::assertSame(20.00, $firstSheet->getCell('A5')->getValue()); + self::assertSame('$20 ', $firstSheet->getCell('A5')->getFormattedValue()); + self::assertEquals(DataType::TYPE_NUMERIC, $firstSheet->getCell('C5')->getDataType()); // Currency ($20.34) + self::assertEquals(20.34, $firstSheet->getCell('C5')->getValue()); + self::assertSame('$20.34 ', $firstSheet->getCell('C5')->getFormattedValue()); $spreadsheet->disconnectWorksheets(); } diff --git a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php b/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php deleted file mode 100644 index 59b5cf3c3..000000000 --- a/tests/PhpSpreadsheetTests/Reader/Xlsx/ReplaceBuiltinNumberFormatTest.php +++ /dev/null @@ -1,69 +0,0 @@ -spreadsheet !== null) { - $this->spreadsheet->disconnectWorksheets(); - $this->spreadsheet = null; - } - if ($this->reloadedSpreadsheet !== null) { - $this->reloadedSpreadsheet->disconnectWorksheets(); - $this->reloadedSpreadsheet = null; - } - } - - public function testReplaceBuiltinNumberFormat(): void - { - $spreadsheet = $this->spreadsheet = new Spreadsheet(); - $sheet = $this->spreadsheet->getActiveSheet(); - $sheet->fromArray([45486, 1023, 45487, 45488, 45489]); - $sheet->getStyle('A1')->getNumberFormat() - ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); - $sheet->getStyle('B1')->getNumberFormat() - ->setFormatCode('#,##0.00'); - $sheet->getStyle('C1')->getNumberFormat() - ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); - $sheet->getStyle('D1')->getNumberFormat() - ->setFormatCode('dd-MMM-yyyy'); - $sheet->getStyle('E1')->getNumberFormat() - ->setBuiltInFormatCode(16); - $values = $sheet->toArray(); - $expected = [[ - '7/13/2024', // builtin style 14 - '1,023.00', // #,##0.00 - '7/14/2024', // builtin style 14 - '15-Jul-2024', // dd-MMM-yyyy - '16-Jul', // builtin style 16 - ]]; - self::assertSame($expected, $values); - $this->reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Xlsx'); - $this->reloadedSpreadsheet->replaceBuiltinNumberFormat( - NumberFormat::SHORT_DATE_INDEX, - 'yyyy-mm-dd' - ); - $rsheet = $this->reloadedSpreadsheet->getActiveSheet(); - $newValues = $rsheet->toArray(); - $newExpected = [[ - '2024-07-13', // yyyy-mm-dd changed from builtin style 14 - '1,023.00', // unchanged #,##0.00 - '2024-07-14', // yyyy-mm-dd changed from builtin style 14 - '15-Jul-2024', // unchanged dd-MMM-yyyy - '16-Jul', // unchanged builtin style 16 - ]]; - self::assertSame($newExpected, $newValues); - } -} diff --git a/tests/PhpSpreadsheetTests/Style/ReplaceBuiltinNumberFormatTest.php b/tests/PhpSpreadsheetTests/Style/ReplaceBuiltinNumberFormatTest.php new file mode 100644 index 000000000..3c8c4a31d --- /dev/null +++ b/tests/PhpSpreadsheetTests/Style/ReplaceBuiltinNumberFormatTest.php @@ -0,0 +1,101 @@ +spreadsheet !== null) { + $this->spreadsheet->disconnectWorksheets(); + $this->spreadsheet = null; + } + } + + public function testReplaceBuiltinNumberFormat(): void + { + $spreadsheet = $this->spreadsheet = new Spreadsheet(); + $sheet = $this->spreadsheet->getActiveSheet(); + $sheet->fromArray([45486, 1023, 45487, 45488, 45489, 45480, 45479, 45478, 45477, 45476, 45475]); + $sheet->getStyle('A1')->getNumberFormat() + ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); + $sheet->getStyle('B1')->getNumberFormat() + ->setFormatCode('#,##0.00'); + $sheet->getStyle('C1')->getNumberFormat() + ->setBuiltInFormatCode(NumberFormat::SHORT_DATE_INDEX); + $sheet->getStyle('D1')->getNumberFormat() + ->setFormatCode('dd-MMM-yyyy'); + $sheet->getStyle('E1')->getNumberFormat() + ->setBuiltInFormatCode(16); + $sheet->getStyle('F1')->getNumberFormat() + ->setFormatCode('d-MMM-yy'); + $sheet->getStyle('G1')->getNumberFormat() + ->setFormatCode('dd/m/yy'); + $sheet->getStyle('H1')->getNumberFormat() + ->setFormatCode('mm-dd-yy'); + $sheet->getStyle('I1')->getNumberFormat() + ->setFormatCode('yy-mm-dd'); + $sheet->getStyle('J1')->getNumberFormat() + ->setFormatCode('m/d/yyyy h:mm'); + $sheet->getStyle('K1')->getNumberFormat() + ->setFormatCode('d"/"m"/"yy'); + $values = $sheet->toArray(); + $expected = [[ + '7/13/2024', // builtin style 14 + '1,023.00', // #,##0.00 + '7/14/2024', // builtin style 14 + '15-Jul-2024', // dd-MMM-yyyy + '16-Jul', // builtin style 16 + '7-Jul-24', // d-MMM-yy + '06/7/24', // dd/m/yy + '07-05-24', // mm-dd-yy + '24-07-04', // yy-mm-dd + '7/3/2024 0:00', // m/d/yyyy h:mm + '2/7/24', // d"/"m"/"yy + ]]; + self::assertSame($expected, $values); + $spreadsheet->replaceBuiltinNumberFormat( + NumberFormat::SHORT_DATE_INDEX, + 'yyyy-mm-dd' + ); + $newValues = $sheet->toArray(); + $newExpected = [[ + '2024-07-13', // yyyy-mm-dd changed from builtin style 14 + '1,023.00', // unchanged #,##0.00 + '2024-07-14', // yyyy-mm-dd changed from builtin style 14 + '15-Jul-2024', // unchanged dd-MMM-yyyy + '16-Jul', // unchanged builtin style 16 + '7-Jul-24', // unchanged d-MMM-yy + '06/7/24', // unchanged dd/m/yy + '07-05-24', // unchanged mm-dd-yy + '24-07-04', // unchanged yy-mm-dd + '7/3/2024 0:00', // unchanged m/d/yyyy h:mm + '2/7/24', // unchanged d"/"m"/"yy + ]]; + self::assertSame($newExpected, $newValues); + $spreadsheet->disambiguateDateStyles(); + $newValues2 = $sheet->toArray(); + $newExpected2 = [[ + '2024-07-13', // unchanged yyyy-mm-dd + '1,023.00', // unchanged #,##0.00 + '2024-07-14', // unchanged yyyy-mm-dd + '15-Jul-2024', // unchanged dd-MMM-yyyy + '16-Jul', // unchanged builtin style 16 + '7-Jul-2024', // changed from d-MMM-yy to d-MMM-yyyy + '2024-07-06', // changed from dd/m/yy to yyyy-mm-dd + '2024-07-05', // changed from mm-dd-yy tp yyyy-mm-dd + '2024-07-04', // changed from yy-mm-dd to yyyy-mm-dd + '2024-07-03 0:00', // changed m/d/yyyy h:mm to yyyy-mm-dd h:mm + '2024-07-02', // changed d"/"m"/"yy to yyyy-mm-dd + ]]; + self::assertSame($newExpected2, $newValues2); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/DurationTest.php b/tests/PhpSpreadsheetTests/Writer/Ods/DurationTest.php new file mode 100644 index 000000000..a9d1cc67a --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Ods/DurationTest.php @@ -0,0 +1,50 @@ +getActiveSheet(); + $sheet->getCell('E1')->setValue('=TIMEVALUE("8:15:01")'); + $sheet->getCell('E2')->setValue('=TIMEVALUE("9:17:35")'); + $sheet->getCell('E3')->setValue('=TIMEVALUE("8:02:23")'); + $sheet->getCell('F2')->setValue('=E2-E1'); + $sheet->getCell('F3')->setValue('=E3-E1'); + $sheet->getStyle('E1:F3')->getNumberFormat() + ->setFormatCode( + NumberFormat::FORMAT_DATE_TIME_INTERVAL_HMS + ); + $sheet->getCell('A1')->setValue(0.5); + $sheet->getStyle('A1')->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_PERCENTAGE_0); + $sheet->getCell('A2')->setValue(20); + $sheet->getStyle('A2')->getNumberFormat() + ->setFormatCode('$0.00'); + $sheet->getCell('A3')->setValue(20); + $sheet->getStyle('A3')->getNumberFormat() + ->setFormatCode('#.0'); + $sheet->getCell('A4')->setValue(46000); + $sheet->getStyle('A4')->getNumberFormat() + ->setFormatCode('yyyy-mm-dd'); + + $reloadedSpreadsheet = $this->writeAndReload($spreadsheet, 'Ods'); + $spreadsheet->disconnectWorksheets(); + $rsheet = $reloadedSpreadsheet->getActiveSheet(); + self::assertSame('1:02:34', $rsheet->getCell('F2')->getFormattedValue()); + self::assertSame('-00:12:38', $rsheet->getCell('F3')->getFormattedValue()); + self::assertSame('50.0%', $rsheet->getCell('A1')->getFormattedValue()); + self::assertSame('$20.00 ', $rsheet->getCell('A2')->getFormattedValue()); + self::assertSame('20.0', $rsheet->getCell('A3')->getFormattedValue()); + self::assertSame('2025-12-09', $rsheet->getCell('A4')->getFormattedValue()); + $reloadedSpreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Ods/Issue4798Test.php b/tests/PhpSpreadsheetTests/Writer/Ods/Issue4798Test.php new file mode 100644 index 000000000..4871a266c --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Ods/Issue4798Test.php @@ -0,0 +1,37 @@ +getActiveSheet(); + $sheet->setCellValue('A1', 1); + $sheet->setCellValue('A2', 1.2); + $sheet->setCellValue('A3', -1.234); + $sheet->setCellValue('A4', '=1+2'); + $sheet->getStyle('A1:A4')->getNumberFormat() + ->setFormatCode(NumberFormat::FORMAT_NUMBER_00); + $sheet->getStyle('A1')->getFont()->setItalic(true); + $sheet->getStyle('A2')->getFont()->setBold(true); + $writer = new OdsWriter($spreadsheet); + $writerWorksheet = new OdsWriter\Content($writer); + $data = $writerWorksheet->write(); + $count = substr_count($data, 'style:data-style-name="N200"'); + self::assertSame(3, $count, 'once for the italic cell, once for the bold, and once for the other two'); + self::assertStringContainsString( + '', + $data + ); + $spreadsheet->disconnectWorksheets(); + } +} diff --git a/tests/data/Writer/Ods/content-with-data.xml b/tests/data/Writer/Ods/content-with-data.xml index bde23e29d..5654cd762 100644 --- a/tests/data/Writer/Ods/content-with-data.xml +++ b/tests/data/Writer/Ods/content-with-data.xml @@ -13,7 +13,18 @@ - + + + / + + / + + + + : + + + @@ -92,8 +103,8 @@ 1 1 - - 42798.572060185 + + 4/3/17 13:43