Files
ny-exam-platform/lib/XlsxReader.php
T
nxxy335 153c732220 feat: 登录门认证 + 每套试卷独立满分等增强
- 登录门:进入页面先输入系统网址/API路由/账号/密码,测试连接通过后才显示登录按钮,登录后才进入主界面;密码每次需输入,防止依赖一次配置直接查看数据
- 每套试卷独立满分配置(设置中按当前试卷单独设置,合格率按 ≥60% 满分计算)
- 班级联动统计与面板计数(成绩明细/缺考名单标题计数随班级过滤)
- 移除最高/最低分获得者显示
- 平均用时不换行(避免“秒”字换行)
- 卷王地址默认 https://exam.nxxy335.top,默认勾选校验 HTTPS 证书
- 统计卡片含合格率(≥60% 满分)
- .gitignore 排除 data/(账号密码/令牌/学生名单,禁止入库)
2026-09-08 15:22:04 +08:00

224 lines
7.4 KiB
PHP

<?php
/**
* 极简 XLSX 读取器(无第三方依赖)
* 优先使用 ZipArchive,缺失时回退到纯 PHP 的 ZIP 解析(支持 stored / deflate)。
*/
declare(strict_types=1);
class XlsxReader
{
/**
* @return array<int, array<int, string>> 二维数组(行 => 列)
*/
public static function read(string $file): array
{
if (!is_file($file)) {
throw new RuntimeException('文件不存在:' . $file);
}
$entries = self::readZip($file);
$shared = [];
if (isset($entries['xl/sharedStrings.xml'])) {
$shared = self::parseSharedStrings($entries['xl/sharedStrings.xml']);
}
$sheet = self::firstSheetContent($entries);
if ($sheet === null) {
throw new RuntimeException('未找到工作表内容');
}
return self::parseSheet($sheet, $shared);
}
// ---------------- ZIP ----------------
private static function readZip(string $file): array
{
if (class_exists('ZipArchive')) {
$out = [];
$za = new ZipArchive();
if ($za->open($file) === true) {
for ($i = 0; $i < $za->numFiles; $i++) {
$name = $za->getNameIndex($i);
if ($name === false) {
continue;
}
$content = $za->getFromIndex($i);
$out[$name] = $content === false ? '' : $content;
}
$za->close();
return $out;
}
}
return self::parseZipRaw($file);
}
private static function parseZipRaw(string $file): array
{
$data = (string)@file_get_contents($file);
$len = strlen($data);
if ($len < 22) {
throw new RuntimeException('不是有效的 xlsx 文件');
}
$eocd = -1;
for ($i = $len - 22; $i >= 0; $i--) {
if (substr($data, $i, 4) === "\x50\x4b\x05\x06") {
$eocd = $i;
break;
}
}
if ($eocd < 0) {
throw new RuntimeException('不是有效的 xlsx/zip 文件(未找到目录结束标记)');
}
$count = self::u16($data, $eocd + 10);
$offset = self::u32($data, $eocd + 16);
$out = [];
$p = $offset;
for ($i = 0; $i < $count; $i++) {
if (substr($data, $p, 4) !== "\x50\x4b\x01\x02") {
break;
}
$method = self::u16($data, $p + 10);
$compSize = self::u32($data, $p + 20);
$nameLen = self::u16($data, $p + 28);
$extraLen = self::u16($data, $p + 30);
$commentLen = self::u16($data, $p + 32);
$localOff = self::u32($data, $p + 42);
$name = substr($data, $p + 46, $nameLen);
$lNameLen = self::u16($data, $localOff + 26);
$lExtraLen = self::u16($data, $localOff + 28);
$start = $localOff + 30 + $lNameLen + $lExtraLen;
$raw = substr($data, $start, $compSize);
if ($method === 0) {
$content = $raw;
} else {
$content = @gzinflate($raw);
if ($content === false) {
$content = @gzinflate(substr($raw, 2));
}
}
$out[str_replace('\\', '/', $name)] = $content === false ? '' : $content;
$p += 46 + $nameLen + $extraLen + $commentLen;
}
return $out;
}
private static function u16(string $d, int $off): int
{
$s = substr($d, $off, 2);
return $s === '' || strlen($s) < 2 ? 0 : unpack('v', $s)[1];
}
private static function u32(string $d, int $off): int
{
$s = substr($d, $off, 4);
return $s === '' || strlen($s) < 4 ? 0 : unpack('V', $s)[1];
}
// ---------------- XML ----------------
private static function firstSheetContent(array $entries): ?string
{
if (isset($entries['xl/worksheets/sheet1.xml'])) {
return $entries['xl/worksheets/sheet1.xml'];
}
$found = [];
foreach (array_keys($entries) as $k) {
if (preg_match('#^xl/worksheets/sheet\d+\.xml$#', $k)) {
$found[] = $k;
}
}
if ($found === []) {
return null;
}
sort($found, SORT_NATURAL);
return $entries[$found[0]];
}
private static function parseSharedStrings(string $xml): array
{
$out = [];
if (preg_match_all('#<si>(.*?)</si>#s', $xml, $m) > 0) {
foreach ($m[1] as $si) {
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $si, $tm) > 0) {
$out[] = implode('', array_map(
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
$tm[1]
));
} else {
$out[] = '';
}
}
}
return $out;
}
private static function parseSheet(string $xml, array $shared): array
{
$rows = [];
if (preg_match_all('#<row\b[^>]*>(.*?)</row>#s', $xml, $rm) === 0) {
return $rows;
}
foreach ($rm[1] as $rowXml) {
$cells = [];
if (preg_match_all('#<c(?:\s([^>]*?))?(?:/>|>(.*?)</c>)#s', $rowXml, $cm, PREG_SET_ORDER) > 0) {
$autoIdx = 0;
foreach ($cm as $c) {
$attrs = $c[1] ?? '';
$inner = $c[2] ?? '';
$idx = $autoIdx;
if (preg_match('#\br="([A-Z]+)\d*"#', $attrs, $r)) {
$idx = self::colToIndex($r[1]);
}
$autoIdx = $idx + 1;
$type = preg_match('#\bt="([^"]+)"#', $attrs, $tm) ? $tm[1] : '';
if ($type === 'inlineStr') {
$val = '';
if (preg_match_all('#<t[^>]*>(.*?)</t>#s', $inner, $im) > 0) {
$val = implode('', array_map(
static fn($x) => html_entity_decode($x, ENT_QUOTES | ENT_HTML5, 'UTF-8'),
$im[1]
));
}
} else {
$val = preg_match('#<v>(.*?)</v>#s', $inner, $vm)
? html_entity_decode($vm[1], ENT_QUOTES | ENT_HTML5, 'UTF-8')
: '';
if ($type === 's') {
$i = (int)$val;
$val = $shared[$i] ?? '';
} elseif ($type === 'b') {
$val = ((int)$val) === 1 ? 'TRUE' : 'FALSE';
}
}
$cells[$idx] = $val;
}
}
if ($cells === []) {
continue;
}
$max = max(array_keys($cells));
$line = [];
for ($i = 0; $i <= $max; $i++) {
$line[] = $cells[$i] ?? '';
}
$rows[] = $line;
}
return $rows;
}
private static function colToIndex(string $letters): int
{
$n = 0;
$len = strlen($letters);
for ($i = 0; $i < $len; $i++) {
$n = $n * 26 + (ord($letters[$i]) - 64);
}
return $n - 1;
}
}