diff --git a/CHANGELOG.md b/CHANGELOG.md index 163e79a5d9..e56172b95e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ Some earlier branches remain supported and security fixes are applied to them; i - Support for Excel sparklines (line, column, and win/loss) in Xlsx reader and writer. [Issue #4941](https://github.com/PHPOffice/PhpSpreadsheet/issues/4941) - Read-only object model for Pivot Tables. Existing pivot tables in an Xlsx file are now parsed into `Worksheet\PivotTable\PivotTable` objects (name, location, source cache definition, and row/column/page/data field layout), accessible via `Worksheet::getPivotTableCollection()` / `getPivotTableByName()`. Pivot tables (their tables, caches and records) are now also preserved through an Xlsx load/save round-trip instead of being silently dropped. [Issue #4534](https://github.com/PHPOffice/PhpSpreadsheet/issues/4534) +- Streaming Xlsx writer for large, append-only exports with memory use independent of row count. [PR #4966](https://github.com/PHPOffice/PhpSpreadsheet/pull/4966) ### Removed diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md new file mode 100644 index 0000000000..3040a3bedc --- /dev/null +++ b/docs/topics/streaming-writer.md @@ -0,0 +1,236 @@ +# Streaming Xlsx writer + +`PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter` writes Xlsx +files directly to a file handle, one row at a time. It does not build a +`Spreadsheet` object in memory. Use it for large exports where the standard +writer would use too much memory. + +## When to use it + +Use the streaming writer for large, append-only exports, for example a +report with hundreds of thousands of rows. Peak memory does not depend on +the number of rows written. + +Measurements on this feature: writing 100,000 rows and writing 200,000 rows +both peak at about 34MB of total memory. Most of that 34MB is a fixed 16MB +read block used internally by the Zip stream, not row data. The standard +`Xlsx` writer holds every cell in memory, at roughly 1KB or more per cell, +so its memory use grows with the row count. + +Do not use the streaming writer when you need to read the file back, edit +existing cells, or use features it does not support (see below). Use the +standard `Xlsx` writer, with [cell caching](memory_saving.md) if needed, +for those cases. + +## Basic usage + +```php +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; + +$writer = new StreamingWriter('large-export.xlsx'); +$sheet = $writer->startSheet('Data'); +$sheet->appendRow(['Name', 'Amount']); +$sheet->appendRow(['Widget', 12.5]); +$writer->close(); +``` + +Rows are plain arrays of values, written left to right starting at column A. +A `null` value leaves that cell empty. Once `close()` has run, the writer +and every sheet it produced are no longer usable. + +## Full worked example + +This example shows styles, dates, formulas, column widths, a freeze pane, +an autofilter, and a second sheet: + +```php +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamedCell; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; + +$writer = new StreamingWriter('large-export.xlsx'); + +// Styles are registered once on the writer and referenced by id. +$headerStyle = $writer->registerStyle(['font' => ['bold' => true]]); +$moneyStyle = $writer->registerStyle(['numberFormat' => ['formatCode' => '#,##0.00']]); + +$sheet = $writer->startSheet('Orders'); + +// Column widths and the freeze pane must be set before the first +// appendRow() call. +$sheet->setColumnWidths([1 => 30.0, 2 => 15.0, 3 => 12.0]); +$sheet->freezePane('A2'); + +// The autofilter can be requested at any time before the sheet is +// finished; the actual range is only known once the sheet finishes. +$sheet->setAutoFilterToWrittenRange(); + +// A style id applies to every cell in the row unless overridden per cell. +$sheet->appendRow(['Customer', 'Order Date', 'Total'], $headerStyle); + +$sheet->appendRow([ + 'Acme Corp', + new \DateTimeImmutable('2026-01-15'), + new StreamedCell(1234.5, $moneyStyle), +]); + +// A leading "=" writes a formula; it is stored without a cached value. +// A string that is only "=" (length 1) is not long enough to be treated +// as a formula and is written as a plain string instead. +$sheet->appendRow(['Total', null, '=SUM(C2:C2)']); + +// Start a second sheet; the first sheet is finished and can no longer +// be appended to once startSheet() runs again. +$sheet2 = $writer->startSheet('Notes'); +$sheet2->appendRow(['Generated by the streaming writer.']); + +$writer->close(); +``` + +Because the workbook contains a formula, `close()` marks the workbook for +full calculation on load, so Excel (or another spreadsheet application) +computes `=SUM(C2:C2)` when it opens the file. The written cell itself has +no cached value. If no sheet in the workbook contains a formula, `close()` +does not force a recalculation on load. + +Any `DateTimeInterface` value that has no explicit style gets a default +date number format automatically. + +`freezePane('A1')` is accepted and writes no frozen pane. A pane frozen +at the top-left cell freezes zero rows and columns, so the writer skips +it. This makes computed freeze cells safe: code that derives the freeze +cell from a header-row count does not need a special case when that cell +turns out to be A1. + +Each finished sheet keeps its buffered XML in memory (a `php://temp` +stream) until `close()` runs, up to about 2MB per sheet before it spills +to a real temporary file on disk. Memory use at `close()` therefore scales +with the number of sheets, not with the number of rows in any one sheet. + +## API + +### `StreamingWriter` + +- `__construct(string $filename)` — opens `$filename` for writing. +- `startSheet(string $name): StreamingSheet` — finishes the current sheet, + if any, and starts a new one. The name must not be empty, must be at most + 31 characters, and must not contain `* : / \ ? [ ]`; an invalid name + throws a `Writer\Exception` and leaves the writer usable. A name that is + already in use is silently given a numeric suffix (a second `Data` sheet + becomes `Data 1`), the same behavior as `Worksheet::setTitle()`. +- `registerStyle(array $styleArray): int` — registers a style, in the same + array format used by `Style::applyFromArray()`, and returns its style id. +- `close(): void` — finishes the last sheet and writes the Xlsx file. The + writer must have at least one sheet. + +### `StreamingSheet` (returned by `startSheet()`) + +- `appendRow(array $cells, ?int $styleId = null): void` — writes one row. + `$styleId`, if given, applies to every cell in the row that does not + carry its own style through `StreamedCell`. +- `setColumnWidths(array $widths): void` — `$widths` is a 1-based column + number mapped to a width, for example `[1 => 30.0, 3 => 12.0]`. Must be + called before the first `appendRow()`. +- `freezePane(string $cell): void` — freezes rows and columns above and to + the left of `$cell`, for example `'A2'` to freeze the header row. Must be + called before the first `appendRow()`. The cell reference is validated + immediately; an invalid reference throws a `Writer\Exception` and leaves + the sheet usable. +- `setAutoFilterToWrittenRange(): void` — adds an autofilter over the full + range written to the sheet. Can be called at any time before the sheet is + finished; the range is only known once the sheet is finished. + +### `StreamedCell` + +Wrap a single cell value in `new StreamedCell($value, $styleId, $dataType)` +to give that cell its own style, overriding the row style, or to force its +data type. `$styleId` and `$dataType` both default to `null`. The only +supported `$dataType` values are `DataType::TYPE_STRING` and +`DataType::TYPE_STRING2`, which force the value to be written as a string +even if it looks like a formula (starts with `=`). Any other `$dataType` +value throws a `Writer\Exception`. Forcing a non-scalar value, such as a +`DateTimeInterface` date, to a string also throws; only scalar values can +be forced to string. + +### Supported cell values + +- Strings +- Integers and floats +- Booleans +- `DateTimeInterface` instances +- Formula strings (any string starting with `=`) +- `null` (leaves the cell empty) + +Values are written by their PHP type; there is no value binder. A numeric +string such as `'123'` is written as text, not as a number. Pass an `int` +or a `float` to write a number. + +Any other value type throws a `Writer\Exception`. Invalid values of a +supported type also throw instead of producing a broken file: + +- `NAN` and `INF` floats (Excel cannot store non-finite numbers) +- Strings that are not valid UTF-8 (they would corrupt the sheet XML) +- Strings longer than 32,767 characters (the Excel cell limit) + +## Strings are written as inline strings + +The streaming writer has no shared string table; every string cell is +written as an inline string. Excel and other spreadsheet applications read +inline strings as plain strings, with no special handling needed. + +PhpSpreadsheet's own `Xlsx` reader is the exception: it reads inline string +cells back as `RichText` objects, not plain strings. If you read a +streaming-writer file back with PhpSpreadsheet, cast the cell value to +`(string)` before comparing it to the original string: + +```php +$value = (string) $worksheet->getCell('A1')->getValue(); +``` + +## Supported features + +- Values, formulas, and dates +- Per-row styles (the `$styleId` argument of `appendRow()`) +- Per-cell styles and forced data types (`StreamedCell`) +- Column widths (`setColumnWidths()`) +- A single freeze pane (`freezePane()`) +- An autofilter over the written range (`setAutoFilterToWrittenRange()`) +- Multiple sheets, written one after another (`startSheet()`) + +## Not supported + +The streaming writer is append-only and forward-only. It does not support: + +- Random access: you cannot go back and change a cell or row already + written. +- Reading the file back through this writer; use the `Xlsx` reader instead, + and remember the inline-string caveat above. +- Merged cells +- Charts +- Drawings and images +- Comments +- Conditional formatting +- Hyperlinks +- Rich text cell values + +## Lifecycle rules + +- `setColumnWidths()` and `freezePane()` must be called before the first + `appendRow()` on that sheet. Calling either after the first row throws a + `Writer\Exception`. +- Sheets are sequential. Calling `startSheet()` finishes the current sheet; + the `StreamingSheet` object returned by the previous `startSheet()` call + becomes unusable, and any further method call on it throws a + `Writer\Exception`. +- Nothing on the writer or on any sheet is usable after `close()` has run. +- Arguments are checked before any XML is written where possible: an + invalid sheet name, an unregistered row-level `$styleId`, an invalid + freeze pane cell, and invalid column widths all throw without harming + the writer, so those exceptions are safe to catch and correct. +- A failed `appendRow()` call that throws while a row is being built, for + example because of an unsupported value or an unregistered + `StreamedCell` style id, invalidates the sheet. Every later call on that + sheet, including `close()`, throws. Discard the writer and start again. +- If the writer is destroyed without a `close()` call, for example when an + exception ends the request, the destructor deletes the partial file. No + file exists on disk until `close()` returns; a forgotten `close()` means + no output and no error. diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php new file mode 100644 index 0000000000..c632a5a82a --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php @@ -0,0 +1,19 @@ + */ + private array $columnWidths = []; + + private ?string $freezeCell = null; + + private bool $autoFilter = false; + + public function __construct(private StreamingWriter $writer) + { + $stream = fopen(self::TEMP_STREAM, 'wb+'); + if ($stream === false) { + // @codeCoverageIgnoreStart + throw new WriterException('Could not open temporary stream for sheet data.'); + // @codeCoverageIgnoreEnd + } + $this->stream = $stream; + $this->xmlWriter = new XMLWriter(); + $this->xmlWriter->openMemory(); + } + + /** + * Close sheetData and worksheet, and hand the temp stream to the writer. + * + * @return resource + * + * @internal called by StreamingWriter only + */ + public function finish() + { + $this->assertUsable(); + if (!$this->headerWritten) { + $this->writeHeader(); + } + $this->finished = true; + fwrite($this->stream, ''); + if ($this->autoFilter && $this->rowNumber > 0 && $this->maxColumn > 0) { + $range = 'A1:' . Coordinate::stringFromColumnIndex($this->maxColumn) . $this->rowNumber; + fwrite($this->stream, ''); + } + fwrite($this->stream, ''); + + return $this->stream; + } + + private function writeHeader(): void + { + $this->headerWritten = true; + fwrite($this->stream, '' . "\n"); + fwrite($this->stream, ''); + $paneXml = ''; + if ($this->freezeCell !== null) { + [$paneColumn, $paneRow] = Coordinate::indexesFromString($this->freezeCell); + $xSplit = $paneColumn - 1; + $ySplit = $paneRow - 1; + $activePane = ($xSplit > 0 && $ySplit > 0) ? 'bottomRight' : ($ySplit > 0 ? 'bottomLeft' : 'topRight'); + $paneXml = ' 0 ? ' xSplit="' . $xSplit . '"' : '') + . ($ySplit > 0 ? ' ySplit="' . $ySplit . '"' : '') + . ' topLeftCell="' . $this->freezeCell . '" activePane="' . $activePane . '" state="frozen"/>'; + } + fwrite($this->stream, '' . $paneXml . ''); + if ($this->columnWidths !== []) { + $cols = ''; + ksort($this->columnWidths); + foreach ($this->columnWidths as $columnNumber => $width) { + $cols .= ''; + } + $cols .= ''; + fwrite($this->stream, $cols); + } + fwrite($this->stream, ''); + } + + private function assertUsable(): void + { + if ($this->broken) { + throw new WriterException('A failed appendRow() left this sheet in an undefined state; discard this writer.'); + } + if ($this->finished) { + throw new WriterException('This sheet has been finished; use the sheet returned by the most recent startSheet().'); + } + } + + /** @param mixed[] $cells */ + public function appendRow(array $cells, ?int $styleId = null): void + { + $this->assertUsable(); + if ($styleId !== null) { + $this->assertStyleId($styleId); + } + + try { + if (!$this->headerWritten) { + $this->writeHeader(); + } + ++$this->rowNumber; + $xmlWriter = $this->xmlWriter; + $xmlWriter->startElement('row'); + $xmlWriter->writeAttribute('r', (string) $this->rowNumber); + $column = 0; + foreach ($cells as $value) { + ++$column; + if ($value === null) { + continue; + } + $this->writeCell($column, $value, $styleId); + } + $this->maxColumn = max($this->maxColumn, $column); + $xmlWriter->endElement(); // row + $flushed = $xmlWriter->flush(); + if (!is_string($flushed)) { + // @codeCoverageIgnoreStart + throw new WriterException('Unexpected non-string result from XMLWriter::flush().'); + // @codeCoverageIgnoreEnd + } + fwrite($this->stream, $flushed); + } catch (Throwable $e) { + $this->broken = true; + $this->xmlWriter->flush(); // discard the unclosed left behind by the failure + if ($e instanceof WriterException) { + throw $e; + } + + throw new WriterException('Failed to write the row: ' . $e->getMessage(), 0, $e); + } + } + + private function writeCell(int $column, mixed $value, ?int $rowStyleId): void + { + $cellStyleId = $rowStyleId; + $forcedType = null; + if ($value instanceof StreamedCell) { + if ($value->styleId !== null) { + $this->assertStyleId($value->styleId); + $cellStyleId = $value->styleId; + } + $forcedType = $value->dataType; + if ($forcedType !== null && $forcedType !== DataType::TYPE_STRING && $forcedType !== DataType::TYPE_STRING2) { + throw new WriterException("Unsupported StreamedCell data type '$forcedType'; only DataType::TYPE_STRING and DataType::TYPE_STRING2 are supported."); + } + $value = $value->value; + if ($value === null) { + return; + } + } + + $isDate = $value instanceof DateTimeInterface; + if ($isDate && $cellStyleId === null) { + $cellStyleId = $this->writer->getDefaultDateStyleId(); + } + + $xmlWriter = $this->xmlWriter; + $xmlWriter->startElement('c'); + $xmlWriter->writeAttribute('r', Coordinate::stringFromColumnIndex($column) . $this->rowNumber); + if ($cellStyleId !== null && $cellStyleId !== 0) { + $xmlWriter->writeAttribute('s', (string) $cellStyleId); + } + + if ($forcedType === DataType::TYPE_STRING || $forcedType === DataType::TYPE_STRING2) { + $this->writeInlineString(is_scalar($value) ? (string) $value : $this->rejectValue($value)); + } elseif ($isDate) { + $excelDate = Date::PHPToExcel($value); + if ($excelDate === false) { + $this->rejectValue($value); // @codeCoverageIgnore + } + $xmlWriter->writeElement('v', self::formatNumber($excelDate)); + } elseif (is_bool($value)) { + $xmlWriter->writeAttribute('t', 'b'); + $xmlWriter->writeElement('v', $value ? '1' : '0'); + } elseif (is_int($value) || is_float($value)) { + if (is_float($value) && !is_finite($value)) { + throw new WriterException('Cell value is not a finite number; NAN and INF cannot be stored in an Xlsx file.'); + } + $xmlWriter->writeElement('v', self::formatNumber($value)); + } elseif (is_string($value)) { + if (strlen($value) > 1 && $value[0] === '=') { + if (!StringHelper::isUTF8($value)) { + throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.'); + } + $this->writer->noteFormulaWritten(); + $xmlWriter->startElement('f'); + $xmlWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($value)); + $xmlWriter->endElement(); // f + } else { + $this->writeInlineString($value); + } + } else { + $this->rejectValue($value); + } + $xmlWriter->endElement(); // c + } + + private function writeInlineString(string $value): void + { + if (!StringHelper::isUTF8($value)) { + throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.'); + } + if (mb_strlen($value, 'UTF-8') > DataType::MAX_STRING_LENGTH) { + throw new WriterException('Cell string value exceeds the Excel limit of ' . DataType::MAX_STRING_LENGTH . ' characters.'); + } + $xmlWriter = $this->xmlWriter; + $xmlWriter->writeAttribute('t', 'inlineStr'); + $xmlWriter->startElement('is'); + $xmlWriter->startElement('t'); + if (trim($value) !== $value) { + $xmlWriter->writeAttribute('xml:space', 'preserve'); + } + $xmlWriter->text(StringHelper::controlCharacterPHP2OOXML($value)); + $xmlWriter->endElement(); // t + $xmlWriter->endElement(); // is + } + + /** + * Same serialization as Writer\Xlsx\Worksheet::writeCellNumeric(): full + * float precision, and a ".0" marker so integral floats read back as float. + */ + private static function formatNumber(float|int $value): string + { + $result = StringHelper::convertToString($value); + if (is_float($value) && !str_contains($result, '.')) { + $result .= '.0'; + } + + return $result; + } + + private function rejectValue(mixed $value): never + { + throw new WriterException('Unsupported cell value of type ' . get_debug_type($value) . '.'); + } + + private function assertStyleId(int $styleId): void + { + if (!$this->writer->isStyleIdRegistered($styleId)) { + throw new WriterException("Style id $styleId has not been registered with registerStyle()."); + } + } + + /** @param array $widths 1-based column number => width */ + public function setColumnWidths(array $widths): void + { + $this->assertBeforeFirstRow('setColumnWidths'); + foreach ($widths as $columnNumber => $width) { + if ($columnNumber < 1) { + throw new WriterException("Column number $columnNumber is invalid; column numbers are 1-based."); + } + if ($width <= 0) { + throw new WriterException("Column width $width is invalid; width must be positive."); + } + $this->columnWidths[$columnNumber] = $width; + } + } + + public function freezePane(string $cell): void + { + $this->assertBeforeFirstRow('freezePane'); + + try { + [$column, $row] = Coordinate::indexesFromString($cell); + } catch (Throwable $e) { + throw new WriterException("Invalid freeze pane cell '$cell': " . $e->getMessage(), 0, $e); + } + if ($row < 1) { + throw new WriterException("Invalid freeze pane cell '$cell': row numbers are 1-based."); + } + $normalized = Coordinate::stringFromColumnIndex($column) . $row; + if ($normalized === 'A1') { + return; + } + $this->freezeCell = $normalized; + } + + public function setAutoFilterToWrittenRange(): void + { + $this->assertUsable(); + $this->autoFilter = true; + } + + private function assertBeforeFirstRow(string $method): void + { + $this->assertUsable(); + if ($this->headerWritten) { + throw new WriterException("$method() must be called before the first appendRow()."); + } + } +} diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php new file mode 100644 index 0000000000..15f5e6eb01 --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -0,0 +1,207 @@ + */ + private array $finishedSheets = []; + + private int $sheetCount = 0; + + private bool $closed = false; + + private bool $hasFormulas = false; + + private ?int $defaultDateStyleId = null; + + public function __construct(string $filename) + { + try { + $fileHandle = fopen($filename, 'wb'); + } catch (Exception) { + $fileHandle = false; + } + if ($fileHandle === false) { + throw new WriterException("Could not open file $filename for writing."); + } + $this->filename = $filename; + $this->fileHandle = $fileHandle; + $this->shell = new Spreadsheet(); + $this->partWriter = new XlsxWriter($this->shell); + } + + public function __destruct() + { + if (!$this->closed) { + $this->closeSheetStreams(); + $this->closeFileHandleAndUnlink(); + } + } + + public function startSheet(string $name): StreamingSheet + { + $this->assertNotClosed(); + if ($name === '') { + throw new WriterException('Sheet name cannot be empty.'); + } + $this->finishActiveSheet(); + $shellSheet = ($this->sheetCount === 0) + ? $this->shell->getSheet(0) + : $this->shell->createSheet(); + + try { + $shellSheet->setTitle($name); + } catch (Throwable $e) { + if ($this->sheetCount > 0) { + // roll back createSheet() so the shell workbook only lists sheets that have a stream + $this->shell->removeSheetByIndex($this->shell->getIndex($shellSheet)); + } + + throw new WriterException("Invalid sheet name '$name': " . $e->getMessage(), 0, $e); + } + ++$this->sheetCount; + $this->activeSheet = new StreamingSheet($this); + + return $this->activeSheet; + } + + /** @param mixed[] $styleArray */ + public function registerStyle(array $styleArray): int + { + $this->assertNotClosed(); + $style = new Style(); + $style->applyFromArray($styleArray); + $this->shell->addCellXf($style); + + return $style->getIndex(); + } + + public function close(): void + { + $this->assertNotClosed(); + if ($this->sheetCount === 0) { + $this->closed = true; + $this->closeFileHandleAndUnlink(); + + throw new WriterException('Cannot close a streaming writer with no sheets; call startSheet() first.'); + } + $this->closed = true; + + try { + if (!is_resource($this->fileHandle)) { + // ZipStream silently falls back to php://output for a non-resource + throw new WriterException('The output file handle is no longer valid.'); + } + $this->finishActiveSheet(); + $zip = ZipStream0::newZipStream($this->fileHandle); + $partWriter = $this->partWriter; + $partWriter->createStyleDictionaries(); + $zip->addFile('[Content_Types].xml', $partWriter->getWriterPartContentTypes()->writeContentTypes($this->shell, false)); + $zip->addFile('_rels/.rels', $partWriter->getWriterPartRels()->writeRelationships($this->shell)); + $zip->addFile('xl/_rels/workbook.xml.rels', $partWriter->getWriterPartRels()->writeWorkbookRelationships($this->shell)); + $zip->addFile('docProps/app.xml', $partWriter->getWriterPartDocProps()->writeDocPropsApp($this->shell)); + $zip->addFile('docProps/core.xml', $partWriter->getWriterPartDocProps()->writeDocPropsCore($this->shell)); + $zip->addFile('xl/theme/theme1.xml', $partWriter->getWriterPartTheme()->writeTheme($this->shell)); + $zip->addFile('xl/sharedStrings.xml', $partWriter->getWriterPartStringTable()->writeStringTable([])); + $zip->addFile('xl/styles.xml', $partWriter->getWriterPartStyle()->writeStyles($this->shell)); + // when a formula was streamed, $forceFullCalc makes writeCalcPr() emit + // fullCalcOnLoad="1" forceFullCalc="1" so the opening application computes it + $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, !$this->hasFormulas, $this->hasFormulas)); + foreach ($this->finishedSheets as $index => $finishedSheet) { + rewind($finishedSheet['stream']); + $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $finishedSheet['stream']); + } + $zip->finish(); + } catch (Throwable $e) { + $this->closeSheetStreams(); + $this->closeFileHandleAndUnlink(); + if ($e instanceof WriterException) { + throw $e; + } + + throw new WriterException('Failed to write the Xlsx file: ' . $e->getMessage(), 0, $e); + } + + $this->closeSheetStreams(); + fclose($this->fileHandle); + } + + public function isStyleIdRegistered(int $styleId): bool + { + return $styleId >= 0 && $styleId < count($this->shell->getCellXfCollection()); + } + + public function getDefaultDateStyleId(): int + { + if ($this->defaultDateStyleId === null) { + $this->defaultDateStyleId = $this->registerStyle([ + 'numberFormat' => ['formatCode' => NumberFormat::FORMAT_DATE_DATETIME_BETTER], + ]); + } + + return $this->defaultDateStyleId; + } + + public function noteFormulaWritten(): void + { + $this->hasFormulas = true; + } + + private function finishActiveSheet(): void + { + if ($this->activeSheet !== null) { + $this->finishedSheets[] = ['stream' => $this->activeSheet->finish()]; + $this->activeSheet = null; + } + } + + private function assertNotClosed(): void + { + if ($this->closed) { + throw new WriterException('This streaming writer has already been closed.'); + } + } + + private function closeSheetStreams(): void + { + foreach ($this->finishedSheets as $finishedSheet) { + if (is_resource($finishedSheet['stream'])) { + fclose($finishedSheet['stream']); + } + } + $this->finishedSheets = []; + } + + private function closeFileHandleAndUnlink(): void + { + if (is_resource($this->fileHandle)) { + fclose($this->fileHandle); + } + if (file_exists($this->filename)) { + unlink($this->filename); + } + } +} diff --git a/tests/Benchmark/README.md b/tests/Benchmark/README.md new file mode 100644 index 0000000000..f424b221b5 --- /dev/null +++ b/tests/Benchmark/README.md @@ -0,0 +1,53 @@ +# Streaming writer benchmark + +`bench.php` compares the standard `Writer\Xlsx` engine against +`Writer\Xlsx\Streaming\StreamingWriter`. It writes rows of 8 mixed-type +cells (string, int, float, bool, `DateTimeImmutable`, string, float, bool) +and prints one JSON line with wall time, peak memory, and output file +size. + +These scripts are not PHPUnit tests. `phpunit.xml.dist` only scans +`tests/PhpSpreadsheetTests`, so `tests/Benchmark` is never picked up by +`phpunit` runs. + +## Usage + +``` +php tests/Benchmark/bench.php [rows] +``` + +`rows` defaults to 200000. + +Run each engine in its own process, so peak memory and wall time are never +shared between engines or between runs of the same engine. For a stable +median, run each engine 3 times: + +``` +php -d memory_limit=4G tests/Benchmark/bench.php standard 200000 +php -d memory_limit=4G tests/Benchmark/bench.php standard 200000 +php -d memory_limit=4G tests/Benchmark/bench.php standard 200000 +php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000 +php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000 +php -d memory_limit=4G tests/Benchmark/bench.php streaming 200000 +``` + +Take the median `elapsed_ms` and median `peak_memory_bytes` of each set of +3 runs. + +## Recorded results + +Environment: PHP 8.5.2 (cli, NTS, Opcache), Darwin 24.5.0 arm64 +(macOS, Apple Silicon), 2026-08-18. 200,000 rows x 8 columns, 3 runs per +engine in separate `php -d memory_limit=4G` processes, medians reported. + +| Engine | Median wall time | Median peak memory | Output file size | +| --- | --- | --- | --- | +| Standard (`Spreadsheet` + `Writer\Xlsx`) | 123.35 s | 1113.36 MB | 10,547,334 bytes | +| Streaming (`StreamingWriter`) | 6.73 s | 46.42 MB | 9,398,884 bytes | +| **Ratio (standard / streaming)** | **~18.3x slower** | **~24.0x more memory** | ~1.12x larger | + +The streaming writer is about 18 times faster and uses about 24 times less +peak memory for this workload, while producing a slightly smaller file +(inline strings avoid `sharedStrings.xml` overhead for this +mostly-unique-string dataset). Peak memory and file size are stable across +runs; wall time varies by less than 4%. diff --git a/tests/Benchmark/bench.php b/tests/Benchmark/bench.php new file mode 100644 index 0000000000..4842c9aaf1 --- /dev/null +++ b/tests/Benchmark/bench.php @@ -0,0 +1,109 @@ + [rows] + * + * Run each engine in its own process (this script does not fork) so wall + * time and memory are never shared between runs. See README.md in this + * directory for the full recipe and recorded results. + */ + +use PhpOffice\PhpSpreadsheet\Spreadsheet; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; + +require __DIR__ . '/../../vendor/autoload.php'; + +/** @return array */ +function benchBuildRow(int $row): array +{ + return [ + 'Name ' . $row, + $row, + $row * 1.5, + $row % 2 === 0, + new DateTimeImmutable('2026-01-01 00:00:00'), + 'Description for row ' . $row, + $row * 3.25, + $row % 3 === 0, + ]; +} + +function benchRunStandard(int $rows, string $file): void +{ + $spreadsheet = new Spreadsheet(); + $sheet = $spreadsheet->getActiveSheet(); + for ($row = 1; $row <= $rows; ++$row) { + $sheet->fromArray(benchBuildRow($row), null, 'A' . $row); + } + $writer = new Xlsx($spreadsheet); + $writer->save($file); + $spreadsheet->disconnectWorksheets(); +} + +function benchRunStreaming(int $rows, string $file): void +{ + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + for ($row = 1; $row <= $rows; ++$row) { + $sheet->appendRow(benchBuildRow($row)); + } + $writer->close(); +} + +/** @param array $argv */ +function benchMain(array $argv): int +{ + $engine = $argv[1] ?? null; + if ($engine !== 'standard' && $engine !== 'streaming') { + fwrite(STDERR, "Usage: php bench.php [rows]\n"); + + return 1; + } + $rows = isset($argv[2]) ? (int) $argv[2] : 200000; + + $file = tempnam(sys_get_temp_dir(), 'phpspreadsheet_bench_'); + if ($file === false) { + fwrite(STDERR, "Could not create a temporary file.\n"); + + return 1; + } + + $start = hrtime(true); + if ($engine === 'standard') { + benchRunStandard($rows, $file); + } else { + benchRunStreaming($rows, $file); + } + $elapsedNs = hrtime(true) - $start; + + $peakMemoryBytes = memory_get_peak_usage(true); + $fileSizeBytes = filesize($file); + unlink($file); + + $result = [ + 'engine' => $engine, + 'rows' => $rows, + 'elapsed_ms' => $elapsedNs / 1_000_000, + 'peak_memory_bytes' => $peakMemoryBytes, + 'file_size_bytes' => $fileSizeBytes, + ]; + + $encoded = json_encode($result); + echo ($encoded === false ? '{}' : $encoded) . "\n"; + + return 0; +} + +/** @var array $arguments */ +$arguments = $_SERVER['argv'] ?? []; +exit(benchMain($arguments)); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php new file mode 100644 index 0000000000..71be1b763b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php @@ -0,0 +1,28 @@ +value); + self::assertSame(3, $cell->styleId); + self::assertSame(DataType::TYPE_STRING, $cell->dataType); + } + + public function testDefaults(): void + { + $cell = new StreamedCell(1.5); + self::assertSame(1.5, $cell->value); + self::assertNull($cell->styleId); + self::assertNull($cell->dataType); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php new file mode 100644 index 0000000000..850947416b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -0,0 +1,306 @@ +tempFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + $this->tempFiles = []; + } + + private function tempFile(): string + { + $file = File::temporaryFilename(); + $this->tempFiles[] = $file; + + return $file; + } + + public function testUnwritableFileThrows(): void + { + $this->expectException(WriterException::class); + new StreamingWriter('/nonexistent-dir-zzz/out.xlsx'); + } + + public function testCloseWithoutSheetsThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('no sheets'); + $writer->close(); + } + + public function testDoubleCloseThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $writer->startSheet('Data'); + $writer->close(); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('already been closed'); + $writer->close(); + } + + public function testStartSheetAfterCloseThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $writer->startSheet('Data'); + $writer->close(); + $this->expectException(WriterException::class); + $writer->startSheet('More'); + } + + public function testStaleSheetThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $first = $writer->startSheet('First'); + $writer->startSheet('Second'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('finished'); + $first->appendRow(['x']); + } + + public function testAppendAfterCloseThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + $writer->close(); + $this->expectException(WriterException::class); + $sheet->appendRow(['x']); + } + + public function testInvalidSheetNameThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $this->expectException(WriterException::class); + $this->expectExceptionMessage("Invalid sheet name 'Bad[Name]'"); + $writer->startSheet('Bad[Name]'); + } + + public function testEmptySheetNameThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('cannot be empty'); + $writer->startSheet(''); + } + + public function testWriterStaysConsistentAfterInvalidSheetName(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $writer->startSheet('Good')->appendRow(['x']); + + try { + $writer->startSheet('Bad[Name]'); + self::fail('Expected a WriterException.'); + } catch (WriterException) { + // expected; the failed sheet must leave no trace in the workbook + } + + $writer->startSheet('Recovered')->appendRow(['y']); + $writer->close(); + + $spreadsheet = (new XlsxReader())->load($file); + self::assertSame(['Good', 'Recovered'], $spreadsheet->getSheetNames()); + $spreadsheet->disconnectWorksheets(); + + $zip = new ZipArchive(); + $zip->open($file); + self::assertIsString($zip->getFromName('xl/worksheets/sheet1.xml')); + self::assertIsString($zip->getFromName('xl/worksheets/sheet2.xml')); + self::assertFalse($zip->getFromName('xl/worksheets/sheet3.xml')); + $zip->close(); + } + + public function testInvalidFirstSheetNameLeavesWriterUsable(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + + try { + $writer->startSheet('Bad[Name]'); + self::fail('Expected a WriterException.'); + } catch (WriterException) { + // expected; the initial shell sheet is reused on retry + } + + $writer->startSheet('Good')->appendRow(['x']); + $writer->close(); + + $spreadsheet = (new XlsxReader())->load($file); + self::assertSame(['Good'], $spreadsheet->getSheetNames()); + $spreadsheet->disconnectWorksheets(); + } + + public function testRegisterStyleAfterCloseThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $writer->startSheet('Data'); + $writer->close(); + $this->expectException(WriterException::class); + $writer->registerStyle(['font' => ['bold' => true]]); + } + + public function testAppendRowAfterFailureThrowsBrokenState(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + + try { + $sheet->appendRow([new stdClass()]); + self::fail('Expected a WriterException from the failed appendRow().'); + } catch (WriterException) { + // expected; the sheet is now broken + } + + $this->expectException(WriterException::class); + $this->expectExceptionMessage('undefined state'); + $sheet->appendRow(['x']); + } + + public function testCloseAfterFailedAppendRowThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + + try { + $sheet->appendRow([new stdClass()]); + self::fail('Expected a WriterException from the failed appendRow().'); + } catch (WriterException) { + // expected; the sheet is now broken + } + + $this->expectException(WriterException::class); + $this->expectExceptionMessage('undefined state'); + $writer->close(); + } + + public function testDestructWithoutCloseRemovesFile(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + self::assertFileExists($file); + unset($writer); + self::assertFileDoesNotExist($file); + } + + public function testCloseWithoutSheetsRemovesFile(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + self::assertFileExists($file); + + try { + $writer->close(); + self::fail('Expected a WriterException.'); + } catch (WriterException) { + // expected + } + self::assertFileDoesNotExist($file); + } + + public function testCloseWithInvalidFileHandleThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $writer->startSheet('Data')->appendRow(['x']); + $handleProperty = new ReflectionProperty(StreamingWriter::class, 'fileHandle'); + $handle = $handleProperty->getValue($writer); + self::assertIsResource($handle); + fclose($handle); + + try { + $writer->close(); + self::fail('Expected a WriterException.'); + } catch (WriterException $e) { + self::assertStringContainsString('no longer valid', $e->getMessage()); + } + self::assertFileDoesNotExist($file); + } + + public function testInvalidFreezePaneCellThrowsImmediately(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + + try { + $sheet->freezePane('banana'); + self::fail('Expected a WriterException.'); + } catch (WriterException $e) { + self::assertStringContainsString("Invalid freeze pane cell 'banana'", $e->getMessage()); + } + + // the eager check must not poison the sheet + $sheet->appendRow(['x']); + } + + public function testZeroRowFreezePaneCellThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('row numbers are 1-based'); + $sheet->freezePane('B0'); + } + + public function testAppendRowWrapsUnderlyingWriteFailure(): void + { + $writer = new StreamingWriter($this->tempFile()); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['x']); + $streamProperty = new ReflectionProperty(StreamingSheet::class, 'stream'); + $stream = $streamProperty->getValue($sheet); + self::assertIsResource($stream); + fclose($stream); + + try { + $sheet->appendRow(['y']); + self::fail('Expected a WriterException.'); + } catch (WriterException $e) { + self::assertStringContainsString('Failed to write the row', $e->getMessage()); + self::assertNotNull($e->getPrevious()); + } + } + + public function testCloseWrapsUnderlyingWriteFailure(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['x']); + $streamProperty = new ReflectionProperty(StreamingSheet::class, 'stream'); + $stream = $streamProperty->getValue($sheet); + self::assertIsResource($stream); + fclose($stream); + + try { + $writer->close(); + self::fail('Expected a WriterException.'); + } catch (WriterException $e) { + self::assertStringContainsString('Failed to write the Xlsx file', $e->getMessage()); + self::assertNotNull($e->getPrevious()); + } + self::assertFileDoesNotExist($file); + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php new file mode 100644 index 0000000000..193df7fceb --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php @@ -0,0 +1,51 @@ +measurePeak(100000); + $doubled = $this->measurePeak(200000); + self::assertLessThan($atSaturation + 4 * 1024 * 1024, $doubled); + self::assertLessThan(64 * 1024 * 1024, $doubled); + } + + private function measurePeak(int $rows): int + { + $file = File::temporaryFilename(); + + try { + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Big'); + if (function_exists('memory_reset_peak_usage')) { // PHP 8.2+, the caller skips otherwise + memory_reset_peak_usage(); + } + $before = memory_get_peak_usage(true); + for ($row = 1; $row <= $rows; ++$row) { + $sheet->appendRow(['row ' . $row, $row, $row * 1.5, $row % 2 === 0]); + } + $writer->close(); + self::assertGreaterThan(0, filesize($file)); + + return memory_get_peak_usage(true) - $before; + } finally { + if (file_exists($file)) { + unlink($file); + } + } + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php new file mode 100644 index 0000000000..92080bb6e9 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -0,0 +1,430 @@ +tempFiles as $file) { + if (file_exists($file)) { + unlink($file); + } + } + $this->tempFiles = []; + } + + private function tempFile(): string + { + $file = File::temporaryFilename(); + $this->tempFiles[] = $file; + + return $file; + } + + /** Cell values read back as RichText or scalar; normalize to string for comparison. */ + private static function stringValue(mixed $value): string + { + if (is_scalar($value) || $value instanceof Stringable) { + return (string) $value; + } + + throw new RuntimeException('Expected a stringable cell value, got ' . get_debug_type($value) . '.'); + } + + public function testEmptySheetsRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $writer->startSheet('First'); + $writer->startSheet('Second Sheet'); + $writer->close(); + + $spreadsheet = (new XlsxReader())->load($file); + self::assertSame(['First', 'Second Sheet'], $spreadsheet->getSheetNames()); + $spreadsheet->disconnectWorksheets(); + } + + public function testScalarRowsRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['Name', 'Count', 'Ratio', 'Flag']); + $sheet->appendRow(['Ärger & ', 42, 1.25, true]); + $sheet->appendRow([null, null, ' padded ', false]); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + // Note: inline strings are read as RichText by PhpSpreadsheet's reader; cast to string for comparison + self::assertSame('Name', self::stringValue($worksheet->getCell('A1')->getValue())); + self::assertSame('Ärger & ', self::stringValue($worksheet->getCell('A2')->getValue())); + self::assertSame(42, $worksheet->getCell('B2')->getValue()); + self::assertSame(1.25, $worksheet->getCell('C2')->getValue()); + self::assertTrue($worksheet->getCell('D2')->getValue()); + self::assertFalse($worksheet->getCell('D3')->getValue()); + self::assertNull($worksheet->getCell('A3')->getValue()); + self::assertSame(' padded ', self::stringValue($worksheet->getCell('C3')->getValue())); + } + + public function testNumericFidelityMatchesStandardWriter(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow([2.0, 123456.78901234567, 1.0E+20, PHP_INT_MAX]); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + // an integral float must read back as float, not int, like the standard writer + self::assertSame(2.0, $worksheet->getCell('A1')->getValue()); + // full double precision must survive; a plain (string) cast would truncate at 14 digits + self::assertSame(123456.78901234567, $worksheet->getCell('B1')->getValue()); + self::assertSame(1.0E+20, $worksheet->getCell('C1')->getValue()); + self::assertSame(PHP_INT_MAX, $worksheet->getCell('D1')->getValue()); + } + + public function testUnsupportedValueThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $sheet->appendRow([new stdClass()]); + } + + public function testFormulaRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow([2, 3]); + $sheet->appendRow(['=SUM(A1:B1)']); + $sheet->appendRow([new StreamedCell('=not a formula', null, DataType::TYPE_STRING)]); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertSame('=SUM(A1:B1)', $worksheet->getCell('A2')->getValue()); + self::assertSame(5, $worksheet->getCell('A2')->getCalculatedValue()); + self::assertSame('=not a formula', self::stringValue($worksheet->getCell('A3')->getValue())); + } + + public function testDateTimeRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow([new DateTimeImmutable('2026-01-02 03:04:05')]); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + $cell = $worksheet->getCell('A1'); + self::assertEqualsWithDelta(46024.12783564815, $cell->getValue(), 1E-8); + self::assertSame( + NumberFormat::FORMAT_DATE_DATETIME_BETTER, + $worksheet->getStyle('A1')->getNumberFormat()->getFormatCode() + ); + } + + public function testStylesRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $bold = $writer->registerStyle(['font' => ['bold' => true]]); + $money = $writer->registerStyle(['numberFormat' => ['formatCode' => '#,##0.00']]); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['Header A', 'Header B'], $bold); + $sheet->appendRow([new StreamedCell(1234.5, $money), 'plain']); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertTrue($worksheet->getStyle('A1')->getFont()->getBold()); + self::assertTrue($worksheet->getStyle('B1')->getFont()->getBold()); + self::assertSame('#,##0.00', $worksheet->getStyle('A2')->getNumberFormat()->getFormatCode()); + self::assertFalse($worksheet->getStyle('B2')->getFont()->getBold()); + } + + public function testUnregisteredStyleIdThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('has not been registered'); + $sheet->appendRow(['x'], 99); + } + + public function testSheetFeaturesRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->setColumnWidths([1 => 25.5, 3 => 8.0]); + $sheet->freezePane('A2'); + $sheet->setAutoFilterToWrittenRange(); + $sheet->appendRow(['H1', 'H2', 'H3']); + $sheet->appendRow(['a', 'b', 'c']); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertSame(25.5, $worksheet->getColumnDimension('A')->getWidth()); + self::assertSame(8.0, $worksheet->getColumnDimension('C')->getWidth()); + self::assertSame('A2', $worksheet->getFreezePane()); + self::assertSame('A1:C2', $worksheet->getAutoFilter()->getRange()); + } + + public function testCalcPrReflectsFormulaPresence(): void + { + $noFormulaFile = $this->tempFile(); + $writer = new StreamingWriter($noFormulaFile); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow([1, 2]); + $writer->close(); + + $formulaFile = $this->tempFile(); + $writer = new StreamingWriter($formulaFile); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow([1, 2]); + $sheet->appendRow(['=SUM(A1:B1)']); + $writer->close(); + + $noFormulaCalcPr = $this->readCalcPr($noFormulaFile); + $formulaCalcPr = $this->readCalcPr($formulaFile); + + self::assertSame('0', $noFormulaCalcPr['fullCalcOnLoad']); + self::assertSame('1', $noFormulaCalcPr['calcCompleted']); + self::assertSame('0', $noFormulaCalcPr['forceFullCalc']); + + self::assertSame('1', $formulaCalcPr['fullCalcOnLoad']); + self::assertSame('0', $formulaCalcPr['calcCompleted']); + self::assertSame('1', $formulaCalcPr['forceFullCalc']); + } + + /** @return array */ + private function readCalcPr(string $file): array + { + $zip = new ZipArchive(); + $zip->open($file); + $workbookXml = $zip->getFromName('xl/workbook.xml'); + $zip->close(); + self::assertIsString($workbookXml); + + self::assertMatchesRegularExpression('/]*\/>/', $workbookXml); + preg_match('/]*)\/>/', $workbookXml, $matches); + $attributesXml = $matches[1] ?? ''; + preg_match_all('/(\w+)="([^"]*)"/', $attributesXml, $attributeMatches, \PREG_SET_ORDER); + $attributes = []; + foreach ($attributeMatches as $attributeMatch) { + $attributes[$attributeMatch[1]] = $attributeMatch[2]; + } + + return $attributes; + } + + public function testMultipleSheetsWithDataRoundTrip(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $first = $writer->startSheet('First'); + $first->appendRow(['first-a1', 'first-b1']); + $first->appendRow(['first-a2', 'first-b2']); + $second = $writer->startSheet('Second'); + $second->appendRow(['second-a1', 'second-b1']); + $writer->close(); + + $spreadsheet = (new XlsxReader())->load($file); + self::assertSame(['First', 'Second'], $spreadsheet->getSheetNames()); + + $firstSheet = $spreadsheet->getSheetByNameOrThrow('First'); + self::assertSame('first-a1', self::stringValue($firstSheet->getCell('A1')->getValue())); + self::assertSame('first-b1', self::stringValue($firstSheet->getCell('B1')->getValue())); + self::assertSame('first-a2', self::stringValue($firstSheet->getCell('A2')->getValue())); + self::assertSame('first-b2', self::stringValue($firstSheet->getCell('B2')->getValue())); + + $secondSheet = $spreadsheet->getSheetByNameOrThrow('Second'); + self::assertSame('second-a1', self::stringValue($secondSheet->getCell('A1')->getValue())); + self::assertSame('second-b1', self::stringValue($secondSheet->getCell('B1')->getValue())); + self::assertNull($secondSheet->getCell('A2')->getValue()); + + $spreadsheet->disconnectWorksheets(); + } + + public function testColumnWidthsAfterFirstRowThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['x']); + $this->expectException(WriterException::class); + $sheet->setColumnWidths([1 => 10.0]); + } + + public function testFreezePaneAfterFirstRowThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->appendRow(['x']); + $this->expectException(WriterException::class); + $sheet->freezePane('A1'); + } + + public function testColumnWidthsRejectsInvalidColumnNumber(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('is invalid; column numbers are 1-based'); + $sheet->setColumnWidths([0 => 10.0]); + } + + public function testColumnWidthsRejectsNonPositiveWidth(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('is invalid; width must be positive'); + $sheet->setColumnWidths([1 => 0.0]); + } + + public function testUnsupportedStreamedCellDataTypeThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('Unsupported StreamedCell data type'); + $sheet->appendRow([new StreamedCell(42, null, DataType::TYPE_NUMERIC)]); + } + + public function testNanThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('not a finite number'); + $sheet->appendRow([NAN]); + } + + public function testInfinityThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('not a finite number'); + $sheet->appendRow([INF]); + } + + public function testInvalidUtf8StringThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('not valid UTF-8'); + $sheet->appendRow(["bad \xC3\x28 utf8"]); + } + + public function testInvalidUtf8FormulaThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('not valid UTF-8'); + $sheet->appendRow(["=\"bad \xC3\x28\""]); + } + + public function testStringOverExcelLimitThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('32767'); + $sheet->appendRow([str_repeat('x', DataType::MAX_STRING_LENGTH + 1)]); + } + + public function testStringAtExcelLimitRoundTrips(): void + { + $file = $this->tempFile(); + $value = str_repeat('x', DataType::MAX_STRING_LENGTH); + $writer = new StreamingWriter($file); + $writer->startSheet('Data')->appendRow([$value]); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertSame($value, self::stringValue($worksheet->getCell('A1')->getValue())); + } + + public function testInvalidUtf8ForcedStringThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $this->expectExceptionMessage('not valid UTF-8'); + $sheet->appendRow([new StreamedCell("bad \xC3\x28 utf8", null, DataType::TYPE_STRING)]); + } + + public function testStreamedCellWithNullValueLeavesCellEmpty(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $writer->startSheet('Data')->appendRow([new StreamedCell(null), 'b']); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertNull($worksheet->getCell('A1')->getValue()); + self::assertSame('b', self::stringValue($worksheet->getCell('B1')->getValue())); + } + + public function testFreezePaneNormalizesAbsoluteReference(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->freezePane('$B$2'); + $sheet->appendRow(['a', 'b']); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertSame('B2', $worksheet->getFreezePane()); + } + + public function testFreezePaneA1IsANoOp(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $sheet->freezePane('A1'); + $sheet->appendRow(['x']); + $writer->close(); + + $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); + self::assertNull($worksheet->getFreezePane()); + } +}