From 36a446f997f2fd7f6571b0fdd6c57f3da7cca965 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 11:43:01 +0200 Subject: [PATCH 01/35] Add StreamedCell value object for streaming writer --- .../Writer/Xlsx/Streaming/StreamedCell.php | 19 +++++++++++++ .../Xlsx/Streaming/StreamedCellTest.php | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+) create mode 100644 src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamedCell.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamedCellTest.php 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 @@ +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); + } +} From 0412d0a7eab0f002f17b869795de60f3b062ec68 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 11:46:44 +0200 Subject: [PATCH 02/35] Add StreamingWriter skeleton with zip assembly and shell workbook parts --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 76 ++++++++++ .../Writer/Xlsx/Streaming/StreamingWriter.php | 142 ++++++++++++++++++ .../Xlsx/Streaming/StreamingWriterTest.php | 47 ++++++ 3 files changed, 265 insertions(+) create mode 100644 src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php create mode 100644 src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php new file mode 100644 index 0000000000..d1fa651a5a --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -0,0 +1,76 @@ +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, ''); + fwrite($this->stream, ''); + + return $this->stream; + } + + private function writeHeader(): void + { + $this->headerWritten = true; + fwrite($this->stream, '' . "\n"); + fwrite($this->stream, ''); + fwrite($this->stream, ''); + fwrite($this->stream, ''); + } + + private function assertUsable(): void + { + if ($this->finished) { + throw new WriterException('This sheet has been finished; use the sheet returned by the most recent startSheet().'); + } + } +} diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php new file mode 100644 index 0000000000..0f89172459 --- /dev/null +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -0,0 +1,142 @@ + */ + private array $finishedSheets = []; + + private int $sheetCount = 0; + + private bool $closed = false; + + private bool $hasFormulas = false; + + private ?int $defaultDateStyleId = null; + + public function __construct(string $filename) + { + $fileHandle = fopen($filename, 'wb+'); + if ($fileHandle === false) { + throw new WriterException("Could not open file $filename for writing."); + } + $this->fileHandle = $fileHandle; + $this->shell = new Spreadsheet(); + $this->partWriter = new XlsxWriter($this->shell); + } + + public function startSheet(string $name): StreamingSheet + { + $this->assertNotClosed(); + $this->finishActiveSheet(); + $shellSheet = ($this->sheetCount === 0) + ? $this->shell->getSheet(0) + : $this->shell->createSheet(); + $shellSheet->setTitle($name); + ++$this->sheetCount; + $this->activeSheet = new StreamingSheet($this); + + return $this->activeSheet; + } + + 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) { + throw new WriterException('Cannot close a streaming writer with no sheets; call startSheet() first.'); + } + $this->finishActiveSheet(); + $this->closed = true; + + try { + $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)); + $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, false, $this->hasFormulas ? true : null)); + foreach ($this->finishedSheets as $index => $finishedSheet) { + rewind($finishedSheet['stream']); + $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $finishedSheet['stream']); + } + $zip->finish(); + } finally { + foreach ($this->finishedSheets as $finishedSheet) { + fclose($finishedSheet['stream']); + } + $this->finishedSheets = []; + 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], + ]); + } + + 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.'); + } + } +} diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php new file mode 100644 index 0000000000..f166718b3f --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -0,0 +1,47 @@ +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 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(); + } +} From 3c87d76313f4606ef34b347d534a99c9f65d756f Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 11:55:31 +0200 Subject: [PATCH 03/35] Add appendRow with scalar types and string handling to streaming writer --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 91 +++++++++++++++++++ .../Writer/Xlsx/Streaming/StreamingWriter.php | 21 ++++- .../Xlsx/Streaming/StreamingWriterTest.php | 31 +++++++ 3 files changed, 142 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index d1fa651a5a..8d66dbc9aa 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -4,7 +4,10 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming; +use PhpOffice\PhpSpreadsheet\Cell\Coordinate; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; +use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use XMLWriter; @@ -73,4 +76,92 @@ private function assertUsable(): void throw new WriterException('This sheet has been finished; use the sheet returned by the most recent startSheet().'); } } + + public function appendRow(array $cells, ?int $styleId = null): void + { + $this->assertUsable(); + if ($styleId !== null) { + $this->assertStyleId($styleId); + } + 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 + fwrite($this->stream, $xmlWriter->flush()); + } + + 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; + $value = $value->value; + if ($value === null) { + return; + } + } + + $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) { + $stringValue = is_scalar($value) ? (string) $value : $this->rejectValue($value); + $this->writeSharedString($stringValue); + } elseif (is_bool($value)) { + $xmlWriter->writeAttribute('t', 'b'); + $xmlWriter->writeElement('v', $value ? '1' : '0'); + } elseif (is_int($value) || is_float($value)) { + $xmlWriter->writeElement('v', (string) $value); + } elseif (is_string($value)) { + if (strlen($value) > 1 && $value[0] === '=') { + throw new WriterException('Formulas are not supported yet.'); + } + $this->writeSharedString($value); + } else { + $this->rejectValue($value); + } + $xmlWriter->endElement(); // c + } + + private function writeSharedString(string $value): void + { + $xmlWriter = $this->xmlWriter; + $xmlWriter->writeAttribute('t', 's'); + $index = $this->writer->getStringIndex($value); + $xmlWriter->writeElement('v', (string) $index); + } + + 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()."); + } + } } diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 0f89172459..1f787fde7c 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -33,6 +33,11 @@ class StreamingWriter private ?int $defaultDateStyleId = null; + /** @var array */ + private array $stringDictionary = []; + + private int $nextStringIndex = 0; + public function __construct(string $filename) { $fileHandle = fopen($filename, 'wb+'); @@ -87,7 +92,12 @@ public function close(): void $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([])); + // Build shared strings array ordered by index + $sharedStrings = array_fill(0, $this->nextStringIndex, ''); + foreach ($this->stringDictionary as $string => $index) { + $sharedStrings[$index] = $string; + } + $zip->addFile('xl/sharedStrings.xml', $partWriter->getWriterPartStringTable()->writeStringTable($sharedStrings)); $zip->addFile('xl/styles.xml', $partWriter->getWriterPartStyle()->writeStyles($this->shell)); $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, false, $this->hasFormulas ? true : null)); foreach ($this->finishedSheets as $index => $finishedSheet) { @@ -125,6 +135,15 @@ public function noteFormulaWritten(): void $this->hasFormulas = true; } + public function getStringIndex(string $value): int + { + if (!isset($this->stringDictionary[$value])) { + $this->stringDictionary[$value] = $this->nextStringIndex++; + } + + return $this->stringDictionary[$value]; + } + private function finishActiveSheet(): void { if ($this->activeSheet !== null) { diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index f166718b3f..0633a96676 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -6,6 +6,7 @@ use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Shared\File; +use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; use PHPUnit\Framework\TestCase; @@ -44,4 +45,34 @@ public function testEmptySheetsRoundTrip(): void 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'); + self::assertSame('Name', $worksheet->getCell('A1')->getValue()); + self::assertSame('Ärger & ', $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 ', $worksheet->getCell('C3')->getValue()); + } + + public function testUnsupportedValueThrows(): void + { + $file = $this->tempFile(); + $writer = new StreamingWriter($file); + $sheet = $writer->startSheet('Data'); + $this->expectException(WriterException::class); + $sheet->appendRow([new \stdClass()]); + } } From 36f699823a85e025f00d260523c249432accda3e Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 11:59:04 +0200 Subject: [PATCH 04/35] Use inline strings for streaming writer string cells --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 19 ++++++++++------- .../Writer/Xlsx/Streaming/StreamingWriter.php | 21 +------------------ .../Xlsx/Streaming/StreamingWriterTest.php | 7 ++++--- 3 files changed, 17 insertions(+), 30 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 8d66dbc9aa..c3f71be6b8 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -127,8 +127,7 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void } if ($forcedType === DataType::TYPE_STRING || $forcedType === DataType::TYPE_STRING2) { - $stringValue = is_scalar($value) ? (string) $value : $this->rejectValue($value); - $this->writeSharedString($stringValue); + $this->writeInlineString(is_scalar($value) ? (string) $value : $this->rejectValue($value)); } elseif (is_bool($value)) { $xmlWriter->writeAttribute('t', 'b'); $xmlWriter->writeElement('v', $value ? '1' : '0'); @@ -138,19 +137,25 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void if (strlen($value) > 1 && $value[0] === '=') { throw new WriterException('Formulas are not supported yet.'); } - $this->writeSharedString($value); + $this->writeInlineString($value); } else { $this->rejectValue($value); } $xmlWriter->endElement(); // c } - private function writeSharedString(string $value): void + private function writeInlineString(string $value): void { $xmlWriter = $this->xmlWriter; - $xmlWriter->writeAttribute('t', 's'); - $index = $this->writer->getStringIndex($value); - $xmlWriter->writeElement('v', (string) $index); + $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 } private function rejectValue(mixed $value): never diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 1f787fde7c..0f89172459 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -33,11 +33,6 @@ class StreamingWriter private ?int $defaultDateStyleId = null; - /** @var array */ - private array $stringDictionary = []; - - private int $nextStringIndex = 0; - public function __construct(string $filename) { $fileHandle = fopen($filename, 'wb+'); @@ -92,12 +87,7 @@ public function close(): void $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)); - // Build shared strings array ordered by index - $sharedStrings = array_fill(0, $this->nextStringIndex, ''); - foreach ($this->stringDictionary as $string => $index) { - $sharedStrings[$index] = $string; - } - $zip->addFile('xl/sharedStrings.xml', $partWriter->getWriterPartStringTable()->writeStringTable($sharedStrings)); + $zip->addFile('xl/sharedStrings.xml', $partWriter->getWriterPartStringTable()->writeStringTable([])); $zip->addFile('xl/styles.xml', $partWriter->getWriterPartStyle()->writeStyles($this->shell)); $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, false, $this->hasFormulas ? true : null)); foreach ($this->finishedSheets as $index => $finishedSheet) { @@ -135,15 +125,6 @@ public function noteFormulaWritten(): void $this->hasFormulas = true; } - public function getStringIndex(string $value): int - { - if (!isset($this->stringDictionary[$value])) { - $this->stringDictionary[$value] = $this->nextStringIndex++; - } - - return $this->stringDictionary[$value]; - } - private function finishActiveSheet(): void { if ($this->activeSheet !== null) { diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 0633a96676..a19b171abe 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -57,14 +57,15 @@ public function testScalarRowsRoundTrip(): void $writer->close(); $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); - self::assertSame('Name', $worksheet->getCell('A1')->getValue()); - self::assertSame('Ärger & ', $worksheet->getCell('A2')->getValue()); + // Note: inline strings are read as RichText by PhpSpreadsheet's reader; cast to string for comparison + self::assertSame('Name', (string) $worksheet->getCell('A1')->getValue()); + self::assertSame('Ärger & ', (string) $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 ', $worksheet->getCell('C3')->getValue()); + self::assertSame(' padded ', (string) $worksheet->getCell('C3')->getValue()); } public function testUnsupportedValueThrows(): void From d7e248feda7ea45f532a7bcb62bf3afb549f092a Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:05:25 +0200 Subject: [PATCH 05/35] Add formula support with full calc on load to streaming writer --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 9 +++++++-- .../Xlsx/Streaming/StreamingWriterTest.php | 18 ++++++++++++++++++ 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index c3f71be6b8..8ddd70c9e6 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -9,6 +9,7 @@ use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\FunctionPrefix; use XMLWriter; class StreamingSheet @@ -135,9 +136,13 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void $xmlWriter->writeElement('v', (string) $value); } elseif (is_string($value)) { if (strlen($value) > 1 && $value[0] === '=') { - throw new WriterException('Formulas are not supported yet.'); + $this->writer->noteFormulaWritten(); + $xmlWriter->startElement('f'); + $xmlWriter->text(FunctionPrefix::addFunctionPrefixStripEquals($value)); + $xmlWriter->endElement(); // f + } else { + $this->writeInlineString($value); } - $this->writeInlineString($value); } else { $this->rejectValue($value); } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index a19b171abe..c6b780a41d 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -4,9 +4,11 @@ namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx\Streaming; +use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamedCell; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; use PHPUnit\Framework\TestCase; @@ -76,4 +78,20 @@ public function testUnsupportedValueThrows(): void $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', (string) $worksheet->getCell('A3')->getValue()); + } } From 5095ba78b4945874121e620cc2c3e8263811365b Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:08:38 +0200 Subject: [PATCH 06/35] Add DateTime support with default date format to streaming writer --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 13 +++++++++++++ .../Xlsx/Streaming/StreamingWriterTest.php | 18 ++++++++++++++++++ 2 files changed, 31 insertions(+) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 8ddd70c9e6..558ffe4028 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -4,9 +4,11 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming; +use DateTimeInterface; use PhpOffice\PhpSpreadsheet\Cell\Coordinate; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx\Namespaces; +use PhpOffice\PhpSpreadsheet\Shared\Date; use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\FunctionPrefix; @@ -120,6 +122,11 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void } } + $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); @@ -129,6 +136,12 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void 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', (string) $excelDate); } elseif (is_bool($value)) { $xmlWriter->writeAttribute('t', 'b'); $xmlWriter->writeElement('v', $value ? '1' : '0'); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index c6b780a41d..e49e80b613 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -7,6 +7,7 @@ use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Shared\File; +use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamedCell; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; @@ -94,4 +95,21 @@ public function testFormulaRoundTrip(): void self::assertSame(5, $worksheet->getCell('A2')->getCalculatedValue()); self::assertSame('=not a formula', (string) $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, + $worksheet->getStyle('A1')->getNumberFormat()->getFormatCode() + ); + } } From 21213679c2a1cb6da70e68c609d1454eb9b29993 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:11:07 +0200 Subject: [PATCH 07/35] Add style round-trip tests for streaming writer --- .../Xlsx/Streaming/StreamingWriterTest.php | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index e49e80b613..be664e2e8c 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -112,4 +112,32 @@ public function testDateTimeRoundTrip(): void $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); + } } From 2065c8b87de40e1bfdd8fb4cf1175262ba8383bd Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:13:49 +0200 Subject: [PATCH 08/35] Add column widths, freeze pane and autofilter to streaming writer --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 65 ++++++++++++++++++- .../Xlsx/Streaming/StreamingWriterTest.php | 29 +++++++++ 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 558ffe4028..28c419abd9 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -31,6 +31,13 @@ class StreamingSheet private int $maxColumn = 0; + /** @var array */ + private array $columnWidths = []; + + private ?string $freezeCell = null; + + private bool $autoFilter = false; + public function __construct(private StreamingWriter $writer) { $stream = fopen(self::TEMP_STREAM, 'wb+'); @@ -59,6 +66,10 @@ public function finish() } $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; @@ -69,7 +80,27 @@ private function writeHeader(): void $this->headerWritten = true; fwrite($this->stream, '' . "\n"); fwrite($this->stream, ''); - 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, ''); } @@ -187,4 +218,36 @@ private function assertStyleId(int $styleId): void 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) { + $this->columnWidths[$columnNumber] = $width; + } + } + + public function freezePane(string $cell): void + { + if ($cell === 'A1') { + return; + } + $this->assertBeforeFirstRow('freezePane'); + $this->freezeCell = $cell; + } + + 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/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index be664e2e8c..1fc32d5794 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -140,4 +140,33 @@ public function testUnregisteredStyleIdThrows(): void $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 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]); + } } From a7be151f9fd19ee88786ee87f82722f7692209ed Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:18:42 +0200 Subject: [PATCH 09/35] Validate freeze pane state before the A1 no-op --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 2 +- .../Writer/Xlsx/Streaming/StreamingWriterTest.php | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 28c419abd9..7672cd523f 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -230,10 +230,10 @@ public function setColumnWidths(array $widths): void public function freezePane(string $cell): void { + $this->assertBeforeFirstRow('freezePane'); if ($cell === 'A1') { return; } - $this->assertBeforeFirstRow('freezePane'); $this->freezeCell = $cell; } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 1fc32d5794..6adfcb185a 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -169,4 +169,14 @@ public function testColumnWidthsAfterFirstRowThrows(): void $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'); + } } From 230b775a3346ea728ae0cb16f706d5b3a1cf730a Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:21:45 +0200 Subject: [PATCH 10/35] Add lifecycle guard tests for streaming writer --- .../Writer/Xlsx/Streaming/StreamingWriter.php | 6 +- .../Xlsx/Streaming/StreamingErrorsTest.php | 102 ++++++++++++++++++ 2 files changed, 107 insertions(+), 1 deletion(-) create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 0f89172459..936be71470 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -35,7 +35,11 @@ class StreamingWriter public function __construct(string $filename) { - $fileHandle = fopen($filename, 'wb+'); + try { + $fileHandle = fopen($filename, 'wb+'); + } catch (\Exception $e) { + throw new WriterException("Could not open file $filename for writing."); + } if ($fileHandle === false) { throw new WriterException("Could not open file $filename for writing."); } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php new file mode 100644 index 0000000000..34b0daeae5 --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -0,0 +1,102 @@ +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(\PhpOffice\PhpSpreadsheet\Exception::class); + $writer->startSheet('Bad[Name]'); + } + + public function testRegisterStyleAfterCloseThrows(): void + { + $writer = new StreamingWriter($this->tempFile()); + $writer->startSheet('Data'); + $writer->close(); + $this->expectException(WriterException::class); + $writer->registerStyle(['font' => ['bold' => true]]); + } +} From 3efb9b05db333006234e87e4131da18dcbbbd246 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:26:51 +0200 Subject: [PATCH 11/35] Use unqualified Exception import in streaming writer --- src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 936be71470..5df211a5bd 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming; +use Exception; use PhpOffice\PhpSpreadsheet\Spreadsheet; use PhpOffice\PhpSpreadsheet\Style\NumberFormat; use PhpOffice\PhpSpreadsheet\Style\Style; @@ -37,7 +38,7 @@ public function __construct(string $filename) { try { $fileHandle = fopen($filename, 'wb+'); - } catch (\Exception $e) { + } catch (Exception) { throw new WriterException("Could not open file $filename for writing."); } if ($fileHandle === false) { From 2524a85d1c174df74955e575d961756274f3a412 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:29:09 +0200 Subject: [PATCH 12/35] Add flat-memory guard test for streaming writer --- .../Xlsx/Streaming/StreamingMemoryTest.php | 41 +++++++++++++++++++ 1 file changed, 41 insertions(+) create mode 100644 tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php new file mode 100644 index 0000000000..4c519e8c5b --- /dev/null +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php @@ -0,0 +1,41 @@ +startSheet('Big'); + memory_reset_peak_usage(); + $before = memory_get_peak_usage(true); + for ($row = 1; $row <= 100000; ++$row) { + $sheet->appendRow(['row ' . $row, $row, $row * 1.5, $row % 2 === 0]); + } + $writer->close(); + $peakDelta = memory_get_peak_usage(true) - $before; + // 100k rows x 4 cells at ~1KB/cell would need ~400MB in the + // standard model; the streaming writer must stay under 24MB + // (temp stream spill threshold + zip deflate buffers). + self::assertLessThan(24 * 1024 * 1024, $peakDelta); + self::assertGreaterThan(0, filesize($file)); + } finally { + if (file_exists($file)) { + unlink($file); + } + } + } +} From dde8440334b908309ca9b099c8edfd391491389e Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:32:21 +0200 Subject: [PATCH 13/35] Assert streaming writer memory is flat past zip block saturation --- .../Xlsx/Streaming/StreamingMemoryTest.php | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php index 4c519e8c5b..5eaeb93464 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php @@ -10,11 +10,22 @@ class StreamingMemoryTest extends TestCase { - public function testMemoryStaysFlat(): void + public function testMemoryIsIndependentOfRowCount(): void { if (!function_exists('memory_reset_peak_usage')) { self::markTestSkipped('memory_reset_peak_usage requires PHP 8.2'); } + // ZipStream v3 reads sheet XML in fixed 16MB blocks, so the close()-time + // peak saturates once the sheet exceeds one block (~100k rows here). + // Doubling the rows past saturation must not raise the peak further. + $atSaturation = $this->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 { @@ -22,16 +33,13 @@ public function testMemoryStaysFlat(): void $sheet = $writer->startSheet('Big'); memory_reset_peak_usage(); $before = memory_get_peak_usage(true); - for ($row = 1; $row <= 100000; ++$row) { + for ($row = 1; $row <= $rows; ++$row) { $sheet->appendRow(['row ' . $row, $row, $row * 1.5, $row % 2 === 0]); } $writer->close(); - $peakDelta = memory_get_peak_usage(true) - $before; - // 100k rows x 4 cells at ~1KB/cell would need ~400MB in the - // standard model; the streaming writer must stay under 24MB - // (temp stream spill threshold + zip deflate buffers). - self::assertLessThan(24 * 1024 * 1024, $peakDelta); self::assertGreaterThan(0, filesize($file)); + + return memory_get_peak_usage(true) - $before; } finally { if (file_exists($file)) { unlink($file); From 9b2c4a37b7c6d6d624e0b928efe384e37011f248 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:36:22 +0200 Subject: [PATCH 14/35] Add streaming writer documentation --- CHANGELOG.md | 2 +- docs/topics/streaming-writer.md | 192 ++++++++++++++++++++++++++++++++ 2 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 docs/topics/streaming-writer.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 024d2ff29b..54eccf31c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a ### Added -- Nothing yet. +- Streaming Xlsx writer for large, append-only exports with memory use independent of row count. (PR pending) ### Removed diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md new file mode 100644 index 0000000000..839578d67f --- /dev/null +++ b/docs/topics/streaming-writer.md @@ -0,0 +1,192 @@ +# 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'); + +// Layout must be set before the first appendRow() call. +$sheet->setColumnWidths([1 => 30.0, 2 => 15.0, 3 => 12.0]); +$sheet->freezePane('A2'); +$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. +$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. + +Any `DateTimeInterface` value that has no explicit style gets a default +date number format automatically. + +## API + +### `StreamingWriter` + +- `__construct(string $filename)` — opens `$filename` for writing. +- `startSheet(string $name): StreamingSheet` — finishes the current sheet, + if any, and starts a new one. +- `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()`. +- `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 `=`) or is a date. + +### Supported cell values + +- Strings +- Integers and floats +- Booleans +- `DateTimeInterface` instances +- Formula strings (any string starting with `=`) +- `null` (leaves the cell empty) + +Any other value type throws a `Writer\Exception`. + +## 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. +- A failed `appendRow()` call, for example one that throws because of an + unsupported value or an unregistered style id, invalidates the file + being written. Do not catch the exception and continue writing to the + same writer; discard it and start again. From 607f0f723db71baf56852e8c6028bfd99d723b3c Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 12:57:46 +0200 Subject: [PATCH 15/35] Fix new phpstan errors in streaming writer --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 9 ++++++- .../Writer/Xlsx/Streaming/StreamingWriter.php | 1 + .../Xlsx/Streaming/StreamingMemoryTest.php | 2 +- .../Xlsx/Streaming/StreamingWriterTest.php | 26 ++++++++++++++----- 4 files changed, 30 insertions(+), 8 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 7672cd523f..385f694e4c 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -111,6 +111,7 @@ private function assertUsable(): void } } + /** @param mixed[] $cells */ public function appendRow(array $cells, ?int $styleId = null): void { $this->assertUsable(); @@ -134,7 +135,13 @@ public function appendRow(array $cells, ?int $styleId = null): void } $this->maxColumn = max($this->maxColumn, $column); $xmlWriter->endElement(); // row - fwrite($this->stream, $xmlWriter->flush()); + $flushed = $xmlWriter->flush(); + if (!is_string($flushed)) { + // @codeCoverageIgnoreStart + throw new WriterException('Unexpected non-string result from XMLWriter::flush().'); + // @codeCoverageIgnoreEnd + } + fwrite($this->stream, $flushed); } private function writeCell(int $column, mixed $value, ?int $rowStyleId): void diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 5df211a5bd..2458e7e860 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -63,6 +63,7 @@ public function startSheet(string $name): StreamingSheet return $this->activeSheet; } + /** @param mixed[] $styleArray */ public function registerStyle(array $styleArray): int { $this->assertNotClosed(); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php index 5eaeb93464..f338fd3dc9 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php @@ -31,7 +31,7 @@ private function measurePeak(int $rows): int try { $writer = new StreamingWriter($file); $sheet = $writer->startSheet('Big'); - memory_reset_peak_usage(); + memory_reset_peak_usage(); // @phpstan-ignore-line function.notFound (requires PHP 8.2, guarded by caller) $before = memory_get_peak_usage(true); for ($row = 1; $row <= $rows; ++$row) { $sheet->appendRow(['row ' . $row, $row, $row * 1.5, $row % 2 === 0]); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 6adfcb185a..1f33fee7b7 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx\Streaming; +use DateTimeImmutable; use PhpOffice\PhpSpreadsheet\Cell\DataType; use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Shared\File; @@ -12,6 +13,9 @@ use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamedCell; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; use PHPUnit\Framework\TestCase; +use RuntimeException; +use stdClass; +use Stringable; class StreamingWriterTest extends TestCase { @@ -36,6 +40,16 @@ private function tempFile(): string 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(); @@ -61,14 +75,14 @@ public function testScalarRowsRoundTrip(): void $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', (string) $worksheet->getCell('A1')->getValue()); - self::assertSame('Ärger & ', (string) $worksheet->getCell('A2')->getValue()); + 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 ', (string) $worksheet->getCell('C3')->getValue()); + self::assertSame(' padded ', self::stringValue($worksheet->getCell('C3')->getValue())); } public function testUnsupportedValueThrows(): void @@ -77,7 +91,7 @@ public function testUnsupportedValueThrows(): void $writer = new StreamingWriter($file); $sheet = $writer->startSheet('Data'); $this->expectException(WriterException::class); - $sheet->appendRow([new \stdClass()]); + $sheet->appendRow([new stdClass()]); } public function testFormulaRoundTrip(): void @@ -93,7 +107,7 @@ public function testFormulaRoundTrip(): void $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', (string) $worksheet->getCell('A3')->getValue()); + self::assertSame('=not a formula', self::stringValue($worksheet->getCell('A3')->getValue())); } public function testDateTimeRoundTrip(): void @@ -101,7 +115,7 @@ 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')]); + $sheet->appendRow([new DateTimeImmutable('2026-01-02 03:04:05')]); $writer->close(); $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); From 518c2ff17ea25c1aee44323bf024ce2092b33657 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 13:34:48 +0200 Subject: [PATCH 16/35] Fix always-on forceFullCalc and add streaming writer failure cleanup close() forced fullCalcOnLoad/forceFullCalc unconditionally regardless of whether any formula was written. Tie it to hasFormulas instead. Also clean up on failure: open the output file in 'wb' (was 'wb+'), track its path, and on a zero-sheet close(), a close() that throws mid-write, or destruction without a close(), fclose the handle(s) and unlink the partial output file instead of leaving a truncated file behind. --- .../Writer/Xlsx/Streaming/StreamingWriter.php | 55 ++++++++++++++++--- 1 file changed, 46 insertions(+), 9 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 2458e7e860..c76aaf1ce1 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -11,12 +11,15 @@ use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx as XlsxWriter; use PhpOffice\PhpSpreadsheet\Writer\ZipStream0; +use Throwable; class StreamingWriter { /** @var resource */ private $fileHandle; + private string $filename; + private Spreadsheet $shell; private XlsxWriter $partWriter; @@ -37,18 +40,27 @@ class StreamingWriter public function __construct(string $filename) { try { - $fileHandle = fopen($filename, 'wb+'); + $fileHandle = fopen($filename, 'wb'); } catch (Exception) { throw new WriterException("Could not open file $filename for writing."); } 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(); @@ -78,12 +90,15 @@ 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->finishActiveSheet(); $this->closed = true; try { + $this->finishActiveSheet(); $zip = ZipStream0::newZipStream($this->fileHandle); $partWriter = $this->partWriter; $partWriter->createStyleDictionaries(); @@ -95,19 +110,21 @@ public function close(): void $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)); - $zip->addFile('xl/workbook.xml', $partWriter->getWriterPartWorkbook()->writeWorkbook($this->shell, false, $this->hasFormulas ? true : null)); + $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(); - } finally { - foreach ($this->finishedSheets as $finishedSheet) { - fclose($finishedSheet['stream']); - } - $this->finishedSheets = []; - fclose($this->fileHandle); + } catch (Throwable $e) { + $this->closeSheetStreams(); + $this->closeFileHandleAndUnlink(); + + throw $e; } + + $this->closeSheetStreams(); + fclose($this->fileHandle); } public function isStyleIdRegistered(int $styleId): bool @@ -145,4 +162,24 @@ private function assertNotClosed(): void 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); + } + } } From 64f2682990d87b43c0256143dad2e64a34122a0e Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 13:34:57 +0200 Subject: [PATCH 17/35] Guard streaming sheet against corruption from a failed appendRow() If appendRow() throws mid-row (unsupported value, bad style id, etc.) the XMLWriter buffer was left holding an unclosed ; the next appendRow() call would nest a new row inside it, silently corrupting the sheet. Catch any Throwable, discard the buffered XML, and mark the sheet broken. Once broken, assertUsable() rejects further appendRow() and finish() calls with a Writer\Exception instead of writing the file as if nothing happened. Also reject an unsupported StreamedCell $dataType (only TYPE_STRING and TYPE_STRING2 are supported) and invalid setColumnWidths() input (column numbers below 1, non-positive widths), both of which were silently accepted before and produced invalid or ignored output. --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 67 +++++++++++++------ 1 file changed, 45 insertions(+), 22 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 385f694e4c..c8101dae2d 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -12,6 +12,7 @@ use PhpOffice\PhpSpreadsheet\Shared\StringHelper; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\FunctionPrefix; +use Throwable; use XMLWriter; class StreamingSheet @@ -27,6 +28,8 @@ class StreamingSheet private bool $finished = false; + private bool $broken = false; + private int $rowNumber = 0; private int $maxColumn = 0; @@ -106,6 +109,9 @@ private function writeHeader(): void 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().'); } @@ -118,30 +124,38 @@ public function appendRow(array $cells, ?int $styleId = null): void if ($styleId !== null) { $this->assertStyleId($styleId); } - 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; + + try { + if (!$this->headerWritten) { + $this->writeHeader(); } - $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 + ++$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 + + throw $e; } - fwrite($this->stream, $flushed); } private function writeCell(int $column, mixed $value, ?int $rowStyleId): void @@ -154,6 +168,9 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void $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; @@ -231,6 +248,12 @@ 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; } } From 0482153ee52fae586a6ff021417d2150afa83ba2 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 13:35:03 +0200 Subject: [PATCH 18/35] Add tests for the streaming writer fix wave Covers: calcPr reflecting formula presence, a multi-sheet round trip with distinct data per sheet, rejection of invalid setColumnWidths() input and unsupported StreamedCell data types, the broken-sheet state left by a failed appendRow(), and file cleanup on destruction without close() and on a close() that fails. --- .../Xlsx/Streaming/StreamingErrorsTest.php | 59 ++++++++++ .../Xlsx/Streaming/StreamingWriterTest.php | 107 ++++++++++++++++++ 2 files changed, 166 insertions(+) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php index 34b0daeae5..6560df1dfc 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -8,6 +8,7 @@ use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; use PHPUnit\Framework\TestCase; +use stdClass; class StreamingErrorsTest extends TestCase { @@ -99,4 +100,62 @@ public function testRegisterStyleAfterCloseThrows(): void $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); + } } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 1f33fee7b7..f4a39bc866 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -16,6 +16,7 @@ use RuntimeException; use stdClass; use Stringable; +use ZipArchive; class StreamingWriterTest extends TestCase { @@ -174,6 +175,82 @@ public function testSheetFeaturesRoundTrip(): void 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(); @@ -193,4 +270,34 @@ public function testFreezePaneAfterFirstRowThrows(): void $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)]); + } } From eda22b559ae42f6f6bae151d0475bb9ccd8a9d09 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 13:35:08 +0200 Subject: [PATCH 19/35] Correct streaming writer documentation - StreamedCell cannot force a date value to a string; is_scalar() rejects it. - A lone "=" (length 1) is written as a plain string, not a formula. - setAutoFilterToWrittenRange() may be called any time before the sheet finishes, not only before the first appendRow() like column widths and the freeze pane. - freezePane('A1') is a no-op. - Each finished sheet keeps up to ~2MB of buffered XML in memory until close(), so memory at close() scales with sheet count, not row count. --- docs/topics/streaming-writer.md | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md index 839578d67f..90233b447d 100644 --- a/docs/topics/streaming-writer.md +++ b/docs/topics/streaming-writer.md @@ -55,9 +55,13 @@ $moneyStyle = $writer->registerStyle(['numberFormat' => ['formatCode' => '#,##0. $sheet = $writer->startSheet('Orders'); -// Layout must be set before the first appendRow() call. +// 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. @@ -70,6 +74,8 @@ $sheet->appendRow([ ]); // 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 @@ -83,11 +89,20 @@ $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. +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 a no-op; freezing at the top-left cell freezes +nothing. + +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` @@ -122,7 +137,10 @@ 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 `=`) or is a date. +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 From 4c2933c0e6973d9dd894ecdf5d52e3f0a298bacc Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 13:35:12 +0200 Subject: [PATCH 20/35] Add streaming writer benchmark scripts --- tests/Benchmark/README.md | 53 ++++++++++++++++++ tests/Benchmark/bench.php | 109 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 tests/Benchmark/README.md create mode 100644 tests/Benchmark/bench.php 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)); From 1aff9f7797844f2478dc49b9ac78c8ede3d2b177 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 14:35:16 +0200 Subject: [PATCH 21/35] Link changelog entry to PR 4966 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54eccf31c1..12e0bb2c42 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,7 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org). Thia is a ### Added -- Streaming Xlsx writer for large, append-only exports with memory use independent of row count. (PR pending) +- 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 From 84c041440cb36f66e4732a8743609d9a2ba77b32 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 14:47:40 +0200 Subject: [PATCH 22/35] Reject non-finite numbers, invalid UTF-8 and over-limit strings --- docs/topics/streaming-writer.md | 7 ++- .../Writer/Xlsx/Streaming/StreamingSheet.php | 12 ++++ .../Xlsx/Streaming/StreamingWriterTest.php | 62 +++++++++++++++++++ 3 files changed, 80 insertions(+), 1 deletion(-) diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md index 90233b447d..85870649e2 100644 --- a/docs/topics/streaming-writer.md +++ b/docs/topics/streaming-writer.md @@ -151,7 +151,12 @@ be forced to string. - Formula strings (any string starting with `=`) - `null` (leaves the cell empty) -Any other value type throws a `Writer\Exception`. +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 diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index c8101dae2d..c2c1e2d7f3 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -201,8 +201,14 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void $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', (string) $value); } elseif (is_string($value)) { + if (!StringHelper::isUTF8($value)) { + throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.'); + } if (strlen($value) > 1 && $value[0] === '=') { $this->writer->noteFormulaWritten(); $xmlWriter->startElement('f'); @@ -219,6 +225,12 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void 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'); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index f4a39bc866..f6971f028a 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -300,4 +300,66 @@ public function testUnsupportedStreamedCellDataTypeThrows(): void $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, (string) $worksheet->getCell('A1')->getValue()); + } } From 0adaab1f404cf411b73d8942efcc6b026aeb1fa5 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 14:51:53 +0200 Subject: [PATCH 23/35] Cover StreamedCell null, forced-string UTF-8 and freeze pane no-op paths --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 6 ++-- .../Xlsx/Streaming/StreamingWriterTest.php | 35 +++++++++++++++++++ 2 files changed, 38 insertions(+), 3 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index c2c1e2d7f3..6cf8ad0604 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -206,10 +206,10 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void } $xmlWriter->writeElement('v', (string) $value); } elseif (is_string($value)) { - if (!StringHelper::isUTF8($value)) { - throw new WriterException('Cell value is not valid UTF-8; writing it would corrupt the sheet XML.'); - } 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)); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index f6971f028a..1119129a5f 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -362,4 +362,39 @@ public function testStringAtExcelLimitRoundTrips(): void $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); self::assertSame($value, (string) $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', (string) $worksheet->getCell('B1')->getValue()); + } + + 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()); + } } From 2a9b6a8214217fe248cc6a46e2c7a60c331ff5b0 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 15:01:51 +0200 Subject: [PATCH 24/35] Surface close() failures as writer exceptions and guard the output handle --- .../Writer/Xlsx/Streaming/StreamingWriter.php | 9 +++- .../Xlsx/Streaming/StreamingErrorsTest.php | 42 +++++++++++++++++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index c76aaf1ce1..cf879cb513 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -98,6 +98,10 @@ public function close(): void $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; @@ -119,8 +123,11 @@ public function close(): void } catch (Throwable $e) { $this->closeSheetStreams(); $this->closeFileHandleAndUnlink(); + if ($e instanceof WriterException) { + throw $e; + } - throw $e; + throw new WriterException('Failed to write the Xlsx file: ' . $e->getMessage(), 0, $e); } $this->closeSheetStreams(); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php index 6560df1dfc..d2b56ff7c0 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -6,8 +6,10 @@ use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; +use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingSheet; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingWriter; use PHPUnit\Framework\TestCase; +use ReflectionProperty; use stdClass; class StreamingErrorsTest extends TestCase @@ -158,4 +160,44 @@ public function testCloseWithoutSheetsRemovesFile(): void } 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 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); + } } From 56816c2a8d3fa96c6d794d3767c723d3aca13963 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:16:25 +0200 Subject: [PATCH 25/35] Use stringValue helper instead of casting mixed in streaming tests --- .../Writer/Xlsx/Streaming/StreamingWriterTest.php | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 1119129a5f..cd161bf068 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -360,7 +360,7 @@ public function testStringAtExcelLimitRoundTrips(): void $writer->close(); $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); - self::assertSame($value, (string) $worksheet->getCell('A1')->getValue()); + self::assertSame($value, self::stringValue($worksheet->getCell('A1')->getValue())); } public function testInvalidUtf8ForcedStringThrows(): void @@ -382,7 +382,7 @@ public function testStreamedCellWithNullValueLeavesCellEmpty(): void $worksheet = (new XlsxReader())->load($file)->getSheetByNameOrThrow('Data'); self::assertNull($worksheet->getCell('A1')->getValue()); - self::assertSame('b', (string) $worksheet->getCell('B1')->getValue()); + self::assertSame('b', self::stringValue($worksheet->getCell('B1')->getValue())); } public function testFreezePaneA1IsANoOp(): void From 275731cdf9c1b9c107985ed1bd9a9e76f26b150a Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:17:22 +0200 Subject: [PATCH 26/35] Validate sheet names in startSheet before mutating the shell workbook --- .../Writer/Xlsx/Streaming/StreamingWriter.php | 15 ++++- .../Xlsx/Streaming/StreamingErrorsTest.php | 61 ++++++++++++++++++- 2 files changed, 74 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index cf879cb513..c22652c307 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -64,11 +64,24 @@ public function __destruct() 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(); - $shellSheet->setTitle($name); + + 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); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php index d2b56ff7c0..8ecc484535 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -4,6 +4,7 @@ namespace PhpOffice\PhpSpreadsheetTests\Writer\Xlsx\Streaming; +use PhpOffice\PhpSpreadsheet\Reader\Xlsx as XlsxReader; use PhpOffice\PhpSpreadsheet\Shared\File; use PhpOffice\PhpSpreadsheet\Writer\Exception as WriterException; use PhpOffice\PhpSpreadsheet\Writer\Xlsx\Streaming\StreamingSheet; @@ -11,6 +12,7 @@ use PHPUnit\Framework\TestCase; use ReflectionProperty; use stdClass; +use ZipArchive; class StreamingErrorsTest extends TestCase { @@ -90,10 +92,67 @@ public function testAppendAfterCloseThrows(): void public function testInvalidSheetNameThrows(): void { $writer = new StreamingWriter($this->tempFile()); - $this->expectException(\PhpOffice\PhpSpreadsheet\Exception::class); + $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()); From 4c2d3460607ef68f542121fcf79d6734b17aee8a Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:18:19 +0200 Subject: [PATCH 27/35] Match standard writer numeric serialization in streaming sheets --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 18 ++++++++++++++++-- .../Xlsx/Streaming/StreamingWriterTest.php | 17 +++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 6cf8ad0604..78a76a6303 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -196,7 +196,7 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void if ($excelDate === false) { $this->rejectValue($value); // @codeCoverageIgnore } - $xmlWriter->writeElement('v', (string) $excelDate); + $xmlWriter->writeElement('v', self::formatNumber($excelDate)); } elseif (is_bool($value)) { $xmlWriter->writeAttribute('t', 'b'); $xmlWriter->writeElement('v', $value ? '1' : '0'); @@ -204,7 +204,7 @@ private function writeCell(int $column, mixed $value, ?int $rowStyleId): void 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', (string) $value); + $xmlWriter->writeElement('v', self::formatNumber($value)); } elseif (is_string($value)) { if (strlen($value) > 1 && $value[0] === '=') { if (!StringHelper::isUTF8($value)) { @@ -243,6 +243,20 @@ private function writeInlineString(string $value): void $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) . '.'); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index cd161bf068..603f2bd922 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -86,6 +86,23 @@ public function testScalarRowsRoundTrip(): void 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(); From a7e53dee4a973d65d10f9f9165b66906f922121c Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:19:02 +0200 Subject: [PATCH 28/35] Wrap unexpected appendRow failures in writer exceptions --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 5 ++++- .../Xlsx/Streaming/StreamingErrorsTest.php | 19 +++++++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 78a76a6303..47760deb3b 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -153,8 +153,11 @@ public function appendRow(array $cells, ?int $styleId = null): void } catch (Throwable $e) { $this->broken = true; $this->xmlWriter->flush(); // discard the unclosed left behind by the failure + if ($e instanceof WriterException) { + throw $e; + } - throw $e; + throw new WriterException('Failed to write the row: ' . $e->getMessage(), 0, $e); } } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php index 8ecc484535..4ad39fbc30 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -239,6 +239,25 @@ public function testCloseWithInvalidFileHandleThrows(): void self::assertFileDoesNotExist($file); } + 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(); From 851a3f044ffb48f6b784705f3b0c0c49465c0c67 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:20:04 +0200 Subject: [PATCH 29/35] Validate and normalize the freeze pane cell eagerly --- .../Writer/Xlsx/Streaming/StreamingSheet.php | 14 +++++++++-- .../Xlsx/Streaming/StreamingErrorsTest.php | 25 +++++++++++++++++++ .../Xlsx/Streaming/StreamingWriterTest.php | 13 ++++++++++ 3 files changed, 50 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php index 47760deb3b..cabe22a200 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingSheet.php @@ -290,10 +290,20 @@ public function setColumnWidths(array $widths): void public function freezePane(string $cell): void { $this->assertBeforeFirstRow('freezePane'); - if ($cell === 'A1') { + + 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 = $cell; + $this->freezeCell = $normalized; } public function setAutoFilterToWrittenRange(): void diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php index 4ad39fbc30..850947416b 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingErrorsTest.php @@ -239,6 +239,31 @@ public function testCloseWithInvalidFileHandleThrows(): void 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()); diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 603f2bd922..11b41b1009 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -402,6 +402,19 @@ public function testStreamedCellWithNullValueLeavesCellEmpty(): void 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(); From da10704dae4a7226ebc6baf5bfa1789e62530947 Mon Sep 17 00:00:00 2001 From: kemo Date: Tue, 18 Aug 2026 17:20:52 +0200 Subject: [PATCH 30/35] Document streaming writer name rules, binder-free values, and cleanup --- docs/topics/streaming-writer.md | 30 ++++++++++++++++++++++++------ 1 file changed, 24 insertions(+), 6 deletions(-) diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md index 85870649e2..03ab629d2a 100644 --- a/docs/topics/streaming-writer.md +++ b/docs/topics/streaming-writer.md @@ -109,7 +109,11 @@ with the number of sheets, not with the number of rows in any one sheet. - `__construct(string $filename)` — opens `$filename` for writing. - `startSheet(string $name): StreamingSheet` — finishes the current sheet, - if any, and starts a new one. + 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 @@ -125,7 +129,9 @@ with the number of sheets, not with the number of rows in any one sheet. 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()`. + 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. @@ -151,6 +157,10 @@ be forced to string. - 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: @@ -209,7 +219,15 @@ The streaming writer is append-only and forward-only. It does not support: 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. -- A failed `appendRow()` call, for example one that throws because of an - unsupported value or an unregistered style id, invalidates the file - being written. Do not catch the exception and continue writing to the - same writer; discard it and start again. +- 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. From 29a5315fe6e6ffb2f7928795279be77a1bd128af Mon Sep 17 00:00:00 2001 From: kemo Date: Wed, 19 Aug 2026 12:58:12 +0200 Subject: [PATCH 31/35] Guard memory_reset_peak_usage with function_exists instead of a phpstan ignore --- .../Writer/Xlsx/Streaming/StreamingMemoryTest.php | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php index f338fd3dc9..193df7fceb 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingMemoryTest.php @@ -31,7 +31,9 @@ private function measurePeak(int $rows): int try { $writer = new StreamingWriter($file); $sheet = $writer->startSheet('Big'); - memory_reset_peak_usage(); // @phpstan-ignore-line function.notFound (requires PHP 8.2, guarded by caller) + 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]); From 2badc79ac9b9341c6bc146b3b2a5faf1b4a23c2c Mon Sep 17 00:00:00 2001 From: kemo Date: Wed, 26 Aug 2026 13:55:29 +0200 Subject: [PATCH 32/35] Use the unambiguous datetime format for default date styles --- src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php | 2 +- .../Writer/Xlsx/Streaming/StreamingWriterTest.php | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index c22652c307..cef6b59250 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -156,7 +156,7 @@ public function getDefaultDateStyleId(): int { if ($this->defaultDateStyleId === null) { $this->defaultDateStyleId = $this->registerStyle([ - 'numberFormat' => ['formatCode' => NumberFormat::FORMAT_DATE_DATETIME], + 'numberFormat' => ['formatCode' => NumberFormat::FORMAT_DATE_DATETIME_BETTER], ]); } diff --git a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php index 11b41b1009..92080bb6e9 100644 --- a/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php +++ b/tests/PhpSpreadsheetTests/Writer/Xlsx/Streaming/StreamingWriterTest.php @@ -140,7 +140,7 @@ public function testDateTimeRoundTrip(): void $cell = $worksheet->getCell('A1'); self::assertEqualsWithDelta(46024.12783564815, $cell->getValue(), 1E-8); self::assertSame( - NumberFormat::FORMAT_DATE_DATETIME, + NumberFormat::FORMAT_DATE_DATETIME_BETTER, $worksheet->getStyle('A1')->getNumberFormat()->getFormatCode() ); } From 822057f16343d98cbe386fb36c98530ee943ce1b Mon Sep 17 00:00:00 2001 From: kemo Date: Wed, 26 Aug 2026 13:55:40 +0200 Subject: [PATCH 33/35] Collapse duplicate constructor throw into the false-handle check --- src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index cef6b59250..1c7f4e4598 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -42,7 +42,7 @@ public function __construct(string $filename) try { $fileHandle = fopen($filename, 'wb'); } catch (Exception) { - throw new WriterException("Could not open file $filename for writing."); + $fileHandle = false; } if ($fileHandle === false) { throw new WriterException("Could not open file $filename for writing."); From 729d1b03fc28746bc0329c8763075ccee2c5aca0 Mon Sep 17 00:00:00 2001 From: kemo Date: Wed, 26 Aug 2026 14:00:26 +0200 Subject: [PATCH 34/35] Explain the forceFullCalc argument at the workbook write site --- src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php index 1c7f4e4598..15f5e6eb01 100644 --- a/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php +++ b/src/PhpSpreadsheet/Writer/Xlsx/Streaming/StreamingWriter.php @@ -127,6 +127,8 @@ public function close(): void $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']); From 7f675875f6c58ea2fa0a502bc702ae8e500fb3b4 Mon Sep 17 00:00:00 2001 From: kemo Date: Wed, 26 Aug 2026 14:00:26 +0200 Subject: [PATCH 35/35] Explain why freezePane at A1 writes no pane --- docs/topics/streaming-writer.md | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/docs/topics/streaming-writer.md b/docs/topics/streaming-writer.md index 03ab629d2a..3040a3bedc 100644 --- a/docs/topics/streaming-writer.md +++ b/docs/topics/streaming-writer.md @@ -95,8 +95,11 @@ 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 a no-op; freezing at the top-left cell freezes -nothing. +`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