Files
nxxy335 d05fd4a9b5 feat(export/dashboard): 导出增强与大屏公开化
导出-每次考试/多考试对比:
- 合格次数阈值等标记项抽为常显区块,三类成绩表共用
- 附缺考名单扩展到多考试导出(取所选考试全都没交的人)
- 块表每场固定2列(成绩+合并空位),修复漏推空串导致的整行左移/串列
- 列宽按实际内容估算,合并区空位只给最小宽度
- 合格次数始终显示实际次数(含0),仅 ≥ 阈值标绿底
- 打印标题改为「成绩明细」,缺考名单分页单独成页
- 打印样式加 print-color-adjust:exact,绿底/红字可正常印出

数据大屏:
- ?class= 班级大屏免登录公开访问,接口侧放行并按班级裁剪后回传
- 单班大屏移除退出按钮,仅首页保留
- 修复红绿判定真bug:membersTable 误用 window.DASH(DASH为let声明取不到)
  改为 scoreGrade() 直接读自由变量,按后台合格率正确判红/黄/绿

测试/样例/文档同步更新,全量回归通过。
2026-09-27 22:04:17 +08:00

578 lines
26 KiB
PHP
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?php
/**
* NY 问卷考试系统数据平台 - 零依赖 XLSX 生成
*
* 仅用 PHP 内置函数生成最小可用的 .xlsx 文件:
* - 有 zip 扩展时走 ZipArchive;无 zip 扩展时回退到"手写 ZIP(stored)"实现,保证任何环境都能导出
* - 单元格统一按「共享字符串 + 文本格式(numFmtId=49)」写入,学号/账号等长数字不会被转成科学计数法
* - 表头行(默认第 1 行,可用 header_rows 指定多行)加粗 + 居中 + 边框
* - 支持合并单元格(merges),被合并的非首格写「空值 + 同样的样式」,保证合并区域四周边框完整
* - 支持单元格级样式(cell_styles):按 [行,列] 指定「红字 / 绿底」等标记,
* 用于"每场最高分标红、合格次数达标标绿"
*/
declare(strict_types=1);
final class XlsxWriter
{
/** @var array<string,int> 共享字符串 => 索引 */
private array $strIndex = [];
/** @var array<int,string> 索引 => 共享字符串 */
private array $strings = [];
/** @var array<int,int> 索引 => 出现次数(sst 的 count 用,区别于 uniqueCount) */
private array $strCount = [];
/** @var array<int,array{name:string,rows:array,widths:array,merges: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<string,int>
*/
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<int,array{0:int,1:int,2:int,3:int}>
*/
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 .= '<col min="' . $c . '" max="' . $c . '" width="' . $w . '" customWidth="1"/>';
}
// 合并区域的非首格不能带值(否则 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 .= '<c r="' . $ref . '" s="' . $style . '"/>';
} else {
$line .= '<c r="' . $ref . '" s="' . $style . '" t="s"><v>' . $cells[$r][$c] . '</v></c>';
}
}
if (!$has) {
continue;
}
$data .= '<row r="' . $r . '" spans="1:' . $maxCol . '">' . $line . '</row>';
}
$mergeXml = '';
if ($merges !== []) {
$mergeXml = '<mergeCells count="' . count($merges) . '">';
foreach ($merges as $m) {
$mergeXml .= '<mergeCell ref="' . self::colName($m[1]) . $m[0] . ':' . self::colName($m[3]) . $m[2] . '"/>';
}
$mergeXml .= '</mergeCells>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
'<dimension ref="' . $dim . '"/>' .
'<sheetViews><sheetView workbookViewId="0"/></sheetViews>' .
'<sheetFormatPr defaultRowHeight="13.5"/>' .
($cols !== '' ? '<cols>' . $cols . '</cols>' : '') .
'<sheetData>' . $data . '</sheetData>' .
$mergeXml .
'</worksheet>';
}
private function contentTypesXml(int $sheetCount): string
{
$overrides = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$overrides .= '<Override PartName="/xl/worksheets/sheet' . $i . '.xml" ' .
'ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">' .
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>' .
'<Default Extension="xml" ContentType="application/xml"/>' .
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>' .
$overrides .
'<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>' .
'<Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/>' .
'<Override PartName="/docProps/core.xml" ContentType="application/vnd.openxmlformats-package.core-properties+xml"/>' .
'<Override PartName="/docProps/app.xml" ContentType="application/vnd.openxmlformats-officedocument.extended-properties+xml"/>' .
'</Types>';
}
private function rootRelsXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>' .
'<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/package/2006/relationships/metadata/core-properties" Target="docProps/core.xml"/>' .
'<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties" Target="docProps/app.xml"/>' .
'</Relationships>';
}
/** @param array<int,string> $names 工作表名(已安全化) */
private function workbookXml(array $names): string
{
$sheets = '';
foreach (array_values($names) as $i => $nm) {
$n = (int)$i + 1;
$sheets .= '<sheet name="' . self::xml((string)$nm) . '" sheetId="' . $n . '" r:id="rId' . $n . '"/>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" ' .
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">' .
'<sheets>' . $sheets . '</sheets>' .
'</workbook>';
}
private function workbookRelsXml(int $sheetCount): string
{
$rels = '';
for ($i = 1; $i <= max(1, $sheetCount); $i++) {
$rels .= '<Relationship Id="rId' . $i . '" ' .
'Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" ' .
'Target="worksheets/sheet' . $i . '.xml"/>';
}
$stylesId = max(1, $sheetCount) + 1;
$sstId = max(1, $sheetCount) + 2;
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">' .
$rels .
'<Relationship Id="rId' . $stylesId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>' .
'<Relationship Id="rId' . $sstId . '" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/sharedStrings" Target="sharedStrings.xml"/>' .
'</Relationships>';
}
private function stylesXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">' .
// numFmts: 49 = @(文本)
'<numFmts count="1"><numFmt numFmtId="49" formatCode="@"/></numFmts>' .
// 字体:0 常规 / 1 加粗(表头)/ 2 红色(最高分)/ 3 加粗绿字
'<fonts count="3">' .
'<font><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><b/><sz val="11"/><color theme="1"/><name val="宋体"/><charset val="134"/></font>' .
'<font><sz val="11"/><color rgb="FFD0021B"/><name val="宋体"/><charset val="134"/></font>' .
'</fonts>' .
// 填充:0 none / 1 gray125(Excel 约定占位)/ 2 合格绿底 / 3 表头绿底
// 绿色沿用导出截图里的浅绿 (#63C384 系):既醒目又不至于盖住黑色文字
'<fills count="4">' .
'<fill><patternFill patternType="none"/></fill>' .
'<fill><patternFill patternType="gray125"/></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'<fill><patternFill patternType="solid"><fgColor rgb="FF63C384"/><bgColor indexed="64"/></patternFill></fill>' .
'</fills>' .
// 0 = 无边框,1 = 细灰边框(表格统一描边,打印/截图都清晰)
'<borders count="2">' .
'<border><left/><right/><top/><bottom/><diagonal/></border>' .
'<border>' .
'<left style="thin"><color rgb="FF808080"/></left>' .
'<right style="thin"><color rgb="FF808080"/></right>' .
'<top style="thin"><color rgb="FF808080"/></top>' .
'<bottom style="thin"><color rgb="FF808080"/></bottom>' .
'<diagonal/></border>' .
'</borders>' .
'<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>' .
'<cellXfs count="6">' .
'<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>' . // 0 常规
'<xf numFmtId="0" fontId="1" fillId="0" borderId="1" xfId="0" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 1 表头
'<xf numFmtId="49" fontId="0" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyBorder="1"><alignment vertical="center"/></xf>' . // 2 文本
'<xf numFmtId="49" fontId="2" fillId="0" borderId="1" xfId="0" applyNumberFormat="1" applyFont="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 3 红字(最高分)
'<xf numFmtId="49" fontId="0" fillId="2" borderId="1" xfId="0" applyNumberFormat="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 4 绿底(合格次数达标)
'<xf numFmtId="0" fontId="1" fillId="3" borderId="1" xfId="0" applyFont="1" applyFill="1" applyBorder="1"><alignment horizontal="center" vertical="center"/></xf>' . // 5 表头绿底
'</cellXfs>' .
'<cellStyles count="1"><cellStyle name="常规" xfId="0" builtinId="0"/></cellStyles>' .
'</styleSheet>';
}
private function sharedStringsXml(): string
{
$unique = count($this->strings);
$total = array_sum($this->strCount);
$body = '';
foreach ($this->strings as $s) {
$body .= '<si><t xml:space="preserve">' . self::xml((string)$s) . '</t></si>';
}
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<sst xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" count="' . $total .
'" uniqueCount="' . $unique . '">' . $body . '</sst>';
}
private function coreXml(string $title): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<cp:coreProperties xmlns:cp="http://schemas.openxmlformats.org/package/2006/metadata/core-properties" ' .
'xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:dcterms="http://purl.org/dc/terms/" ' .
'xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">' .
'<dc:title>' . self::xml($title) . '</dc:title>' .
'<dc:creator>NY问卷考试系统数据平台</dc:creator>' .
'<cp:lastModifiedBy>NY问卷考试系统数据平台</cp:lastModifiedBy>' .
'<dcterms:created xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:created>' .
'<dcterms:modified xsi:type="dcterms:W3CDTF">' . gmdate('Y-m-d\TH:i:s\Z') . '</dcterms:modified>' .
'</cp:coreProperties>';
}
private function appXml(): string
{
return '<?xml version="1.0" encoding="UTF-8" standalone="yes"?>' . "\n" .
'<Properties xmlns="http://schemas.openxmlformats.org/officeDocument/2006/extended-properties" ' .
'xmlns:vt="http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes">' .
'<Application>NY Exam Platform</Application></Properties>';
}
/* ---------------- 内部:打包 ---------------- */
/** @param array<string,string> $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<string,string> $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';
}
}