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);
}
}