共享字符串 => 索引 */ private array $strIndex = []; /** @var array 索引 => 共享字符串 */ private array $strings = []; /** @var array> 行数据 */ private array $rows = []; /** @var array 列宽(1 基) */ private array $widths = []; // 样式索引(见 stylesXml) private const S_DEFAULT = 0; // 常规 private const S_HEADER = 1; // 表头:加粗 + 居中 private const S_TEXT = 2; // 文本格式(numFmtId=49) /** * 生成 xlsx 二进制内容 * * @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串 * @param array $opts ['widths' => [列宽,...], 'title' => 工作表名] * @return string xlsx 文件二进制 */ public static function render(array $rows, array $opts = []): string { $w = new self(); $w->rows = array_values($rows); $w->widths = array_values((array)($opts['widths'] ?? [])); return $w->build((string)($opts['title'] ?? 'Sheet1')); } /* ---------------- 内部:构建 ---------------- */ private function build(string $title): string { $maxCol = 1; foreach ($this->rows as $r) { $maxCol = max($maxCol, count((array)$r)); } $maxRow = max(1, count($this->rows)); // 先登记所有字符串,生成 sharedStrings 索引 $cells = []; foreach ($this->rows as $ri => $row) { $r = (int)$ri + 1; foreach (array_values((array)$row) as $ci => $val) { $c = (int)$ci + 1; $s = $this->intern((string)($val ?? '')); $cells[$r][$c] = $s; } } $sheet = $this->sheetXml($cells, $maxRow, $maxCol); $files = [ '[Content_Types].xml' => $this->contentTypesXml(), '_rels/.rels' => $this->rootRelsXml(), 'docProps/app.xml' => $this->appXml(), 'docProps/core.xml' => $this->coreXml($title), 'xl/workbook.xml' => $this->workbookXml($title), 'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml(), 'xl/styles.xml' => $this->stylesXml(), 'xl/sharedStrings.xml' => $this->sharedStringsXml(), 'xl/worksheets/sheet1.xml' => $sheet, ]; return $this->zip($files); } private function intern(string $s): int { if (!array_key_exists($s, $this->strIndex)) { $i = count($this->strings); $this->strIndex[$s] = $i; $this->strings[$i] = $s; } return $this->strIndex[$s]; } private function sheetXml(array $cells, int $maxRow, int $maxCol): string { $dim = 'A1:' . self::colName($maxCol) . $maxRow; $cols = ''; for ($c = 1; $c <= $maxCol; $c++) { $w = $this->widths[$c - 1] ?? 16; $cols .= ''; } $data = ''; for ($r = 1; $r <= $maxRow; $r++) { $line = ''; $has = false; for ($c = 1; $c <= $maxCol; $c++) { if (!isset($cells[$r][$c])) { continue; } $has = true; $s = $r === 1 ? self::S_HEADER : self::S_TEXT; $line .= '' . $cells[$r][$c] . ''; } if (!$has) { continue; } $data .= '' . $line . ''; } return '' . "\n" . '' . '' . '' . '' . ($cols !== '' ? '' . $cols . '' : '') . '' . $data . '' . ''; } private function contentTypesXml(): string { return '' . "\n" . '' . '' . '' . '' . '' . '' . '' . '' . '' . ''; } private function rootRelsXml(): string { return '' . "\n" . '' . '' . '' . '' . ''; } private function workbookXml(string $title): string { return '' . "\n" . '' . '' . ''; } private function workbookRelsXml(): string { return '' . "\n" . '' . '' . '' . '' . ''; } private function stylesXml(): string { return '' . "\n" . '' . // numFmts: 49 = @(文本) '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . // 0 常规 '' . // 1 表头 '' . // 2 文本 '' . '' . ''; } private function sharedStringsXml(): string { $count = count($this->strings); $body = ''; foreach ($this->strings as $s) { $body .= '' . self::xml((string)$s) . ''; } return '' . "\n" . '' . $body . ''; } private function coreXml(string $title): string { return '' . "\n" . '' . '' . self::xml($title) . '' . 'NY问卷考试系统数据平台' . 'NY问卷考试系统数据平台' . '' . gmdate('Y-m-d\TH:i:s\Z') . '' . '' . gmdate('Y-m-d\TH:i:s\Z') . '' . ''; } private function appXml(): string { return '' . "\n" . '' . 'NY Exam Platform'; } /* ---------------- 内部:打包 ---------------- */ /** @param array $files 相对路径 => 内容 */ private function zip(array $files): string { if (class_exists('ZipArchive')) { $tmp = tempnam(sys_get_temp_dir(), 'nyx'); if ($tmp !== false) { $zip = new ZipArchive(); if ($zip->open($tmp, ZipArchive::OVERWRITE) === true) { foreach ($files as $name => $content) { $zip->addFromString($name, $content); } $zip->close(); $bin = (string)@file_get_contents($tmp); @unlink($tmp); if ($bin !== '') { return $bin; } } @unlink($tmp); } } return self::packManual($files); } /** * 无 zip 扩展时的手写打包(stored,不压缩) * 单独公开,便于在没有 ZipArchive 的环境下自检 * * @param array $files 相对路径 => 内容 */ public static function packManual(array $files): string { $local = ''; $central = ''; $offset = 0; $time = self::dosTime(); $date = self::dosDate(); foreach ($files as $name => $content) { $nameBin = (string)$name; $data = (string)$content; $crc = crc32($data); $len = strlen($data); $local .= "\x50\x4b\x03\x04" . pack('vvvvvVVVvv', 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0) . $nameBin . $data; $central .= "\x50\x4b\x01\x02" . pack('vvvvvvVVVvvvvvVV', 20, 20, 0x0800, 0, $time, $date, $crc, $len, $len, strlen($nameBin), 0, 0, 0, 0, 0, $offset) . $nameBin; $offset += 30 + strlen($nameBin) + $len; } return $local . $central . "\x50\x4b\x05\x06" . pack('vvvvVVv', 0, 0, count($files), count($files), strlen($central), $offset, 0); } private static function dosTime(): int { $t = getdate(); return ($t['hours'] << 11) | ($t['minutes'] << 5) | (intdiv($t['seconds'], 2) & 0x1f); } private static function dosDate(): int { $t = getdate(); return (($t['year'] - 1980) << 9) | ($t['mon'] << 5) | $t['mday']; } /* ---------------- 工具 ---------------- */ private static function xml(string $s): string { return htmlspecialchars($s, ENT_XML1 | ENT_COMPAT, 'UTF-8'); } /** 列序号(1基) => A / B / ... / AA */ private static function colName(int $n): string { $s = ''; while ($n > 0) { $m = ($n - 1) % 26; $s = chr(65 + $m) . $s; $n = intdiv($n - 1, 26); } return $s ?: 'A'; } }