', '123456789012345678'], ]; $bin = XlsxWriter::render($rows, ['widths' => [16, 24], 'title' => '白名单']); file_put_contents($out, $bin); echo "生成字节数: " . strlen($bin) . PHP_EOL; // 1) ZIP 头必须是 PK $pass = 0; $fail = 0; function chk(string $name, bool $ok, string $extra = ''): void { global $pass, $fail; if ($ok) { $pass++; echo " PASS $name" . ($extra ? " — $extra" : '') . PHP_EOL; } else { $fail++; echo " FAIL $name" . ($extra ? " — $extra" : '') . PHP_EOL; } } chk('ZIP 魔数 PK', substr($bin, 0, 2) === 'PK'); // 用 ZipArchive 解开校验包内结构(ZIP 内容已压缩,不能直接在二进制里搜字符串) $parts = []; if (class_exists('ZipArchive')) { $z = new ZipArchive(); if ($z->open($out) === true) { for ($i = 0; $i < $z->numFiles; $i++) { $n = $z->getNameIndex($i); $parts[$n] = $z->getFromIndex($i); } $z->close(); } } chk('包内含 xl/workbook.xml', isset($parts['xl/workbook.xml'])); chk('包内含 xl/worksheets/sheet1.xml', isset($parts['xl/worksheets/sheet1.xml'])); chk('包内含 xl/sharedStrings.xml', isset($parts['xl/sharedStrings.xml'])); chk('Content_Types 声明 workbook 主部件', strpos((string)($parts['[Content_Types].xml'] ?? ''), 'spreadsheetml.sheet.main+xml') !== false); chk('文本格式 numFmtId=49 已写入', strpos((string)($parts['xl/styles.xml'] ?? ''), 'numFmtId="49"') !== false); chk('学号以共享字符串写入(非纯数字单元格)', strpos((string)($parts['xl/sharedStrings.xml'] ?? ''), '2024010102001234') !== false); chk('列名(A/B)正确', strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="A1"') !== false && strpos((string)($parts['xl/worksheets/sheet1.xml'] ?? ''), 'r="B1"') !== false); // 2) 用自带的 XlsxReader 读回 $back = XlsxReader::read($out); chk('XlsxReader 成功解析', is_array($back) && count($back) > 0, '行数=' . count($back)); chk('行数一致', count($back) === count($rows), '期望 ' . count($rows) . ',实际 ' . count($back)); $first = $back[0] ?? []; chk('表头 = 姓名/登陆账号', ($first[0] ?? '') === '姓名' && ($first[1] ?? '') === '登陆账号', json_encode($first, JSON_UNESCAPED_UNICODE)); chk('普通行正确', ($back[1][0] ?? '') === '张三' && ($back[1][1] ?? '') === '2024010101', json_encode($back[1] ?? [], JSON_UNESCAPED_UNICODE)); chk('超长学号不被转科学计数法', ($back[2][1] ?? '') === '2024010102001234', '实际=' . ($back[2][1] ?? 'NULL')); chk('18 位纯数字学号原样保留', ($back[4][1] ?? '') === '123456789012345678', '实际=' . ($back[4][1] ?? 'NULL')); chk('特殊字符转义正确', ($back[4][0] ?? '') === 'Bob "引号" & <标签>', '实际=' . ($back[4][0] ?? 'NULL')); // 3) 无 ZipArchive 时的手写打包路径(stored):单独生成再用 XlsxReader 读回 $manualOut = __DIR__ . '/_xlsxwriter_manual.xlsx'; $manual = XlsxWriter::packManual($parts !== [] ? $parts : []); if ($parts !== []) { file_put_contents($manualOut, $manual); chk('手写 ZIP 魔数 PK', substr($manual, 0, 2) === 'PK'); $mBack = []; try { $mBack = XlsxReader::read($manualOut); } catch (Throwable $e) { $mBack = []; } chk('手写打包产物可被解析', count($mBack) === count($rows), '行数=' . count($mBack)); chk('手写打包内容一致', ($mBack[1][0] ?? '') === '张三' && ($mBack[4][1] ?? '') === '123456789012345678', json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE)); @unlink($manualOut); } // 4) 合并单元格(「每次考试」导出用):写入 mergeCells,且被合并的非首格必须留空 $mergedRows = [ ['学号', '姓名', '考试名称', '第几次', '成绩', '最高分'], ['2021001', '张三', '数学期中', '第1次', '90', '是'], ['', '', '', '第2次', '80', ''], // 学号/姓名/考试名称被合并占用 ['', '', '英语期中', '第1次', '85', '是'], ]; $mOut = __DIR__ . '/_xlsxwriter_merge.xlsx'; $mBin = XlsxWriter::renderMulti([[ 'name' => '每次考试', 'rows' => $mergedRows, 'widths' => [20, 14, 26, 10, 10, 8], // A2:A3、B2:B3 纵向合并;C2:C2 为退化区间(应被丢弃);越界区间也应被丢弃 'merges' => [[2, 1, 3, 1], [2, 2, 3, 2], [2, 3, 2, 3], [2, 6, 99, 6]], ]]); file_put_contents($mOut, $mBin); chk('合并表 ZIP 魔数 PK', substr($mBin, 0, 2) === 'PK'); // 取 sheet XML:优先 ZipArchive;没有 zip 扩展时借 XlsxReader 的纯 PHP 解包(stored/deflate 都能读) $mParts = []; if (class_exists('ZipArchive')) { $mz = new ZipArchive(); if ($mz->open($mOut) === true) { for ($i = 0; $i < $mz->numFiles; $i++) { $n = $mz->getNameIndex($i); $mParts[$n] = $mz->getFromIndex($i); } $mz->close(); } } if ($mParts === []) { try { $rm = new ReflectionMethod('XlsxReader', 'readZip'); $rm->setAccessible(true); $mParts = (array)$rm->invoke(null, $mOut); } catch (Throwable $e) { $mParts = []; } } $sheetXml = (string)($mParts['xl/worksheets/sheet1.xml'] ?? ''); if ($sheetXml !== '') { chk('写入 mergeCells 且计数为 2(退化/越界区间被丢弃)', strpos($sheetXml, '') !== false, '未找到 mergeCells count=2'); chk('合并区间 A2:A3 / B2:B3 正确', strpos($sheetXml, '') !== false && strpos($sheetXml, '') !== false); chk('mergeCells 位于 sheetData 之后', strpos($sheetXml, '') !== false, '未找到 '); chk('区块首格仍然保留(A2 / B2 在)', strpos($sheetXml, 'r="A2"') !== false && strpos($sheetXml, 'r="B2"') !== false); chk('英语块第 4 行考试名称未被吞(C4 在)', strpos($sheetXml, 'r="C4"') !== false); chk('样式表已写入细边框', strpos((string)($mParts['xl/styles.xml'] ?? ''), '') !== false); } else { chk('(跳过)无 ZipArchive,合并单元格 XML 未校验', true); } $mBack = XlsxReader::read($mOut); chk('含合并表可被 XlsxReader 解析', count($mBack) === count($mergedRows), '行数=' . count($mBack)); chk('合并表首行内容正确', ($mBack[1][1] ?? '') === '张三' && ($mBack[3][2] ?? '') === '英语期中', json_encode($mBack[1] ?? [], JSON_UNESCAPED_UNICODE)); @unlink($mOut); /* 5) 两行表头 + 交叉合并(「多考试导出」块表用):header_rows=2 时两行都按表头样式输出, 并用 cell_styles 标记「最高分红字 / 合格次数绿底」 */ $blockRows = [ ['学号', '姓名', '第几次', '数学', '', '英语', ''], ['', '', '', '成绩', '', '成绩', ''], ['356001', '张三', '1', '112', '112'], ['', '', '2', '100', '100'], ['', '', '3', '136', '133'], ['', '', '合格次数', '3', '', '2', ''], ['356002', '李四', '1', '未考', '', '130'], ]; $bOut = __DIR__ . '/_xlsxwriter_block.xlsx'; $bBin = XlsxWriter::renderMulti([[ 'name' => '每次考试', 'rows' => $blockRows, 'widths' => [20, 14, 9, 10, 11, 10, 11], 'merges' => [ [1, 1, 2, 1], [1, 2, 2, 2], [1, 3, 2, 3], [1, 4, 1, 5], [1, 6, 1, 7], // 表头 [3, 1, 6, 1], [3, 2, 6, 2], [3, 3, 5, 3], // 张三 学号/姓名/第几次 // 「第几次」不并入合格次数行, // 否则 C6 的「合格次数」标签会被吞掉 [7, 4, 7, 5], // 李四 数学「未考」跨 2 列 ], 'header_rows' => 2, 'cell_styles' => [ '5,4' => 'red', // 张三数学最高分 136 → 红字 '6,4' => 'green', // 张三数学合格次数 → 绿底 '6,6' => 'green', // 张三英语合格次数 → 绿底 '8,2' => 'nope', // 未知标记应被静默忽略 ], ]]); file_put_contents($bOut, $bBin); chk('两行表头表 ZIP 魔数 PK', substr($bBin, 0, 2) === 'PK'); $bParts = []; if (class_exists('ZipArchive')) { $bz = new ZipArchive(); if ($bz->open($bOut) === true) { for ($i = 0; $i < $bz->numFiles; $i++) { $bn = $bz->getNameIndex($i); $bParts[$bn] = $bz->getFromIndex($i); } $bz->close(); } } $bXml = (string)($bParts['xl/worksheets/sheet1.xml'] ?? ''); $bStyles = (string)($bParts['xl/styles.xml'] ?? ''); if ($bXml !== '') { chk('第 2 行表头也用表头样式(s="1")', strpos($bXml, '') !== false && strpos($bXml, 'r="D2" s="1"') !== false); chk('数据行用文本样式(s="2")', strpos($bXml, 'r="A3" s="2"') !== false); chk('表头合并区间齐备', strpos($bXml, '') !== false && strpos($bXml, '') !== false && strpos($bXml, '') !== false && strpos($bXml, '') !== false); chk('mergeCells 计数 = 9', strpos($bXml, '') !== false); chk('「第几次」不并入合格次数行(C6 仍带值)', strpos($bXml, '16') !== false); // ---- 单元格级样式:最高分红字(s="3")/ 合格次数绿底(s="4") ---- chk('最高分标红用红字样式(D5 s="3")', strpos($bXml, '') !== false); // XML 必须合法:结构错一个字符 Excel 就会提示"修复",用 DOM 解析一遍最省事 if (class_exists('DOMDocument')) { $okXml = true; foreach (['sheet1.xml' => $bXml, 'styles.xml' => $bStyles] as $part => $xml) { $doc = new DOMDocument(); if ($xml === '' || !@$doc->loadXML($xml)) { $okXml = false; chk('XML 可解析:' . $part, false, '解析失败'); } } if ($okXml) { chk('XML 可解析(sheet / styles)', true); } } } else { chk('(跳过)无 ZipArchive,两行表头 XML 未校验', true); } $bBack = XlsxReader::read($bOut); chk('两行表头表可被读回', count($bBack) === count($blockRows), '行数=' . count($bBack)); chk('两行表头表内容正确', ($bBack[2][0] ?? '') === '356001' && ($bBack[5][2] ?? '') === '合格次数' // 合格次数标签不被合并吞掉 && ($bBack[6][3] ?? '') === '未考' && ($bBack[4][3] ?? '') === '136' && ($bBack[4][4] ?? '') === '133', json_encode($bBack[6] ?? [], JSON_UNESCAPED_UNICODE)); @unlink($bOut); @unlink($out); echo PHP_EOL . "结果:$pass 通过,$fail 失败" . PHP_EOL; exit($fail === 0 ? 0 : 1);