共享字符串 => 索引 */ private array $strIndex = []; /** @var array 索引 => 共享字符串 */ private array $strings = []; /** @var array 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */ private array $strCount = []; /** @var array 工作表(按输出顺序) */ private array $sheets = []; // 样式索引(见 stylesXml) private const S_DEFAULT = 0; // 常规(无边框) private const S_HEADER = 1; // 表头:加粗 + 居中 + 细边框 private const S_TEXT = 2; // 数据:文本格式(numFmtId=49)+ 细边框 private const S_RED = 3; // 数据 + 红字(最高分标记) private const S_GREEN = 4; // 数据 + 绿底(合格次数达标标记) private const S_HEADER_GREEN = 5; // 表头样式 + 绿底(合格次数行的表头格) /** * 语义化标记 => 样式索引。调用方只管语义('red' / 'green' / 'header_green'), * 具体配色编号由本类统一维护,避免外部硬编码样式下标。 */ private const MARK_STYLES = [ 'red' => self::S_RED, 'green' => self::S_GREEN, 'header_green' => self::S_HEADER_GREEN, ]; /** * 生成单表 xlsx 二进制内容(兼容旧调用) * * @param array $rows 二维表,第一行视为表头;每个元素会被强制转成字符串 * @param array $opts ['widths' => [列宽,...], 'title' => 工作表名] * @return string xlsx 文件二进制 */ public static function render(array $rows, array $opts = []): string { return self::renderMulti([[ 'name' => (string)($opts['title'] ?? 'Sheet1'), 'rows' => $rows, 'widths' => array_values((array)($opts['widths'] ?? [])), ]]); } /** * 生成多工作表 xlsx * * 说明:所有单元格都写成「共享字符串 + 文本格式(numFmtId=49)」, * 因此既不会出现长学号被转成科学计数法,也不会把以 = + - @ 开头的内容 * 当成公式执行(天然规避 CSV 公式注入问题)。 * * @param array $sheets 每个元素:['name'=>工作表名, 'rows'=>二维表, 'widths'=>[列宽...], 'merges'=>[[r1,c1,r2,c2],...], 'header_rows'=>int, * 'cell_styles'=>['r,c'=>'red'|'green'|'header_green', ...]] * merges 为 1-based 闭区间(含表头行),如 [[2,1,4,1]] = A2:A4 纵向合并 * header_rows 表头行数(默认 1;两行表头的交叉表用 2) * cell_styles 的键为 1-based 的 "行,列",值为语义标记名(未知标记静默忽略) * @return string xlsx 文件二进制 */ public static function renderMulti(array $sheets): string { $w = new self(); foreach ($sheets as $sh) { if (!is_array($sh)) { continue; } $rows = array_values((array)($sh['rows'] ?? [])); if ($rows === []) { continue; // 空表不输出,避免 Excel 提示修复 } $w->sheets[] = [ 'name' => self::safeSheetName((string)($sh['name'] ?? ''), count($w->sheets) + 1, $w->sheets), 'rows' => $rows, 'widths' => array_values((array)($sh['widths'] ?? [])), 'merges' => self::normalizeMerges((array)($sh['merges'] ?? []), count($rows), self::maxCols($rows)), 'header_rows' => max(1, (int)($sh['header_rows'] ?? 1)), 'cell_styles' => self::normalizeCellStyles((array)($sh['cell_styles'] ?? [])), ]; } if ($w->sheets === []) { // 保底:至少输出一张空表 $w->sheets[] = ['name' => 'Sheet1', 'rows' => [['']], 'widths' => [], 'merges' => [], 'header_rows' => 1, 'cell_styles' => []]; } return $w->build(); } /** * 归一化单元格样式表:键 "行,列"(1-based)=> 样式索引;未知标记直接丢弃。 * * @param array $raw ['3,5' => 'red', [4,5] => 'green', ...](两种写法都接受) * @return array */ private static function normalizeCellStyles(array $raw): array { $out = []; foreach ($raw as $k => $mark) { if (is_array($k) && count($k) >= 2) { $key = ((int)$k[0]) . ',' . ((int)$k[1]); } else { $key = (string)$k; if (!preg_match('/^\s*(\d+)\s*,\s*(\d+)\s*$/', $key, $m)) { continue; } $key = ((int)$m[1]) . ',' . ((int)$m[2]); } $idx = self::MARK_STYLES[(string)$mark] ?? null; if ($idx === null) { continue; // 未知标记:不写样式,不报错 } [$r, $c] = array_map('intval', explode(',', $key)); if ($r < 1 || $c < 1) { continue; } $out[$key] = $idx; } return $out; } /** 二维表最大列数(至少 1) */ private static function maxCols(array $rows): int { $max = 1; foreach ($rows as $r) { $max = max($max, count((array)$r)); } return $max; } /** * 归一化合并区间:过滤非法 / 退化(单格)区间,并按行列排序,保证 Excel 不报修复提示。 * * @param array $raw [[r1,c1,r2,c2], ...] 1-based * @return array */ private static function normalizeMerges(array $raw, int $maxRow, int $maxCol): array { $out = []; foreach ($raw as $m) { if (!is_array($m) || count($m) < 4) { continue; } $r1 = (int)$m[0]; $c1 = (int)$m[1]; $r2 = (int)$m[2]; $c2 = (int)$m[3]; if ($r1 > $r2) { [$r1, $r2] = [$r2, $r1]; } if ($c1 > $c2) { [$c1, $c2] = [$c2, $c1]; } if ($r1 < 1 || $c1 < 1 || $r2 > $maxRow || $c2 > $maxCol) { continue; // 越界丢弃 } if ($r1 === $r2 && $c1 === $c2) { continue; // 单格无需合并 } $out[] = [$r1, $c1, $r2, $c2]; } usort($out, static function (array $a, array $b): int { return [$a[0], $a[1], $a[2], $a[3]] <=> [$b[0], $b[1], $b[2], $b[3]]; }); return $out; } /* ---------------- 内部:构建 ---------------- */ private function build(): string { $files = [ '[Content_Types].xml' => '', // 占位,下面按表数量生成 ]; $sheetXmls = []; $names = []; foreach ($this->sheets as $i => $sh) { $n = $i + 1; $maxCol = 1; foreach ($sh['rows'] as $r) { $maxCol = max($maxCol, count((array)$r)); } $maxRow = max(1, count($sh['rows'])); // 登记共享字符串并生成单元格索引 $cells = []; foreach (array_values($sh['rows']) as $ri => $row) { $r = (int)$ri + 1; foreach (array_values((array)$row) as $ci => $val) { $c = (int)$ci + 1; $cells[$r][$c] = $this->intern((string)($val ?? '')); } } $sheetXmls['xl/worksheets/sheet' . $n . '.xml'] = $this->sheetXml( $cells, $maxRow, $maxCol, $sh['widths'], $sh['merges'] ?? [], (int)($sh['header_rows'] ?? 1), (array)($sh['cell_styles'] ?? []) ); $names[] = $sh['name']; } $count = count($this->sheets); $files = [ '[Content_Types].xml' => $this->contentTypesXml($count), '_rels/.rels' => $this->rootRelsXml(), 'docProps/app.xml' => $this->appXml(), 'docProps/core.xml' => $this->coreXml($names[0] ?? 'Sheet1'), 'xl/workbook.xml' => $this->workbookXml($names), 'xl/_rels/workbook.xml.rels' => $this->workbookRelsXml($count), 'xl/styles.xml' => $this->stylesXml(), 'xl/sharedStrings.xml' => $this->sharedStringsXml(), ] + $sheetXmls; return $this->zip($files); } /** * 工作表名安全化:Excel 限制 31 字符、禁止 []:*?/\、不可重复、不可为空 */ private static function safeSheetName(string $name, int $seq, array $existing): string { $bad = ['\\', '/', '?', '*', '[', ']', ':']; $n = trim(str_replace($bad, '_', $name)); if ($n === '') { $n = 'Sheet' . $seq; } if (function_exists('mb_substr')) { $n = mb_substr($n, 0, 31, 'UTF-8'); } else { $n = substr($n, 0, 31); } // 去重 $base = $n; $i = 2; $taken = []; foreach ($existing as $e) { $taken[strtolower($e['name'])] = true; } while (isset($taken[strtolower($n)])) { $suffix = '(' . $i . ')'; $n = (function_exists('mb_substr') ? mb_substr($base, 0, 31 - strlen($suffix), 'UTF-8') : substr($base, 0, 31 - strlen($suffix))) . $suffix; $i++; } return $n; } 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; $this->strCount[$i] = 0; } $i = $this->strIndex[$s]; $this->strCount[$i] = ($this->strCount[$i] ?? 0) + 1; return $i; } private function sheetXml(array $cells, int $maxRow, int $maxCol, array $widths = [], array $merges = [], int $headerRows = 1, array $cellStyles = []): string { $dim = 'A1:' . self::colName($maxCol) . $maxRow; $cols = ''; for ($c = 1; $c <= $maxCol; $c++) { $w = $widths[$c - 1] ?? 16; $cols .= ''; } // 合并区域的非首格不能带值(否则 Excel 打开会提示"修复"), // 但仍要写成「空值 + 带样式的格子」,否则合并区域的边框会在 Excel 里缺边。 $swallowed = []; foreach ($merges as $m) { for ($r = $m[0]; $r <= $m[2]; $r++) { for ($c = $m[1]; $c <= $m[3]; $c++) { if ($r === $m[0] && $c === $m[1]) { continue; } $swallowed[$r][$c] = true; } } } $data = ''; for ($r = 1; $r <= $maxRow; $r++) { $baseStyle = $r <= $headerRows ? self::S_HEADER : self::S_TEXT; $line = ''; $has = false; for ($c = 1; $c <= $maxCol; $c++) { $sw = isset($swallowed[$r][$c]); if (!isset($cells[$r][$c]) && !$sw) { continue; } $has = true; $ref = self::colName($c) . $r; // 单元格级标记(红字 / 绿底)优先;合并区间跟随左上角首格的样式, // 否则"合并成一格但只有左边半截有底色"会很难看。 $mark = $cellStyles[$r . ',' . $c] ?? null; if ($mark === null && $sw) { foreach ($merges as $m) { if ($r >= $m[0] && $r <= $m[2] && $c >= $m[1] && $c <= $m[3]) { $mark = $cellStyles[$m[0] . ',' . $m[1]] ?? null; break; } } } $style = ($mark !== null) ? (int)$mark : $baseStyle; if ($sw) { $line .= ''; } else { $line .= '' . $cells[$r][$c] . ''; } } if (!$has) { continue; } $data .= '' . $line . ''; } $mergeXml = ''; if ($merges !== []) { $mergeXml = ''; foreach ($merges as $m) { $mergeXml .= ''; } $mergeXml .= ''; } return '' . "\n" . '' . '' . '' . '' . ($cols !== '' ? '' . $cols . '' : '') . '' . $data . '' . $mergeXml . ''; } private function contentTypesXml(int $sheetCount): string { $overrides = ''; for ($i = 1; $i <= max(1, $sheetCount); $i++) { $overrides .= ''; } return '' . "\n" . '' . '' . '' . '' . $overrides . '' . '' . '' . '' . ''; } private function rootRelsXml(): string { return '' . "\n" . '' . '' . '' . '' . ''; } /** @param array $names 工作表名(已安全化) */ private function workbookXml(array $names): string { $sheets = ''; foreach (array_values($names) as $i => $nm) { $n = (int)$i + 1; $sheets .= ''; } return '' . "\n" . '' . '' . $sheets . '' . ''; } private function workbookRelsXml(int $sheetCount): string { $rels = ''; for ($i = 1; $i <= max(1, $sheetCount); $i++) { $rels .= ''; } $stylesId = max(1, $sheetCount) + 1; $sstId = max(1, $sheetCount) + 2; return '' . "\n" . '' . $rels . '' . '' . ''; } private function stylesXml(): string { return '' . "\n" . '' . // numFmts: 49 = @(文本) '' . // 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字 '' . '' . '' . '' . '' . // 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底 // 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字 '' . '' . '' . '' . '' . '' . // 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰) '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . '' . // 0 常规 '' . // 1 表头 '' . // 2 文本 '' . // 3 红字(最高分) '' . // 4 绿底(合格次数达标) '' . // 5 表头绿底 '' . '' . ''; } private function sharedStringsXml(): string { $unique = count($this->strings); $total = array_sum($this->strCount); $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'; } }