Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
94.38% covered (success)
94.38%
84 / 89
55.56% covered (warning)
55.56%
5 / 9
CRAP
0.00% covered (danger)
0.00%
0 / 1
XlsxTabularStreamWriter
94.32% covered (success)
94.32%
83 / 88
55.56% covered (warning)
55.56%
5 / 9
22.09
0.00% covered (danger)
0.00%
0 / 1
 __construct
85.71% covered (warning)
85.71%
12 / 14
0.00% covered (danger)
0.00%
0 / 1
3.03
 writeHeaders
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeRow
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
2
 close
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
3.01
 getMimeType
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getFileExtension
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 buildZipPackage
97.37% covered (success)
97.37%
37 / 38
0.00% covered (danger)
0.00%
0 / 1
2
 getCellCoordinate
100.00% covered (success)
100.00%
6 / 6
100.00% covered (success)
100.00%
1 / 1
2
 buildCellXml
88.89% covered (warning)
88.89%
8 / 9
0.00% covered (danger)
0.00%
0 / 1
7.07
1<?php
2
3declare(strict_types=1);
4
5/** @license For full copyright and license information, please see the LICENSE.md file. */
6
7namespace App\Core\DataExchange\Infrastructure\Writer;
8
9defined('AMMONLY_APP') || exit('Direct script access is forbidden.');
10
11use App\Core\DataExchange\Domain\Exception\DataExchangeException;
12use ZipArchive;
13
14/**
15 * Memory-efficient streaming writer producing standard OpenXML (.xlsx) workbooks.
16 * Uses inlineStr XML elements to avoid in-memory string tables.
17 *
18 * @package App\Core\DataExchange\Infrastructure\Writer
19 */
20final class XlsxTabularStreamWriter implements TabularStreamWriterInterface
21{
22    private const string XML_DECLARATION = '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
23
24    /** @var resource */
25    private $tempSheetHandle;
26
27    private string $tempSheetPath;
28    private int $currentRowIndex = 0;
29    private bool $isClosed = false;
30
31    /**
32     * @param string $outputPath Destination XLSX file path.
33     */
34    public function __construct(private readonly string $outputPath)
35    {
36        $tempPath = tempnam(sys_get_temp_dir(), 'ammonly_xlsx_');
37        if ($tempPath === false) {
38            throw new DataExchangeException('Failed to allocate temporary file for XLSX streaming.');
39        }
40
41        $this->tempSheetPath = $tempPath;
42        $handle = fopen($this->tempSheetPath, 'w+');
43        if ($handle === false) {
44            throw new DataExchangeException("Failed to open temporary file for writing: {$this->tempSheetPath}");
45        }
46
47        $this->tempSheetHandle = $handle;
48
49        fwrite(
50            $this->tempSheetHandle,
51            self::XML_DECLARATION . "\n" .
52            '<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' . "\n" .
53            '  <sheetData>' . "\n"
54        );
55    }
56
57    /** {@inheritdoc} */
58    public function writeHeaders(array $headers): void
59    {
60        $this->writeRow($headers);
61    }
62
63    /** {@inheritdoc} */
64    public function writeRow(array $row): void
65    {
66        $this->currentRowIndex++;
67        $rowXml = '    <row r="' . $this->currentRowIndex . '">' . "\n";
68
69        $colIdx = 0;
70        foreach ($row as $cellValue) {
71            $coord = $this->getCellCoordinate($colIdx, $this->currentRowIndex);
72            $rowXml .= $this->buildCellXml($coord, $cellValue);
73            $colIdx++;
74        }
75
76        $rowXml .= '    </row>' . "\n";
77        fwrite($this->tempSheetHandle, $rowXml);
78    }
79
80    /** {@inheritdoc} */
81    public function close(): void
82    {
83        if ($this->isClosed) {
84            return;
85        }
86
87        fwrite($this->tempSheetHandle, "  </sheetData>\n</worksheet>\n");
88        fflush($this->tempSheetHandle);
89        fclose($this->tempSheetHandle);
90
91        $this->buildZipPackage();
92
93        if (file_exists($this->tempSheetPath)) {
94            @unlink($this->tempSheetPath);
95        }
96
97        $this->isClosed = true;
98    }
99
100    /** {@inheritdoc} */
101    public function getMimeType(): string
102    {
103        return 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet';
104    }
105
106    /** {@inheritdoc} */
107    public function getFileExtension(): string
108    {
109        return 'xlsx';
110    }
111
112    /**
113     * Assembles the full OpenXML ZIP container package.
114     */
115    private function buildZipPackage(): void
116    {
117        $zip = new ZipArchive();
118        if ($zip->open($this->outputPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) {
119            throw new DataExchangeException("Failed to create ZIP package: {$this->outputPath}");
120        }
121
122        $contentTypes = self::XML_DECLARATION . "\n" .
123            '<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' . "\n" .
124            '  <Default Extension="rels" ' .
125            'ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' . "\n" .
126            '  <Default Extension="xml" ContentType="application/xml"/>' . "\n" .
127            '  <Override PartName="/xl/workbook.xml" ' .
128            'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' . "\n" .
129            '  <Override PartName="/xl/worksheets/sheet1.xml" ' .
130            'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>' . "\n" .
131            '</Types>';
132
133        $rootRels = self::XML_DECLARATION . "\n" .
134            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' . "\n" .
135            '  <Relationship Id="rId1" ' .
136            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" ' .
137            'Target="xl/workbook.xml"/>' . "\n" .
138            '</Relationships>';
139
140        $workbookRels = self::XML_DECLARATION . "\n" .
141            '<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' . "\n" .
142            '  <Relationship Id="rId1" ' .
143            'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
144            'Target="worksheets/sheet1.xml"/>' . "\n" .
145            '</Relationships>';
146
147        $workbookXml = self::XML_DECLARATION . "\n" .
148            '<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
149            'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' . "\n" .
150            '  <sheets>' . "\n" .
151            '    <sheet name="Sheet1" sheetId="1" r:id="rId1"/>' . "\n" .
152            '  </sheets>' . "\n" .
153            '</workbook>';
154
155        $zip->addFromString('[Content_Types].xml', $contentTypes);
156        $zip->addFromString('_rels/.rels', $rootRels);
157        $zip->addFromString('xl/_rels/workbook.xml.rels', $workbookRels);
158        $zip->addFromString('xl/workbook.xml', $workbookXml);
159        $zip->addFile($this->tempSheetPath, 'xl/worksheets/sheet1.xml');
160
161        $zip->close();
162    }
163
164    /**
165     * Converts a zero-based column and 1-based row index to an A1 cell reference.
166     */
167    private function getCellCoordinate(int $colIndex, int $rowIndex): string
168    {
169        $letter = '';
170        $temp = $colIndex;
171        while ($temp >= 0) {
172            $letter = chr($temp % 26 + 65) . $letter;
173            $temp = intdiv($temp, 26) - 1;
174        }
175
176        return $letter . $rowIndex;
177    }
178
179    /**
180     * Formats cell XML node with proper typing and formula injection mitigation.
181     */
182    private function buildCellXml(string $coord, mixed $value): string
183    {
184        if ($value === null || $value === '') {
185            return '      <c r="' . $coord . '"/>' . "\n";
186        }
187
188        if (is_int($value) || (is_numeric($value) && !preg_match('/^0\d+/', (string) $value))) {
189            return '      <c r="' . $coord . '" t="n"><v>' . (string) $value . '</v></c>' . "\n";
190        }
191
192        $str = (string) $value;
193        if (in_array($str[0], ['=', '+', '-', '@', "\t", "\r"], true)) {
194            $str = "'" . $str;
195        }
196
197        $escaped = htmlspecialchars($str, ENT_XML1, 'UTF-8');
198
199        return '      <c r="' . $coord . '" t="inlineStr"><is><t>' . $escaped . '</t></is></c>' . "\n";
200    }
201}