答案卷抬头改为一行一项,修复长学院名重叠 #22

## 用户诉求

「评分标准最开始不是有个表格吗,里面有开课学院什么的,如果学院名字太长,
就和后面课程名称重合在一起了。看看怎么解决。我建议一行一个信息,
因为这个又不怕长,如果太长,一行都放不下,就直接换行。」

## 现象(已复现并量化)

`dept` 填超长值后,pymupdf 读出:

    开课学院:(70.87~123.37)  值:(123.37~406.87)

而第二格「课程名称」本该从 216.0 开始 ⇒ **重叠 190.87bp**,
把「课程名称 / 试卷类型」整个压住。

⚠️ **编译完全不报错,也没有 Overfull**(rc=0 over=0)。
`\makebox` 内容溢出盒外时 TeX 不认为是坏行。这类 bug 只能靠
量 span 坐标发现,看日志发现不了。

## 根因

`\ouckey@inforow` 用三个 `\makebox[0.3333\linewidth][l]` 并排。
`\makebox` 是**不可断盒子**:
- 内容比盒宽短 → 左对齐(正常)
- 内容比盒宽长 → **直接溢出盒外,压到右边一格**(就是 bug)
- 盒内中文**不能换行**(LR 模式,不做段落断行)

⇒ 三格布局对短字段友好,对长字段必然重叠。

