diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3d3736d..b64deac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -23,7 +23,7 @@ jobs: uses: shivammathur/setup-php@v2 with: php-version: ${{ matrix.php-versions }} - extensions: sockets, json, curl + extensions: sockets, json, curl, zip - name: Install dependencies with composer run: composer install diff --git a/.mddoc.xml.dist b/.mddoc.xml.dist index 5413a32..aa1f95c 100644 --- a/.mddoc.xml.dist +++ b/.mddoc.xml.dist @@ -13,6 +13,7 @@ A Streamed Data Export Tool Supported formats: - CSV / TSV - SpreadsheetML "Excel 2004 XML Spreadsheet" +- XLSX "Office Open XML Spreadsheet" - More to come. ]]> diff --git a/README.md b/README.md index de965e3..8c7dbb5 100644 --- a/README.md +++ b/README.md @@ -11,6 +11,7 @@ A Streamed Data Export Tool Supported formats: - CSV / TSV - SpreadsheetML "Excel 2004 XML Spreadsheet" +- XLSX "Office Open XML Spreadsheet" - More to come. @@ -375,10 +376,29 @@ function setCreatedTime(?int $createdTime) : void - ***int*** | ***null*** `$createdTime` - The timestamp to use for the created time. If null, the current time will be used. +### Class: \Quorum\Exporter\Engines\XlsxEngine + +Writes a minimal Office Open XML spreadsheet (XLSX) archive. + +This intentionally mirrors SpreadsheetMLEngine: values are either numbers or +strings, empty values leave gaps in their rows, and multiline strings wrap. + +--- + +#### Method: XlsxEngine->setCreatedTime + +```php +function setCreatedTime(?int $createdTime) : void +``` + +##### Parameters: + +- ***int*** | ***null*** `$createdTime` - The timestamp to use for the created time. If null, the current time will be used. + ### Class: \Quorum\Exporter\Exceptions\ExportException ### Class: \Quorum\Exporter\Exceptions\InvalidDataTypeException ### Class: \Quorum\Exporter\Exceptions\OutputException -### Class: \Quorum\Exporter\Exceptions\WritableException \ No newline at end of file +### Class: \Quorum\Exporter\Exceptions\WritableException diff --git a/composer.json b/composer.json index e18f0c8..d18df2b 100644 --- a/composer.json +++ b/composer.json @@ -10,6 +10,7 @@ "php": ">=7.4" }, "require-dev": { + "ext-zip": "*", "donatj/drop": "*", "phpunit/phpunit": "4.* | ~8.5 | ^9.0", "friendsofphp/php-cs-fixer": "^3.16", diff --git a/src/Engines/XlsxEngine.php b/src/Engines/XlsxEngine.php new file mode 100644 index 0000000..ad32653 --- /dev/null +++ b/src/Engines/XlsxEngine.php @@ -0,0 +1,336 @@ + */ + protected array $worksheetData = []; + + protected int $autoIndex = 1; + + protected ?int $createdTime = null; + + public function processSheet( DataSheet $sheet ) : void { + $outputStream = fopen('php://temp', 'r+'); + $worksheet = $this->newDocument(); + $worksheetRoot = $worksheet->createElementNS(self::SPREADSHEET_NAMESPACE, 'worksheet'); + $worksheet->appendChild($worksheetRoot); + $sheetData = $this->appendElement($worksheet, $worksheetRoot, self::SPREADSHEET_NAMESPACE, 'sheetData'); + $this->appendElement($worksheet, $sheetData, self::SPREADSHEET_NAMESPACE, 'Replace_This_Element_With_Rows'); + $documentParts = preg_split('%(?:){1,2}%', $worksheet->saveXML()); + fwrite($outputStream, $documentParts[0]); + + $rowIndex = 1; + foreach( $sheet as $dataRow ) { + $rowDocument = $this->newDocument(); + $row = $rowDocument->createElementNS(self::SPREADSHEET_NAMESPACE, 'row'); + $row->setAttribute('r', (string)$rowIndex); + $rowDocument->appendChild($row); + + $columnIndex = 0; + foreach( $dataRow as $value ) { + if( $this->not_null($value) ) { + $cellReference = $this->columnReference($columnIndex) . $rowIndex; + $cell = $this->appendElement($rowDocument, $row, self::SPREADSHEET_NAMESPACE, 'c', [ + 'r' => $cellReference, + 't' => is_numeric($value) ? 'n' : 'inlineStr', + ]); + + if( is_numeric($value) ) { + $cellValue = $this->appendElement($rowDocument, $cell, self::SPREADSHEET_NAMESPACE, 'v'); + $cellValue->appendChild($rowDocument->createTextNode((string)$value)); + } else { + if( strpbrk((string)$value, "\r\n") !== false ) { + $cell->setAttribute('s', '1'); + } + + $inlineString = $this->appendElement($rowDocument, $cell, self::SPREADSHEET_NAMESPACE, 'is'); + $text = $this->appendElement($rowDocument, $inlineString, self::SPREADSHEET_NAMESPACE, 't'); + $text->setAttributeNS(self::XML_NAMESPACE, 'xml:space', 'preserve'); + $text->appendChild($rowDocument->createTextNode((string)$value)); + } + } + + $columnIndex++; + } + + fwrite($outputStream, $rowDocument->saveXML($row)); + $rowIndex++; + } + + fwrite($outputStream, end($documentParts)); + + $this->worksheetData[] = [ + 'name' => $sheet->getName() ?: 'Sheet' . ($this->autoIndex++), + 'stream' => $outputStream, + ]; + } + + /** + * @throws OutputException + */ + public function outputToStream( $outputStream ) : void { + $options = new Archive; + $options->setOutputStream($outputStream); + $zip = new ZipStream('export.xlsx', $options); + + $zip->addFile('[Content_Types].xml', $this->contentTypesXml()); + $zip->addFile('_rels/.rels', $this->rootRelationshipsXml()); + $zip->addFile('docProps/core.xml', $this->corePropertiesXml()); + $zip->addFile('xl/workbook.xml', $this->workbookXml()); + $zip->addFile('xl/_rels/workbook.xml.rels', $this->workbookRelationshipsXml()); + $zip->addFile('xl/styles.xml', $this->stylesXml()); + + foreach( $this->worksheetData as $index => $sheetData ) { + rewind($sheetData['stream']); + $zip->addFileFromStream('xl/worksheets/sheet' . ($index + 1) . '.xml', $sheetData['stream']); + } + + try { + $zip->finish(); + }catch( OverflowException $exception ) { + throw new OutputException('Zip Overflow', $exception->getCode(), $exception); + } + } + + private function not_null( $value ) : bool { + if( is_array($value) ) { + return sizeof($value) > 0; + } + + return (is_string($value) || is_int($value)) && ($value != '') && ($value != 'NULL') && (strlen(trim($value)) > 0); + } + + private function columnReference( int $columnIndex ) : string { + $reference = ''; + $columnIndex++; + + while( $columnIndex > 0 ) { + $remainder = ($columnIndex - 1) % 26; + $reference = chr(65 + $remainder) . $reference; + $columnIndex = intdiv($columnIndex - 1, 26); + } + + return $reference; + } + + private function newDocument() : \DOMDocument { + return new \DOMDocument('1.0', 'UTF-8'); + } + + /** + * @param array $attributes + */ + private function appendElement( + \DOMDocument $document, + \DOMNode $parent, + string $namespace, + string $name, + array $attributes = [] + ) : \DOMElement { + $element = $document->createElementNS($namespace, $name); + foreach( $attributes as $attribute => $value ) { + $element->setAttribute($attribute, $value); + } + + $parent->appendChild($element); + + return $element; + } + + private function contentTypesXml() : string { + $document = $this->newDocument(); + $types = $document->createElementNS(self::CONTENT_TYPES_NAMESPACE, 'Types'); + $document->appendChild($types); + + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Default', [ + 'Extension' => 'rels', + 'ContentType' => 'application/vnd.openxmlformats-package.relationships+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Default', [ + 'Extension' => 'xml', + 'ContentType' => 'application/xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/docProps/core.xml', + 'ContentType' => 'application/vnd.openxmlformats-package.core-properties+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/workbook.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml', + ]); + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/styles.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml', + ]); + + foreach( $this->worksheetData as $index => $sheetData ) { + $this->appendElement($document, $types, self::CONTENT_TYPES_NAMESPACE, 'Override', [ + 'PartName' => '/xl/worksheets/sheet' . ($index + 1) . '.xml', + 'ContentType' => 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml', + ]); + } + + return $document->saveXML(); + } + + private function rootRelationshipsXml() : string { + $document = $this->newDocument(); + $relationships = $document->createElementNS(self::RELATIONSHIPS_NAMESPACE, 'Relationships'); + $document->appendChild($relationships); + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId1', + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/officeDocument', + 'Target' => 'xl/workbook.xml', + ]); + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId2', + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/metadata/core-properties', + 'Target' => 'docProps/core.xml', + ]); + + return $document->saveXML(); + } + + private function corePropertiesXml() : string { + $document = $this->newDocument(); + $coreProperties = $document->createElementNS(self::CORE_PROPERTIES_NAMESPACE, 'cp:coreProperties'); + $coreProperties->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:dcterms', self::DCTERMS_NAMESPACE); + $coreProperties->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:xsi', self::XML_SCHEMA_INSTANCE_NAMESPACE); + $document->appendChild($coreProperties); + + $createdTime = gmdate('Y-m-d\TH:i:s\Z', $this->createdTime ?: time()); + foreach( [ 'created', 'modified' ] as $property ) { + $date = $this->appendElement($document, $coreProperties, self::DCTERMS_NAMESPACE, 'dcterms:' . $property); + $date->setAttributeNS(self::XML_SCHEMA_INSTANCE_NAMESPACE, 'xsi:type', 'dcterms:W3CDTF'); + $date->appendChild($document->createTextNode($createdTime)); + } + + return $document->saveXML(); + } + + private function workbookXml() : string { + $document = $this->newDocument(); + $workbook = $document->createElementNS(self::SPREADSHEET_NAMESPACE, 'workbook'); + $workbook->setAttributeNS('http://www.w3.org/2000/xmlns/', 'xmlns:r', self::WORKBOOK_RELATIONSHIPS_NAMESPACE); + $document->appendChild($workbook); + $sheets = $this->appendElement($document, $workbook, self::SPREADSHEET_NAMESPACE, 'sheets'); + + foreach( $this->worksheetData as $index => $sheetData ) { + $sheetIndex = $index + 1; + $sheet = $this->appendElement($document, $sheets, self::SPREADSHEET_NAMESPACE, 'sheet', [ + 'name' => $sheetData['name'], + 'sheetId' => (string)$sheetIndex, + ]); + $sheet->setAttributeNS(self::WORKBOOK_RELATIONSHIPS_NAMESPACE, 'r:id', 'rId' . $sheetIndex); + } + + return $document->saveXML(); + } + + private function workbookRelationshipsXml() : string { + $document = $this->newDocument(); + $relationships = $document->createElementNS(self::RELATIONSHIPS_NAMESPACE, 'Relationships'); + $document->appendChild($relationships); + + foreach( $this->worksheetData as $index => $sheetData ) { + $sheetIndex = $index + 1; + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId' . $sheetIndex, + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/worksheet', + 'Target' => 'worksheets/sheet' . $sheetIndex . '.xml', + ]); + } + + $this->appendElement($document, $relationships, self::RELATIONSHIPS_NAMESPACE, 'Relationship', [ + 'Id' => 'rId' . (count($this->worksheetData) + 1), + 'Type' => self::WORKBOOK_RELATIONSHIPS_NAMESPACE . '/styles', + 'Target' => 'styles.xml', + ]); + + return $document->saveXML(); + } + + private function stylesXml() : string { + $document = $this->newDocument(); + $styleSheet = $document->createElementNS(self::SPREADSHEET_NAMESPACE, 'styleSheet'); + $document->appendChild($styleSheet); + + $fonts = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'fonts', [ 'count' => '1' ]); + $this->appendElement($document, $fonts, self::SPREADSHEET_NAMESPACE, 'font'); + $fills = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'fills', [ 'count' => '2' ]); + foreach( [ 'none', 'gray125' ] as $patternType ) { + $fill = $this->appendElement($document, $fills, self::SPREADSHEET_NAMESPACE, 'fill'); + $this->appendElement($document, $fill, self::SPREADSHEET_NAMESPACE, 'patternFill', [ 'patternType' => $patternType ]); + } + + $borders = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'borders', [ 'count' => '1' ]); + $this->appendElement($document, $borders, self::SPREADSHEET_NAMESPACE, 'border'); + $cellStyleXfs = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellStyleXfs', [ 'count' => '1' ]); + $this->appendElement($document, $cellStyleXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + ]); + $cellXfs = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellXfs', [ 'count' => '2' ]); + $this->appendElement($document, $cellXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + 'xfId' => '0', + ]); + $wrappedText = $this->appendElement($document, $cellXfs, self::SPREADSHEET_NAMESPACE, 'xf', [ + 'numFmtId' => '0', + 'fontId' => '0', + 'fillId' => '0', + 'borderId' => '0', + 'xfId' => '0', + 'applyAlignment' => '1', + ]); + $this->appendElement($document, $wrappedText, self::SPREADSHEET_NAMESPACE, 'alignment', [ + 'vertical' => 'bottom', + 'wrapText' => '1', + ]); + $cellStyles = $this->appendElement($document, $styleSheet, self::SPREADSHEET_NAMESPACE, 'cellStyles', [ 'count' => '1' ]); + $this->appendElement($document, $cellStyles, self::SPREADSHEET_NAMESPACE, 'cellStyle', [ + 'name' => 'Normal', + 'xfId' => '0', + 'builtinId' => '0', + ]); + + return $document->saveXML(); + } + + /** + * @param int|null $createdTime The timestamp to use for the created time. If null, the current time will be used. + */ + public function setCreatedTime( ?int $createdTime ) : void { + $this->createdTime = $createdTime; + } + +} diff --git a/test/Integration/XlsxTest.php b/test/Integration/XlsxTest.php new file mode 100644 index 0000000..5135865 --- /dev/null +++ b/test/Integration/XlsxTest.php @@ -0,0 +1,95 @@ +setCreatedTime(518395400); + $export = new DataExport($engine); + + $firstSheet = new DataSheet('First & Last'); + $firstSheet->addRow([ 'test one', '日本語', 'test two' ]); + $firstSheet->addRow([ 'one', '', 3, "multi\nline" ]); + $export->addSheet($firstSheet); + + $secondSheet = new DataSheet('Second'); + $secondSheet->addRow([ 5 => 'another sheet' ]); + $export->addSheet($secondSheet); + + $temp = tmpfile(); + $meta = stream_get_meta_data($temp); + $export->export($temp); + fflush($temp); + + $zip = new \ZipArchive; + $this->assertSame(true, $zip->open($meta['uri']) === true); + + $this->assertSame([ + '[Content_Types].xml', + '_rels/.rels', + 'docProps/core.xml', + 'xl/workbook.xml', + 'xl/_rels/workbook.xml.rels', + 'xl/styles.xml', + 'xl/worksheets/sheet1.xml', + 'xl/worksheets/sheet2.xml', + ], $this->zipFileNames($zip)); + + $workbook = $this->xmlDocument($zip->getFromName('xl/workbook.xml')); + $workbookXPath = new \DOMXPath($workbook); + $workbookXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('First & Last', $workbookXPath->evaluate('string(/x:workbook/x:sheets/x:sheet[1]/@name)')); + $this->assertSame('Second', $workbookXPath->evaluate('string(/x:workbook/x:sheets/x:sheet[2]/@name)')); + + $sheet = $this->xmlDocument($zip->getFromName('xl/worksheets/sheet1.xml')); + $sheetXPath = new \DOMXPath($sheet); + $sheetXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('test one', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[1]/x:c[@r="A1"]/x:is/x:t)')); + $this->assertSame('日本語', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[1]/x:c[@r="B1"]/x:is/x:t)')); + $this->assertSame('3', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="C2"]/x:v)')); + $this->assertSame('n', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="C2"]/@t)')); + $this->assertSame('', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="B2"]/@r)')); + $this->assertSame('1', $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="D2"]/@s)')); + $this->assertSame("multi\nline", $sheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row[2]/x:c[@r="D2"]/x:is/x:t)')); + + $secondSheetXml = $this->xmlDocument($zip->getFromName('xl/worksheets/sheet2.xml')); + $secondSheetXPath = new \DOMXPath($secondSheetXml); + $secondSheetXPath->registerNamespace('x', 'http://schemas.openxmlformats.org/spreadsheetml/2006/main'); + $this->assertSame('another sheet', $secondSheetXPath->evaluate('string(/x:worksheet/x:sheetData/x:row/x:c[@r="A1"]/x:is/x:t)')); + + $coreProperties = $this->xmlDocument($zip->getFromName('docProps/core.xml')); + $corePropertiesXPath = new \DOMXPath($coreProperties); + $corePropertiesXPath->registerNamespace('dcterms', 'http://purl.org/dc/terms/'); + $this->assertSame('1986-06-05T22:43:20Z', $corePropertiesXPath->evaluate('string(/*/dcterms:created)')); + + $zip->close(); + fclose($temp); + } + + private function xmlDocument( string $xml ) : \DOMDocument { + $document = new \DOMDocument; + $this->assertTrue($document->loadXML($xml)); + + return $document; + } + + /** + * @return string[] + */ + private function zipFileNames( \ZipArchive $zip ) : array { + $fileNames = []; + for( $index = 0; $index < $zip->numFiles; $index++ ) { + $fileNames[] = $zip->getNameIndex($index); + } + + return $fileNames; + } + +}