name = $name; } public function writeRow(array $values, array $rowStyle = [], array $cellStyles = []): void { $this->rows[] = ['v'=>$values, 's'=>$rowStyle, 'cs'=>$cellStyles]; } public function writeBlank(int $n = 1): void { for ($i = 0; $i < $n; $i++) $this->rows[] = ['v'=>[], 's'=>[], 'cs'=>[]]; } public function setColWidths(array $widths): void { $this->colWidths = $widths; } public function getRows(): array { return $this->rows; } public function getColWidths(): array { return $this->colWidths; } } class SimpleXLSX { private array $sheets = []; private array $sharedSt = []; // escaped_value => index private array $xfMap = []; // fingerprint => xf index private array $fontMap = []; // font fingerprint => font index private array $fillMap = []; // bg hex => fill index public function addSheet(string $name): SimpleXLSXSheet { $s = new SimpleXLSXSheet($name); $this->sheets[] = $s; return $s; } // ── STYLE FINGERPRINT ───────────────────────────────────────────────────── // Exclude 'merge' and 'height' — layout props, not cell style props private function xfFingerprint(array $s): string { return json_encode([ 'b' => (bool)($s['bold'] ?? false), 'i' => (bool)($s['italic'] ?? false), 'fg' => strtoupper($s['color'] ?? ''), 'bg' => strtoupper($s['bg'] ?? ''), 'ha' => $s['halign'] ?? '', ]); } private function getXfIdx(array $s): int { $fp = $this->xfFingerprint($s); if (!isset($this->xfMap[$fp])) { $this->xfMap[$fp] = count($this->xfMap) + 1; // 0 = default } return $this->xfMap[$fp]; } private function strIdx(string $val): int { if (!isset($this->sharedSt[$val])) $this->sharedSt[$val] = count($this->sharedSt); return $this->sharedSt[$val]; } private function colLetter(int $n): string { $l = ''; while ($n > 0) { $r = ($n-1)%26; $l = chr(65+$r).$l; $n = (int)(($n-1)/26); } return $l; } // ── BUILD SHEET XML ─────────────────────────────────────────────────────── private function buildSheetXml(SimpleXLSXSheet $sheet): string { $merges = []; // reset per sheet $xml = '' . ''; $widths = $sheet->getColWidths(); if (!empty($widths)) { $xml .= ''; foreach ($widths as $i => $w) { $c = $i+1; $xml .= ""; } $xml .= ''; } $xml .= ''; $rowNum = 0; foreach ($sheet->getRows() as $rowData) { $rowNum++; $ht = $rowData['s']['height'] ?? null; $htAttr = $ht ? " ht=\"$ht\" customHeight=\"1\"" : ''; $xml .= ""; foreach ($rowData['v'] as $ci => $val) { $colNum = $ci + 1; $cellRef = $this->colLetter($colNum).$rowNum; $cellStyle = array_merge($rowData['s'], $rowData['cs'][$ci] ?? []); // FIX: only the FIRST cell (ci===0) creates a merge record $colspan = (int)($cellStyle['merge'] ?? 0); if ($colspan > 1 && $ci === 0) { $endL = $this->colLetter($colNum + $colspan - 1); $merges[] = "$cellRef:{$endL}{$rowNum}"; } $xf = $this->getXfIdx($cellStyle); if (is_int($val) || is_float($val)) { $xml .= "$val"; } elseif ($val === '' || $val === null) { $xml .= ""; } else { $esc = htmlspecialchars((string)$val, ENT_XML1, 'UTF-8'); $si = $this->strIdx($esc); $xml .= "$si"; } } $xml .= ''; } $xml .= ''; if (!empty($merges)) { $xml .= ''; foreach ($merges as $m) $xml .= ""; $xml .= ''; } return $xml . ''; } // ── BUILD STYLES XML ────────────────────────────────────────────────────── private function buildStylesXml(): string { // Parse all registered styles $stylesByIdx = []; foreach ($this->xfMap as $fp => $idx) $stylesByIdx[$idx] = json_decode($fp, true); ksort($stylesByIdx); // Collect unique fonts (bold × italic × color combinations) $fontDefs = []; // font fingerprint => font index // Index 0 = default font (no bold, no italic, no color) $fontDefs['000'] = 0; foreach ($stylesByIdx as $s) { $fk = ($s['b']?'1':'0').($s['i']?'1':'0').strtoupper($s['fg']??''); if (!isset($fontDefs[$fk])) $fontDefs[$fk] = count($fontDefs); } // Build font XML entries $fontsXml = ''; foreach ($fontDefs as $fk => $fi) { $bold = substr($fk,0,1)==='1'; $italic = substr($fk,1,1)==='1'; $color = substr($fk,2); $fontsXml .= ''; if ($bold) $fontsXml .= ''; if ($italic) $fontsXml .= ''; if ($color) $fontsXml .= ""; $fontsXml .= ''; } $fontsXml .= ''; // Collect unique fills (bg colors) $fillBgs = []; // bg hex => fill index (0,1 reserved by xlsx spec) foreach ($stylesByIdx as $s) { $bg = strtoupper($s['bg'] ?? ''); if ($bg && !isset($fillBgs[$bg])) $fillBgs[$bg] = count($fillBgs) + 2; } $fillsXml = '' . '' . ''; foreach ($fillBgs as $bg => $fi) { $fillsXml .= "" . ""; } $fillsXml .= ''; $bordersXml = ''; // Build xf entries $xfDefs = []; $xfDefs[] = ''; // index 0 default foreach ($stylesByIdx as $s) { $fk = ($s['b']?'1':'0').($s['i']?'1':'0').strtoupper($s['fg']??''); $fontId = $fontDefs[$fk] ?? 0; $bg = strtoupper($s['bg'] ?? ''); $fillId = ($bg && isset($fillBgs[$bg])) ? $fillBgs[$bg] : 0; $ha = $s['ha'] ?? ''; $alignXml = ''; $applyFont = $fontId > 0 ? ' applyFont="1"' : ''; $applyFill = $fillId > 0 ? ' applyFill="1"' : ''; $xfDefs[] = "" . $alignXml.''; } $cellXfsXml = ''.implode('', $xfDefs).''; return '' . '' . $fontsXml.$fillsXml.$bordersXml . '' . $cellXfsXml . '' . ''; } // ── SHARED STRINGS XML ──────────────────────────────────────────────────── private function buildSharedStringsXml(): string { $count = count($this->sharedSt); $xml = '' . ""; $byIdx = array_flip($this->sharedSt); for ($i = 0; $i < $count; $i++) { $xml .= ''.($byIdx[$i] ?? '').''; } return $xml . ''; } private function buildWorkbookXml(): string { $xml = '' . '' . ''; foreach ($this->sheets as $i => $s) { $id = $i + 1; $name = htmlspecialchars($s->name, ENT_XML1, 'UTF-8'); $xml .= ""; } return $xml . ''; } private function buildWorkbookRels(): string { $xml = '' . ''; foreach ($this->sheets as $i => $s) { $id = $i + 1; $xml .= ""; } $n = count($this->sheets); $xml .= "' . "'; return $xml . ''; } // ── DOWNLOAD ────────────────────────────────────────────────────────────── public function download(string $filename): void { // Pre-pass: register all styles and shared strings before generating XML foreach ($this->sheets as $sheet) { foreach ($sheet->getRows() as $row) { foreach ($row['v'] as $ci => $val) { $cs = array_merge($row['s'], $row['cs'][$ci] ?? []); $this->getXfIdx($cs); if (is_string($val) && $val !== '') { $this->strIdx(htmlspecialchars($val, ENT_XML1, 'UTF-8')); } } } } $tmp = tempnam(sys_get_temp_dir(), 'xlsx_'); $zip = new ZipArchive(); $zip->open($tmp, ZipArchive::OVERWRITE); // [Content_Types].xml $ct = '' . '' . '' . '' . '' . '' . ''; foreach ($this->sheets as $i => $s) { $id = $i + 1; $ct .= "'; } $ct .= ''; $zip->addFromString('[Content_Types].xml', $ct); $zip->addFromString('_rels/.rels', '' . '' . ''); $zip->addFromString('xl/workbook.xml', $this->buildWorkbookXml()); $zip->addFromString('xl/_rels/workbook.xml.rels', $this->buildWorkbookRels()); $zip->addFromString('xl/styles.xml', $this->buildStylesXml()); $zip->addFromString('xl/sharedStrings.xml', $this->buildSharedStringsXml()); foreach ($this->sheets as $i => $sheet) { $zip->addFromString( "xl/worksheets/sheet".($i+1).".xml", $this->buildSheetXml($sheet) ); } $zip->close(); header('Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); header("Content-Disposition: attachment; filename=\"$filename\""); header('Content-Length: '.filesize($tmp)); header('Cache-Control: max-age=0'); readfile($tmp); unlink($tmp); } }