## 改动一:一行一项

    \newcommand{\ouckey@inforow}[2]{%
      \ouckey@ifempty{#2}{}{%
        {\fontsize{10.5bp}{15bp}\selectfont
         \par\noindent\textbf{#1:}#2\par}}}

调用点从「三参数一组」改成「两参数一行」,共 9 项:
课程名称 / 开课学院 / 学生学院 / 年级专业 / 试卷类型 / 考试方式 /
任课教师 / 命题人 / 满分。顺序按"读者关心程度"排。

行距收紧到 15bp(`\Fivhao` 基线距是 16bp)—— 抬头是查阅信息,
不需要正文行距;9 行省约 52bp,将近 3 行正文。

## 改动二:附带修掉一个旧版就有的判空 bug

**旧实现**:
    \newcommand{\ouckey@ifempty}[1]{%
      \if\relax\detokenize{#1}\relax ... }
调用 `\ouckey@ifempty{\ouckey@dept}{空}{非空}`。
`\detokenize` **不展开参数**,看到的是字符串 `\ouckey@dept`(宏名),
永远非空 ⇒ 判空永远失败。

后果:`dept = {}` 时抬头印出孤立的「开课学院:」,后面没有值。
这是旧版就有的 bug(三格布局下不明显),改成一行一项后暴露。

**修法**:在 `\define@key` 里就规范化空值,新增
    \newcommand{\ouckey@setkey}[2]{%
      \if\relax\detokenize{#2}\relax \def#1{}\else \def#1{#2}\fi}
13 个 key 全改走它。

⚠️ 试过「先 `\edef` 展开再 `\detokenize`」的写法,报
   `! Incomplete \iffalse` —— `\if...\else...\fi` 被 `\expandafter`
   链打散,TeX 匹配 `\fi` 时错位。这类宏层展开的修补太脆,放弃。
⚠️ `\ouckey@setkey` 必须定义在 `\define@key` **之前**,否则 undefined。

## 验证

### 三个场景

| 场景 | 结果 |
|---|---|
| 正常(学院名 12 字) | 9 项齐全,无重叠,无越界 |
| 长(含括号说明,约 40 字) | 单行排到 x=406.90,无重叠(下面没有格子可压) |
| 极长(约 70 字) | **自动换行成两行**,第二行缩进对齐,右缘正好 524.40 |
| 留空(dept/college/major 全空) | 三项**整项不印**,不留空行 |

### 编译

    试卷题型示例        6 页  err=0 over=0
    参考答案与评分标准  4 页  err=0 over=0

全库 `bash build.sh all` → **13/13 通过,0 失败**。

### 页数变化

答案卷 3 页 → 4 页。抬头从 3 行变 9 行,是「一行一项」的必然代价。
**已向用户确认,接受 4 页**(第 4 页只有说明文字末尾 2 行 + 签字行)。

## 示例文件未改

`参考答案与评分标准示例.tex` 测试后已还原,`git diff` 为空。
只改 `ouckey.sty` 一个文件。
This commit is contained in:
2026-10-29 22:58:34 +08:00
parent 6819d78f29
commit 42c1394aec
+108 -32
View File
@@ -210,22 +210,65 @@
\def\ouckey@major{}\def\ouckey@dept{}\def\ouckey@teacher{}
\def\ouckey@examiner{}\def\ouckey@mode{}\def\ouckey@date{}
\def\ouckey@total{}
\define@key{ouckey}{year}{\def\ouckey@year{#1}}
\define@key{ouckey}{term}{\def\ouckey@term{#1}}
\define@key{ouckey}{course}{\def\ouckey@course{#1}}
\define@key{ouckey}{type}{\def\ouckey@type{#1}}
\define@key{ouckey}{college}{\def\ouckey@college{#1}}
\define@key{ouckey}{grade}{\def\ouckey@grade{#1}}
\define@key{ouckey}{major}{\def\ouckey@major{#1}}
\define@key{ouckey}{dept}{\def\ouckey@dept{#1}}
\define@key{ouckey}{teacher}{\def\ouckey@teacher{#1}}
\define@key{ouckey}{examiner}{\def\ouckey@examiner{#1}}
\define@key{ouckey}{mode}{\def\ouckey@mode{#1}}
\define@key{ouckey}{date}{\def\ouckey@date{#1}}
\define@key{ouckey}{total}{\def\ouckey@total{#1}}
%----------------------------------------------------------------------
% key 赋值:空值规范成空串(#22)
%
% ⚠️ 所有 key 一律走这里,不要退回 `\def\ouckey@xxx{#1}`。
% 原因见文件下方「判空」一节的完整说明:那样 dept={} 会存成
% 「一个空组」,后续 \detokenize 展开后仍非空,判空失败,
% 抬头会印出孤立的「开课学院:」而没有值。
%
% ⚠️ 必须定义在 \define@key 之**前**,否则展开时 undefined。
%----------------------------------------------------------------------
\newcommand{\ouckey@setkey}[2]{%
\if\relax\detokenize{#2}\relax
\def#1{}%
\else
\def#1{#2}%
\fi}
% 所有 key 一律走 \ouckey@setkey
\define@key{ouckey}{year}{\ouckey@setkey{\ouckey@year}{#1}}
\define@key{ouckey}{term}{\ouckey@setkey{\ouckey@term}{#1}}
\define@key{ouckey}{course}{\ouckey@setkey{\ouckey@course}{#1}}
\define@key{ouckey}{type}{\ouckey@setkey{\ouckey@type}{#1}}
\define@key{ouckey}{college}{\ouckey@setkey{\ouckey@college}{#1}}
\define@key{ouckey}{grade}{\ouckey@setkey{\ouckey@grade}{#1}}
\define@key{ouckey}{major}{\ouckey@setkey{\ouckey@major}{#1}}
\define@key{ouckey}{dept}{\ouckey@setkey{\ouckey@dept}{#1}}
\define@key{ouckey}{teacher}{\ouckey@setkey{\ouckey@teacher}{#1}}
\define@key{ouckey}{examiner}{\ouckey@setkey{\ouckey@examiner}{#1}}
\define@key{ouckey}{mode}{\ouckey@setkey{\ouckey@mode}{#1}}
\define@key{ouckey}{date}{\ouckey@setkey{\ouckey@date}{#1}}
\define@key{ouckey}{total}{\ouckey@setkey{\ouckey@total}{#1}}
\newcommand{\keysetup}[1]{\setkeys{ouckey}{#1}}
% 判空:\ifx 比不了多 token 参数,用 \detokenize 展成字符串再比
%----------------------------------------------------------------------
% 判空(#22 修正:原实现有真 bug)
%
% ⚠️⚠️ 旧实现的坑
% 旧代码是
% \newcommand{\ouckey@ifempty}[1]{%
% \if\relax\detokenize{#1}\relax ... }
% 调用处写 `\ouckey@ifempty{\ouckey@dept}{空}{非空}`。
% **\detokenize 不展开参数** —— 它看到的是字符串 `\ouckey@dept`
% (宏名本身),永远非空 ⇒ 判空永远失败。
% 后果:`dept = {}` 时抬头仍印出孤立的「开课学院:」,后面没有值。
% 这是旧版就有的 bug(三格布局下不明显),#22 改成一行一项后暴露。
%
% 为什么不在宏里修
% 试过「先 \edef 展开再 \detokenize」的版本,会报
% ! Incomplete \iffalse; all text was ignored after line 49.
% 因为 \if...\else...\fi 被 \expandafter 链打散,TeX 匹配 \fi 时错位。
% 这类宏层展开的修补很脆,不值得。
%
% ✅ 正确做法:**在 \define@key 里就把空值规范化**(见文件上方
% \ouckey@setkey)。空值存成真正的空串,判空就能工作。
%
% ⚠️ 这里只留判空宏本身。它拿到的是**已经规范化过的值**
% (`\ouckey@dept` 展开后就是纯文本),所以 \detokenize 有效。
%----------------------------------------------------------------------
\newcommand{\ouckey@ifempty}[1]{%
\if\relax\detokenize{#1}\relax
\expandafter\@firstoftwo
@@ -233,15 +276,45 @@
\expandafter\@secondoftwo
\fi}
% 一格信息:标签加粗,值用宋体;值为空则整格不出
\newcommand{\ouckey@cell}[2]{%
\ouckey@ifempty{#2}{}{\textbf{#1:}#2}}
% 一行三格,等宽
\newcommand{\ouckey@inforow}[6]{%
\par\noindent
\makebox[0.3333\linewidth][l]{\ouckey@cell{#1}{#2}}%
\makebox[0.3333\linewidth][l]{\ouckey@cell{#3}{#4}}%
\makebox[0.3333\linewidth][l]{\ouckey@cell{#5}{#6}}\par}
%----------------------------------------------------------------------
% 一项信息:标签加粗 + 值,**独占一行**(#22)
%
% ⚠️⚠️ 为什么从"一行三格"改成"一行一项"
%
% 旧实现是三个 \makebox[0.3333\linewidth][l] 并排:
% \makebox[固定宽][l]{内容}
% \makebox 是**不可断的盒子**,行为是:
% · 内容比盒宽短 → 左对齐,右侧留白(正常)
% · 内容比盒宽长 → **直接溢出盒外,压到右边一格上**(就是 bug)
% · 盒内中文**不能换行**(LR 模式,不做段落断行)
% 所以三格布局对短字段友好,对长字段必然重叠。
%
% 实测:把 dept 填成
% 计算机科学与技术学院(人工智能与大数据技术方向实验班)
% 后,pymupdf 读出
% 开课学院:(70.87~123.37) 值:(123.37~406.87)
% 而第二格「课程名称」本该从 216.0 开始 ⇒ **重叠 190.87bp**,
% 把「课程名称 / 试卷类型」整个压住。
%
% ⚠️ **编译完全不报错,也没有 Overfull 警告**(rc=0 over=0)。
% \makebox 内容溢出盒外时 TeX 不认为是"坏行"。
% ⇒ 这类 bug 只能靠看渲染结果或量 span 坐标发现,看日志发现不了。
%
% 现在的做法:每项 `\par\noindent\textbf{标签:}值\par`,普通段落。
% · 超长自动换行(中文可在任意汉字间断行)
% · 不存在并排,也就不存在互相压住
% · 抬头是"查阅"性质,一项一行比三格更容易扫读
% 代价:抬头从 3 行变 9 行,多占约 6 行。答案卷 A4、内容不满页,
% 多 6 行不影响版面。
%
% ⚠️ 留空的项仍**整项不印**(也不留空行),逻辑不变。
%----------------------------------------------------------------------
\newcommand{\ouckey@inforow}[2]{%
\ouckey@ifempty{#2}{}{%
% 行距收紧到 15bp(\Fivhao 的基线距是 16bp)。抬头是"查阅"信息,
% 不需要正文那种行距;9 行 × 省 5.8bp ≈ 省 52bp,将近 3 行正文。
{\fontsize{10.5bp}{15bp}\selectfont
\par\noindent\textbf{#1:}#2\par}}}
%----------------------------------------------------------------------
% 抬头:标题 + 信息栏 + 一条分隔线
@@ -263,15 +336,18 @@
\vspace*{10bp}
\hrule height 1.2bp
\vspace*{8bp}
\ouckey@inforow{开课学院}{\ouckey@dept}%
{课程名称}{\ouckey@course}%
{试卷类型}{\ouckey@ifempty{\ouckey@type}{}{\ouckey@type~卷}}
\ouckey@inforow{学生学院}{\ouckey@college}%
{年级专业}{\ouckey@major}%
{考试方式}{\ouckey@mode}
\ouckey@inforow{任课教师}{\ouckey@teacher}%
{命题人}{\ouckey@examiner}%
{满分}{\ouckey@ifempty{\ouckey@total}{}{\TNR{\ouckey@total}~分}}
% 每项一行(#22)。顺序按"读者关心程度"排:
% 先课程、后学院、再人与考试形式,最后分值。
% 留空的自动跳过,所以实际行数随填写项变化。
\ouckey@inforow{课程名称}{\ouckey@course}
\ouckey@inforow{开课学院}{\ouckey@dept}
\ouckey@inforow{学生学院}{\ouckey@college}
\ouckey@inforow{年级专业}{\ouckey@major}
\ouckey@inforow{试卷类型}{\ouckey@ifempty{\ouckey@type}{}{\ouckey@type~卷}}
\ouckey@inforow{考试方式}{\ouckey@mode}
\ouckey@inforow{任课教师}{\ouckey@teacher}
\ouckey@inforow{命题人}{\ouckey@examiner}
\ouckey@inforow{满分}{\ouckey@ifempty{\ouckey@total}{}{\TNR{\ouckey@total}~分}}
% ★ 抬头【不印考试日期】。
% date 这个键仍保留(老文件写了 date= 不会报 unknown key),
% 但不再渲染,对话框里也不显示,避免抬头行数随日期有无而浮动。