数学讲义Word解析排版

SKILL教育学习

原名 high_school_math_formula · 标识 qjy_math_formatting

教育学习 未标注权限 许可证不明确
安装这个技能

对含题目的docx文档在不改动题目文字与行序前提下插入解析段落,并按约定规范统一字体、行距、公式结构与空格间距,让讲义版式整齐可读。

一句话选型结论

按资料字段整理

数学讲义Word解析排版是教育学习类的 Agent Skill,安装方式未标注,权限级别未标注,许可证不明确,有可直接复制的安装命令。技能本质是给模型读的说明书,效果取决于模型与客户端;装完先用一个小任务验证,再放进正式流程。

分类教育学习 安装方式未标注 权限级别未标注权限 许可证许可证不明确
累计安装 未标注 本周 未标注
GitHub 星标 未标注 仓库 未标注
下载量 未标注 技能市场下载数
权限级别 未标注权限 安装前请确认风险范围
许可证 许可证不明确 未说明

安装

未标注
在支持 Skills 的客户端里执行,装完问一句「你有哪些技能」验证
skillhub install user_3789394c/qjy-math-formatting
  1. 1
    确认客户端支持

    确认你的客户端支持 Skills(Claude、Codex、Cursor 等),并升级到较新的版本。

  2. 2
    拿到技能文件

    执行上面的命令安装;也可以把仓库里的 SKILL.md 手动放进客户端的 skills 目录。

  3. 3
    刷新技能列表

    重新打开客户端,或刷新技能列表,让新放进来的技能被加载。

  4. 4
    验证是否生效

    问一句「你有哪些技能」,确认列表里出现它,再拿一个小任务试跑。

技能档案

教育学习
字段来自技能仓库与公开索引,中文说明由本站标注
作者 / 维护方user_3789394c仓库 未标注 · 版本 未标注
宿主客户端未标注技能可被这些客户端加载
安装方式未标注安装包 community
权限级别未标注权限安装前请确认风险范围
许可证未说明许可证不明确,商用前请自行确认条款
仓库可用性available技能仓库当前是否可访问
最近核验2026-11-06上游最后核验时间 · 数据更新 2026-11-06
正文体量92.6 KBSKILL.md 原文大小,越大说明说明书写得越细

能做什么

0 项能力
上游给出的能力概览与适用场景
给题目补解析段落统一讲义公式格式修正讲义排版细节

典型使用场景

3 类
按分类整理的常见用法,具体以 SKILL.md 为准
讲解知识点

按水平把知识点讲清楚。

出练习题

按章节生成练习与答案。

制定学习计划

按目标和时间排学习节奏。

选择建议

装之前先看这几条
按条目字段给出的判断,不替你决定用不用

值得选的地方

  • 有可直接复制的安装命令,不用自己翻文档找入口。

先想清楚的地方

  • 没有标注权限级别,实际能做什么要装好后确认。
  • 许可证不明确,商用或二次分发前先到仓库确认条款。
  • 没有做站内核验,内容以作者仓库为准。
  • 技能只是说明书,实际效果取决于模型与客户端,装完先用小任务试一遍。

更适合

  • 需要在教育学习场景里给模型补一套做法的人。
  • 愿意先读一遍 SKILL.md、确认它会让模型做什么的人。

不太适合

  • 对许可证要求很严、必须明确可商用的人。
  • 只想装完就自动变强、不打算看说明书的人。

同类对比

6 个同类
同分类里挑最接近的几条,字段以各自详情页为准
技能权限级别安装方式累计安装宿主客户端安装命令
数学讲义Word解析排版当前 未标注权限 未标注 未标注 未标注 有
食谱创建课堂课程 未标注权限 GitHub 仓库 25.5 万 Codex、Claude、Cursor 有
语言学习 联网访问 ClawHub 市场 23.6 万 OpenClaw 有
learning 效率 只读 ClawHub 市场 19.2 万 OpenClaw 有
学习习惯 未标注权限 ClawHub 市场 10.2 万 OpenClaw 有
日语家教 只读 ClawHub 市场 8.2 万 OpenClaw 有

SKILL.md 全文

92.6 KB
模型实际读到的说明书原文,来自技能仓库

齐嘉源数学讲义排版解析(qjy_math_formatting)

核心原则:题目内容只读,但格式可修

用户提供现成题目文档时,绝不允许重新打字、重建题目段落、修改题目文字或行序。
题目只做两件事:在原文档上插入解析 + 原地修格式属性,二者都不重打题目。

重新打字会破坏:斜体(R/x/f)、选项表格(w:tbl)、原文标点、段落属性。
2026-10-22 实际发生过,用户明确要求不要重打题目 —— 该要求 2026-10-23 第 6 条细化为
「内容只读、格式可修」,2026-10-24 再补充「空白字符不算内容」(见下)。

但「格式」是可修的(2026-10-23 用户第 6 条明确授权)

用户原话:"我虽然强调不对题目的陈列格式和内容上做任何更改,但要对字体做检查,如果存在对应位置字体错误的情况,应及时修正。"

因此题目区段允许修正的内容限于字符级/段落级格式属性:

允许修 不允许动
字体(Times New Roman / Cambria Math 归位) 题目文字内容
斜体属性(m:sty / w:i) 题目行序、题号结构
公式内空格(函数名与括号、运算符两侧) 选项表格 w:tbl 结构
段落缩进(w:ind firstLine,只写 twips) 题目段落顺序
括号/分式结构((a)/(b) → 真 m:f) 题目断句与标点原文

判据:改完之后题目读起来、算出来必须完全一样,只是"更好看、更规范"。

空白字符属于「格式」,不属于「内容」(2026-10-24 定稿)

半角空格、全角空格、NBSP、Tab 一律按格式处理:该加的加、该删的删,不受「内容只读」约束。

  • 内容判据 = 去空白文本指纹:拼接 w:t + m:t 后剔除全部空白字符再比对,
    与源文件逐字节一致即视为「内容未改」(qjy_polish.text_fingerprint 已内置)。
  • 可自由增删的空格:公式内运算符/区间逗号两侧、函数名与括号之间、视觉碰撞补偿空格、
    上下标结构在公式首/尾与汉字之间、分式与汉字之间、选项字母与内容间距、序号与正文之间。

默认动作:检查 → 直接修,不是只报错

用户给来 .docx 时,默认按「体检 + 修复」处理,不要只交一份问题清单:

  1. 跑 qjy_verify.py / qjy_verify_polish.py 拿违规清单;
  2. 违规项直接按规则改:qjy_polish 有对应函数就用函数,没有就按本文属性级修;
  3. 复跑两个校验器,全绿再交付;
  4. 只有「内容」层面禁止擅动:文字、行序、表格结构、断句与标点原文。
  5. 交付时说清「改了哪几类、各几处」——polish_file() 返回的 stats 就是这份账。

正确做法:

doc = Document(源文件)          # 题目一个元素都不动
anchor.addnext(解析段落)        # 只在题目之后插入

> 例外:需要修题目格式时,同样走"原地修改属性"路线,绝不重打题目。
> 修改后仍要用 XML 指纹核对:允许 w:rPr/m:rPr/w:ind 层面的差异,
> 但 w:t/m:t 文本节点必须逐字节一致。

执行流程

第 1 步:勘察源文档(不可跳过)

先摸清结构再动手。粗看用 qjy_inspect.py,验收/审计时用 qjy_census.py 做全量普查:

python scripts/qjy_inspect.py <源文件.docx>

输出:body 元素顺序表(段落/表格交替)、每道题的起止下标、题目区段总元素数、源文档是否已是目标格式(字体/颜色/加粗)。

关键:解析段是穿插在题目之间的(题1、解析1、题2、解析2…),不能用"第一个深绿元素"切分题目与解析。

第 2 步:写生成脚本

用 scripts/qjy_format.py 作为引擎,用它的助手函数写题目内容。

import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
import qjy_format as Q
from docx import Document

doc = Document(源文件)
body = doc.element.body
kids = list(body)

# 记录题目区段的 XML 指纹,末尾校验未被改动
q_sigs = [Q.xml_of(el) for el in kids[:题目元素数]]

# 定位每道题的锚点(题目最后一个元素,通常是选项表格)
anchor = {1: kids[5], 2: kids[7], ...}

def insert(qno, segs_list):
    cur = anchor[qno]
    for segs, flc in segs_list:
        p = Q.new_para(segs, flc)   # 引擎已套用全部空格规则
        cur.addnext(p)
        cur = p

Q.set_role(True, Q.GREEN)          # 解析 = 深绿加粗
insert(1, [
    ([('t', '解析:(函数的概念与三要素)')], 200),      # 首行必须带考点,见铁律 27
    ([('t', '① 只说明存在一对 '), ('m', Q.sub(Q.v('x'), Q.n('1'))), ...], 200),
])

# 校验题目未被改动
for i, el in enumerate(kids[:题目元素数]):
    assert Q.xml_of(el) == q_sigs[i], '题目元素 #%d 被改动' % i

Q.save_safe(doc, 输出路径)         # 自动处理 Word 占用

输出文件名:不要覆盖源文件,用新名字。

第 3 步:校验(必须全绿才算完成)

python scripts/qjy_verify.py <成品.docx> --src <源文件.docx>
python scripts/qjy_verify_polish.py <成品.docx>        # 第 13 组:格式精修九项
python scripts/qjy_test_polish.py <成品.docx>          # 故障注入,证明第 13 组不空转

十二组 + 第 13 组检查全绿才算通过。任何一项报错都要修到通过再交付。

> ⚠️ 2026-10-24:原 12.4「(N)编号行 firstLineChars ≥ 150」已删除。
> 它与第 8 条定稿(只写 twips、_set_ind 会 pop *Chars)互斥,且与 V6 重复 ——
> 实测最新成品第 13 组 20/20 全绿、12.4 却报 17 处「缩进不足」,两把尺子不可能同时满足。
> 编号行缩进现只由 V6 判定。

> 第 11 组是「格式规格逐项审计」,2026-10-23 新增。它一次性抓出了
> 分式字号、退化为手打括号、区间端点空格等 4 类问题 —— 这些在
> 之前的十组检查里全都漏掉了(十组只查"有没有",不查"对不对")。
>
> 第 12 组是「2026-10-23 十三张截图反馈专项」,覆盖:斜杠分式 (a)/(b)、
> 裸 eq 乱码、中文句号、编号行首行缩进 1.5 字符。
>
> 第 13 组(qjy_verify_polish.py)是「第二批四条截图反馈专项」:
> V1 题号段顶格 / V2 选项字母 TNR 斜体 / V3 选项表缩进对齐题干首字(4×1 与 2×2)/
> V4 集合竖线无空格 / V5 视觉碰撞空格 / V6 子序号双分支对齐 /
> V7 西文跨行 wordWrap=0 / V8 一行四列选项居中 / V9 续行段 = 题干首字。
> V10a 结构件 ctrlPr 字体 = TNR / V10b 公式 run 字体按字符集归位 /
> V11a w:rPr 子元素顺序合法 / V11b 全 run 颜色显式(非 auto)
(2026-10-23 第四批新增)/
> V12 f()/g()/h() 参数内一律 TNR(2026-10-23 用户第 14 条)。
> V13 公式 run 带 m:nor(用户第 15 条)/ V14 拉丁字母斜体(用户第 16 条)/ V15 全文加粗(用户第 17 条)。
> V16 上下标(sSup/sSub/sSubSup)与汉字之间须有公式空格,左右两侧都查 / V17 选项字母与内容间距(短 2 格 / 长 1 格) / V18 序号与正文之间无空格(2026-10-24 第 5、6、7 条截图反馈;V16 于第 11 条扩到「三结构 × 两侧」)。
> V19 分式与汉字之间须有公式空格(2026-10-24 第 9 条截图反馈,见 ⑯B)。
> V20 希腊字母 = Cambria Math + 小写斜体 / V21 上标与右外伸基底之间须有公式空格
> (2026-10-24 第 2、3 条,见 ㉗ ㉘;V20 三个故障点:字体回退 TNR、m:sty 丢 i、w:i 关,
> 均实测抓获)。
> V22 解析内小问缩进(左 4.0 字 + 悬挂 2.5 字) / V23 解析函数记号完整(不许裸 f)
> (2026-10-24 第 22–23 条,见 ㉚ ㉛)。
> 二十五项均已做故障注入验证(qjy_test_polish.py),32 个注入点,实测 29 抓获 / 3 本文件无该结构
> (实测 结论: 32 项检查全部有效;V4 在无 {x|…} 集合描述法的文档上无触发对象但不空转 ——
> V17 拆手写/自动编号两支,V1/V6/V9 各拆「左缩进 / 悬挂 / Chars 压制」多支,
> 故注入点比检查项多七个)。V20/V21 于 2026-10-24 追加,注入点见 ㉗ ㉘。
>
> ⚠️ 2026-10-24 第 8 条定稿:
题目区 left=0,缩进只由 firstLine 表达
> (题号段 firstLine=0 顶格;续行段 firstLine=前缀宽)。
> V1 查题号段必须 0/0,V6/V9 查续行与子序号必须 left=0、firstLine 为正且落点正确。
>
解析区是唯一例外(第 23 条):小问行要「首行 1.5 + 正文/续行 4.0」,
> 只能写 left=960 + hanging=600,由 V22 单独把关(V6 跳过深绿段落)。
>
>
每加一条新检查项,必须做「故障注入验证」:往成品文档里注入该缺陷,
> 确认校验器
会报错*。不报错的检查项等于空转,等于没有。

第 4 步:题目区「格式精修」(qjy_polish.py)

纯题目文档(无解析)或需要重排题目版式时,直接调引擎,一次过六项:

import sys; sys.path.insert(0, 'scripts')
import qjy_polish as P
stats, same = P.polish_file(源文件, 输出文件)   # 内建文本指纹自检
# stats = {'stem':31,'sub':5,'letters':32,'opt_tables':8,'bars':0,'gaps':0,'wrap':90,'fonts':2526}
#  'fonts' = 重设字体的 run 数(含 ctrlPr),恒等于「公式 run 数 + ctrlPr 数」,不是"改错数"
#  stem 题干顶格 / sub 子序号 / letters 字母斜体 / opt_tables 选项表
#  bars 集合竖线 / gaps 碰撞空格 / wrap 西文跨行段落数

它只改 w:ind / w:rPr / m:r / w:wordWrap 层面的属性,文本节点逐字不动;
指纹不一致会直接抛 RuntimeError,不产出文件。

它只改 w:ind / w:rPr / m:r 层面的属性,文本节点逐字不动(脚本内置
「去空格文本指纹」自检,输出 文本指纹一致: True 才允许交付)。

助手函数速查(qjy_format.py)

公式构造(返回 OMML 字符串,用 ('m', ...) 段引用)

函数 用途 例
v(t) 斜体 Q.v('x') → x
n(t) 正体 Q.n('2') → 2
fr(t) / fn(t) 分式的分子/分母(小三 15pt) Q.fr('2')
frac(nu, de) 分式 Q.frac(Q.fr('7'), Q.fr('4'))
sup(b, e) 上标 Q.sup(Q.v('x'), Q.n('2'))
sub(b, s) 下标 Q.sub(Q.v('x'), Q.n('1'))
paren(inner) 圆括号 Q.paren(Q.v('x'))
brk(inner, beg, end) 区间(支持 ( ] [ )) Q.brk(Q.n('0'), beg='(', end=']')
fA(inner) / gA(inner) 函数(自带 f 与 ( 的空格) Q.fA(Q.v('x')) → f (x)
fX() / gX() / fArg(a) 常用函数
ELEM() ∈ 符号(必须是函数) Q.ELEM()

段落构造

函数 用途
new_para(segs, flc) 建一个段落(自动套用全部空格规则)
add_para(doc, segs, flc) 追加到文档末尾
set_role(bold, color) Q.set_role(True, Q.GREEN) 设解析;Q.set_role(True, Q.BLACK) 设题干
init_document() 新建空白文档(设 Normal 样式)
save_safe(doc, path) 保存,自动避开被 Word 占用的文件
xml_of(el) 取元素 XML 字符串(用于题目指纹比对)

segs 格式:[('t', '中文文字'), ('m', omml字符串), ...]

推荐用迷你 LaTeX 写数学(qjy_tex.mstr()),比手拼 OMML 可靠得多:

import qjy_tex as T
('m', T.mstr(r'f(x)=2x^{2}-7x+3'))       # 上标
('m', T.mstr(r'\frac{7}{4}'))             # 分式
('m', T.mstr(r'x\in[1 , 2]'))            # 关系符 + 区间
('m', T.mstr(r'\sqrt{x+5}'))             # 根式
('m', T.mstr(r'f_{\min}=f(-a)'))         # 下标(函数名正体)

迷你 LaTeX 支持的定界符(都会生成合法的 m:d 定界符,不会手打括号):
| 写法 | 生成 | 说明 |
|---|---|---|
| (x) | begChr=( endChr=) | 圆括号 |
| [1 , 2] | begChr=[ endChr=] | 方括号/闭区间 |
| (-5 , 7] | begChr=( endChr=] | 半开区间(左右定界符可不同) |
| \{1,2\} | begChr={ endChr=} | 集合 |
| \|x-1\| | begChr=| endChr=| | 绝对值(支持连续多个 \|a\|-\|b\|) |

写解析时的六条硬约束:
1. 中文段里不要写数学、选项字母(A/B/C/D)、数学符号(⇔ ≤ ∈)——都会被拆成独立数学段
2. 全段不得出现中文句号 。(不只是段末,任何位置都不行),用中文逗号 , 衔接
3. 区间只在英文逗号前后留空格:[1 , 2],不要写成 [ 1 , 2 ]
4. 不要用 Unicode 全角数学字符(− ∞ ∪ ∈)—— 请用 LaTeX 命令(\infty \cup \in),否则字体与区间识别都会失效
5. 分式必须用 \frac{}{},不要手写 a/b —— 手写的会退化成普通字符,不会生成分式结构
6. 绝不能出现 eq 乱码;若上游(联网解析)给出 eq,必须转成 = 或 ≠

题目版式硬规范(2026-10-23 第二批 第 14–17 条 + 第三批 第 18–20 条)

⑭ 题干顶格 + 选项字母斜体对齐

  • 题干段(N、…)与题干续行段(非编号正文段):firstLineChars=0、firstLine=0、left=0。不再缩进 2 字符。
  • 选项字母 A./B./C./D.:Times New Roman + 斜体,字母与点号必须同一个 run(原文常把 C 与 . 拆在两个 run 里,要并回来)。
  • 竖排(单列)选项表:tblInd = 题号前缀宽度 − 108(108 = 单元格默认左边距),单元格段落缩进归零。
  • 前缀宽度 = 数字个数 × 0.5 + 1.0 字符;1、=1.5 字符=360 twips,15、=2.0 字符=480 twips。
  • 实测依据:截图里题干首字「下」与选项「A」起始 x 同为 70px,题干起于 27px → 差 43px ≈ 1.5 字符。
  • 并排(2/4 列)选项表:不动列宽与居中,只把字母改斜体(用户 2026-10-23 明确「保留并排」)。

⑮ 集合公式内竖线两侧不留空格

{x|0≤x≤2} ✅ / {x | 0 ≤ x ≤ 2} ❌
做法:在 begChr="{" 的 m:d 内,把含 | 的 run 文本压成 |(' | ' → '|'),
≤ 两侧的空格照旧保留。

⑯ 视觉碰撞补偿升级为「字体度量驱动」

  • 判据不是字符表硬编码,而是读真实字体度量:解析 timesbi.ttf/timesi.ttf 的
    head/loca/glyf/hmtx/cmap,取每个字形的 bbox 与 advance,算出左右 side bearing;
    外伸为正 = 字形溢出字身框 = 必碰撞。
  • 阈值 0.10em:实测只有 f(左 0.174/右 0.193)、j(左 0.170)、p(左 0.122) 命中;
    y(0.094)、g(0.052)、x(0.045) 属于斜体固有光学余量,补空格只会把版面撑散。
  • 只在公式外围边界判定(公式 ↔ 中文 run);公式内部交给 auto_space 的运算符规则,
    否则会双插。
  • 空格必须是公式空格(m:r + m:sty=p + TNR + 正体),加在公式内部首/尾,
    绝不能加在中文 run 上。
  • 效果:映射f:A→A → 映射 f :A→A;则这样的“f”一共 → 则这样的“ f ”一共。

⑯B 分式与汉字相邻必须留公式空格(2026-10-24 第 9 条,字体度量管不到的那一类)

> 用户原话:
> 「分式如果与中文紧邻,则需要通过公式空格拉开距离」

为什么 ⑯ 的字形度量抓不到它:⑯ 读的是 timesbi.ttf 的字形 bbox(斜体 f/j/p 外伸)。
分式的分子/分母是普通字形,横线是矢量化直线(m:f 内的绘制图元,不是字形),
既不在 cmap 里,也没有 side bearing —— 度量表里查无此物,永远判为「不碰撞」。
所以必须按结构判定,不能按字符判定。

项 口径
触发条件 m:oMath 的首节点或末节点是 m:f(补空格后分式退到 om[1] / om[-2])
相邻字符 只对汉字([\u3400-\u9fff])补;,。「」( 等标点自带留白,再加变双空
空格位置 公式内部首端 om.insert(0, spacer) / 尾端 om.append(spacer),绝不加在中文 run 上
只认 m:f m:rad 的根号钩形自带留白、m:d 定界符自带括号余量,都不补,避免过松
校验 V19:公式首/末节点若仍是 m:f 且外侧是汉字 → FAIL

实测(第 1 题 若x>1,则 [分式] 的最小值为,300dpi):补后 左隙 23px / 右隙 27px,
对照用户给的样板图 21px / 21px,误差 ≤ 2pt,观感一致。

# qjy_polish.add_collision_spaces 内,与「斜体外伸」「下标前置」并列的第三支
if _is_fraction_edge(om, first=True) and CJK.match(ptext[-1:] or ' '):
    need = True

⚠️ 分式边界补空格会新建 m:r:若之后又跑 fix_math_fonts / bold_all,
它们会按「函数名正体」把 log/lg 刷回 m:sty=b + w:i=0,打断 V14。
正确收尾顺序是:fix_subitem_indent → fix_option_gap → add_collision_spaces →
只跑 normalize_rpr(重排 rPr 子元素法定顺序),不要重刷字体与加粗。

⑰ 子序号(1)(2)(3)纵向对齐:~~锚点 = 题号前缀宽度~~

> 本条已被 ⑲ 取代(2026-10-23 第三批第 2 条),保留仅作沿革说明:
> 「(1)独立成段也取前缀宽度」的写法作废,改用 1.5 字符(见 ⑲)。

⑱ 选项表按形状分三类对齐(第三批第 1 条)

表格形状 处理 判据
4 行 1 列(竖排四项) tblInd = 题号前缀宽 − 108,单元格段落缩进归零 A 与题干首字同一 x
2 行 2 列(A B / C D) 同上;A 与 C 同在首列 → 列相同则天然纵向对齐 A/C 与题干首字同一 x
1 行 4 列(并排四项) 只把选项单元格 w:jc 设为 center,不动表缩进、不动列宽 四项居中
5×4 图形表 / 3×3 子问表 跳过,不属于选项表 —
  • 基线:1、=360 twips → tblInd=252;11、/15、/24、=480 → tblInd=372。
  • 实测:2 行 2 列表最容易漏(曾留 469 / 452,文字 x 落到 577 / 560,偏 97 / 80 twips ≈ 0.4 字)。

⑲ 子序号对齐:按「(1)是否与题号同段」双分支(第三批第 2 条,取代 ⑰/铁律 16)

  • 分支 A|(1)在题干首行(15、(1)已知函数…):自动定位(1)的落点,
    组内(2)(3)拉到同一 x。
  • 分支 B|(1)在题干下方另起一行:一律首行缩进 1.5 字符(firstLine=360、
    firstLineChars=150),组内(1)(2)(3)用同一值,不再跟随题号前缀宽度。
  • 判据是「是否共段」,不是题号位数 —— 2 位题号的另起行(1)也从 480 回落到 360。

⑲B 分支 A 的落点 = 前缀宽 − 半个字(2026-10-24 第 10 条,修正 ⑲ 原文)

> 用户原话:「@image 序号没有对齐」(第 15、17 题,(1)比(2)(3)(4)(5)左偏)

成因不是缩进写错,是顿号被压半角。 、(U+3001)与 ((U+FF08)是两个相邻全角标点,
文档 settings.xml 里 characterSpacingControl = compressPunctuation 时,
Word/WPS 把顿号压成半角:

题号 组成 (1)实际落点
15、(1) 6pt + 6pt + 顿号压成 6pt = 18pt 360 twips(不是 480)
5、(1) 6pt + 6pt = 12pt 240 twips(不是 360)
1、若(顿号接汉字) 6pt + 12pt = 18pt 前缀宽 360 不变

也就是说 、 后接汉字时不压缩,接 ( 时才压缩 —— 所以:
- 续行段的 firstLine = 前缀宽(digits×0.5 + 1.0 字符)仍然正确(那里是 、 + 汉字);
- 子序号 分支 A 的锚点必须写 digits×120 + 120 twips,比 prefix_chars() 少 120。

实测(WPS 渲染,pymupdf 取字形 x0):修复前(1)在 74.64pt、其余 4 项在 80.64pt,
相差 6pt = 恰好半个字;改成 360 后 8 处全部落 74.64pt,逐像素对齐。

m2 = re.match(r'^(\d+)、[((]', txt)          # 分支 A 且括号紧跟顿号
anchor = len(m2.group(1)) * (EM // 2) + EM // 2   # EM=240 -> 2 位题号得 360

⚠️ 试过但无效:把 settings.xml 改成 doNotCompress —— WPS 直接忽略,
渲染后(1)仍在 74.64pt。别在这上面浪费时间,按压缩后的实际宽度写锚点即可。

⑳ 允许西文/公式跨行,消除行内稀疏(第三批第 3 条)

  • 段落写 w:wordWrap w:val="0"(= 允许西文在单词中间换行),公式与英文单词因此获得行内断点。
  • 必须插在 w:overflowPunct 之前(pPr 元素法定顺序:… kinsoku → wordWrap → overflowPunct → autoSpaceDE/DN → spacing → ind → jc → rPr)。
  • 成因实测(截图 2):行末撞上一个不可断的长行内公式 → 整块被推到下一行 → 上一行只剩零散内容,
    两端对齐把它拉成稀疏排布,字距 22px(正常 3–4px)、字距比 1.6 倍。开了 wordWrap=0 后
    公式内部可断,行长自然填满,不再拉伸。

㉑ 续行对齐(第三批第 4 条;2026-10-24 连改三版,第 8 条定稿:首行缩进)

用户原话(第三批第 4 条):
> 续行段的首个字符与上一行的首个中文字符纵向对齐(一般来说是首行缩进 1.5 字符或 2 字符),
> 注意不是序号和顿号。

用户原话(第 7 条,否掉悬挂缩进):
> @image#5 这个看起来很难受,能采用首行缩进的,不要采用悬挂缩进。
> 只有公式跨行影响美观的时候才采用另起一行的方式且与上一行对齐;如果是正常跨行,
> 就不会出现影响美观的问题,无需更改。

用户原话(第 8 条,否掉整段左缩进,回应我上一版的方案):
> 不可接受

定稿口径(第 8 条):题号段完全归零,缩进一律用 firstLine 表达,left 永远为 0。

段落类型 判据 w:ind
题号段 匹配 ^\d+、 left=0, firstLine=0 —— 题号顶格,一个 twip 都不移
续行段 非题号、非(N)、非解析、非空 left=0, firstLine=前缀宽 —— 该段首行右移,落在题干首字下
子序号段 匹配 ^(N) left=0, firstLine = 分支 A digits×120+120 / 分支 B 360(见 ⑲B)
①②③ 段 首字为 ①②③ left=0, firstLine=360
解析段 含 00B050 run 普通段首行缩进 2 字符,不参与本规则

三版演进与教训(这段历史必须留着,否则会再绕回去):

版本 写法 观感 用户判定
第 6 条 悬挂 left=前缀宽, firstLine=−前缀宽 题号顶格、续行右移,跨行多时像阶梯 ❌「看起来很难受」
第 7 条 整段左缩进 left=前缀宽, firstLine=0 首行续行齐平,但题号整体右移 1.5–2 字符 ❌「不可接受」
第 8 条 首行缩进 left=0, firstLine=前缀宽 题号原位不动,续行段首行右移对齐题干首字 ✅ 定稿

核心认识:「续行与题干首字对齐」本来就该由续行段自己的 firstLine 表达,
根本不需要动题号段。前两版都在折腾题号段的 left,所以怎么调都不对。
left 是「整段推右」,用户要的是「首行缩进」——只有 firstLine 能做到只动第一行。

⚠️ *Chars 压制坑:firstLineChars / leftChars 一旦存在就压过
firstLine / left(ECMA-376)。写 firstLine=360 却留着 firstLineChars="0",
Word/WPS 按 0 处理,缩进静默失效。_set_ind() 一律只写 twips 并 pop 掉这两个属性。

❌ 已废弃(第 6 条):悬挂缩进(firstLine 为负)。
❌ 已废弃(第 7 条):用 w:left 承担位置(题号会被推右)。
❌ 已废弃:为「公式跨行」单独插 w:br 或把段落拦腰截断 —— 行宽靠猜,注定拆错。
⚠️ 「公式跨行是否影响美观」无法在 OOXML 层精确预判:Word 的换行点取决于真实字体度量、
公式结构件(分式/根式/上下标)的实际占宽,用 m:t 文本估宽误差可达 3–5 倍
(实测某段估算 49620 twips vs 可用行宽 9638 twips)。所以不自动拆行——
题号段内部因公式撑高而自动换行、续行回到页边,属于用户明确认可的「正常跨行」。
确有单段实在难看时,手工把该段拦腰拆成两段,后一段照常写 firstLine=前缀宽,
天然与上一行题干首字对齐,不需要额外对齐逻辑。

黄金样本事实清单(2026-10-23 3.1 节定稿普查)

> 由 _census 式全量普查得出:这些是「已经验收的成品」里真实存在的格式事实,
> 新文件必须同等具备。带 ⚑ 的是 2026-10-23 审计时技能里缺失、现已补入的条目。

页面与节 ⚑

项 值
纸张 A4(pgSz w=11906 h=16838)
页边距 四边 1134 twips(2 cm),header=851 footer=992 gutter=0
文档网格 docGrid type="lines" linePitch="312" charSpace="0"
节数 1(sectPr 只有 body 末尾一个)

> 网格是 lines 而非 linesAndChars:不要开字距网格,否则中文会被拉到网格里、字距变散。

表格 ⚑

项 值 说明
tblW w=0 type=auto 自动宽度,不要写死
tblLayout autofit(图形表 2×4 用 fixed) 图形表锁定列宽,防图片挤变形
tblCellMar 上下 0、左右 108 决定 tblInd = 目标x − 108
trPr jc=center(表格整体居中)/ trHeight hRule=atLeast 图形行给最小行高,避免图贴字
表内段落 ind=0、jc=both(选项)或 center(1×4) —

图片 ⚑

  • 图形题图片是 w:drawing 内嵌在表格单元格里(本例 11 张),不是浮动图形。
  • 铁律:图片只读 —— 不移动、不缩放、不重采样;所在单元格段落 jc=center。
  • 图注(图①…)与图片同格上下两段,jc=center。

公式结构 ⚑

结构 数量级 要点
m:oMath 152 行内公式,不用 m:oMathPara(独立公式会独占行)
m:d 125 100% 带 m:ctrlPr
m:f 27 100% 带 m:ctrlPr,分子分母 sz=30
m:rad 9 根式必须用 m:rad(qjy_tex 的 \sqrt{}),禁止手打 √
m:sSup / m:sSub 27 / 3 上下标走结构件

定界符分布:( ) 99、[ ] 9、{ } 6、[ ) 4、{(endChr 空)4、| | 2、( ] 1。
endChr 为空是合法写法({x|… 只开不合,右括号由正文补),不要强行补成 }。

run 级细节 ⚑

  • bCs / iCs 必须与 b / i 同步(中文不粗、西文粗的经典 bug 就出在这里)。
  • szCs 与 sz 同步(24 或 30)。
  • lang w:val="en-US" w:eastAsia="zh-CN":公式 run 建议补,利于拼写检查不误报。
  • color 必须显式 000000 / 00B050;color="auto" 一律视为未设置。

允许保留的遗留属性(WPS 注入,不清除、不新增)

numPr(numId=0)、keepNext、keepLines、pageBreakBefore(val=0)、widowControl(val=0)、
kinsoku、topLinePunct、bidi、adjustRightInd、snapToGrid、textAlignment(val=auto)。
这些在 18 个段落上成组出现,全部为「关」或中性值,不影响排版;强行删除反而可能触发 WPS 重排。

对齐策略

对象 jc
题干 / 续行段 / 子序号 both(两端对齐,配合 wordWrap=0 不产生稀疏)
图形题长文段 left(图题左齐,避免拉伸)
图形表 / 3×3 子问表 center
4 列选项单元格 center

三十二条铁律(违反即返工)

  1. 题目内容只读:不重打、不重建、不改文字与行序。题目区段的文本节点剔除空白后必须与源文件逐字节一致(空白属格式,见「空白字符属于格式」)。格式属性(字体/斜体/公式内空格/段落缩进)允许修(2026-10-23 用户第 6 条授权)。
  2. 加粗与颜色逐 run 显式设置(四重保险):①中文 run run.font.bold + run.font.color.rgb ②公式 m:r 内嵌 <w:b/> + <w:color/> ③结构件 m:ctrlPr 内含同款 w:rPr ④段落级 w:pPr/w:rPr 兜底。颜色永远写死:题干 000000、解析 00B050。
  3. 依赖角色的片段必须写成函数:ELEM = n(' ∈ ') 写在模块顶层 → 加载时颜色未定 → 全部渲染成黑色。写成 def ELEM(): return n(' ∈ ')。
  4. 插入元素后下标失效:定位元素用对象引用,追加到文末用 sectPr.addprevious()。
  5. 禁止绕过助手函数手写底层拼接:v('f')+paren(X) → 用 fA(X);sub(n('1'),n('1')) → 用 sub(v('x'),n('1'));字母不能放进中文 run。
  6. 空格只用于公式内部:中文与公式一律贴紧;公式内部运算符两侧留空格。引擎已自动处理,不要手写空格。
  7. 区间不加端点内侧空格:[1 , 2],只在英文逗号前后留空格。不要写成 [ 1 , 2 ]。
  8. 改复合片段的基底要删旧残留:v('x') + sub(v('x'), n('1')) = xx₁。
  9. 视觉碰撞补偿:中文紧贴横笔画/上下标/分式会显紧 → 在公式内部首尾补空格(不是加在中文 run 上)。三类触发必须全:斜体字形外伸(按字体度量,⑯)、上下标结构 m:sSup/m:sSub/m:sSubSup 在首端或尾端(按结构判,㉖-1,V16 查左右两侧)、首末原子是分式 m:f(按结构判,⑯B,V19 校验)。后两类查不了字形度量——度量表里只有字母外伸量,没有「上标抬高 65%」和「分式横线」这回事;少判一个方向就整类漏。
  10. 二元运算符前后各一个空格;一元判定结合位置:8 + 2x(二元)vs −2x(一元)。上标内保持紧凑。
  11. 减号必须是公式编辑器的 U+2212:LaTeX 里写 ASCII - 时,qjy_tex._Parser 会自动改写为 −(U+2212)。若自行绕过引擎直接输出 -,它会被 _cls() 判为 Ord(当成字母数字)→ 既不加空格、字体也错。校验时必须断言 OMML 内无 ASCII -。
  12. 函数名与括号的间距是三分规则(2026-10-23 用户第 1/2/3/5 条):f(普通表达式) → 留公式空格;f(分式) → 省略空格;g(x)/h(x)/y(x) → 不设空格(用户明确说"视觉上也是舒服的")。引擎已内置,见 qjy_format._is_func_letter / _is_other_func_letter / _paren_is_frac。
  13. 禁止 (a)/(b) 斜杠分式(2026-10-23 用户第 7/9 条):即使联网解析给出 / 形式,生成解释时也必须转成真正的 m:f 分式。m:f 内部首原子判定用于区分 f(分式)。
  14. 禁止裸 eq 乱码(2026-10-23 用户第 8 条):公式里出现独立的 eq 必须是 = 或 ≠。
  15. 全文档禁止中文句号 。(2026-10-23 用户第 4 条):题目与解析都不出现,用中文逗号 , 衔接前后语句。校验器同时检查"段末"与"全段任一位置"。
  16. 子序号 (1)(2)(3) 按「是否与题号共段」双分支(2026-10-23 第 3 批第 2 条,替代「一律前缀宽度」):①(1)在题干首行 → 定位(1)落点,组内拉到同一 x(紧跟前缀 = 360/480);②(1)另起一行 → 首行缩进 1.5 字符(360 twips / 150),组内统一,不跟前缀。判据是共段与否,不是题号位数。
  17. 跨行观感(第 9/10/12 条 + 第 3 批第 3 条):段落设 w:overflowPunct、w:autoSpaceDE="1"、w:autoSpaceDN="1",并必须加 w:wordWrap w:val="0"(允许西文/公式跨行),否则长行内公式会整块跳行,把上一行拉成稀疏排布。wordWrap 必须排在 overflowPunct 之前。
  18. 缩进一律用 firstLine,left 永远为 0(第 2 批第 1 条 + 第三批第 4 条 + 2026-10-24 第 8 条定稿):题号段 N、… 必须 left=0 / firstLine=0(题号顶格,不许右移);题干拆成多段时第二段起(续行段)写 left=0 / firstLine=前缀宽,该段首行右移到题干首字下方。前缀宽:1 位=360/150,2 位=480/200。解析段(深绿)不参与。禁止悬挂缩进(firstLine 为负,第 7 条否掉);禁止用 left 承担位置(整段被推右,第 8 条判「不可接受」)。
  19. 视觉碰撞按字体度量判定(第 2 批第 3 条):外伸 ≥0.10em 的斜体小写字母(f/j/p)与正体正文相邻处补公式空格;大写字形与 y/g/x 一律不补。
  20. 选项表按形状分类(第 3 批第 1 条):4×1 与 2×2 一律 tblInd = 前缀宽 − 108(A/C 与题干首字齐平);1×4 只做单元格居中,不动缩进与列宽;图形表/子问表跳过。
  21. 图片只读(2026-10-23 审计补入):图形题的 w:drawing 内嵌在表格单元格里,不移动、不缩放、不重采样;图片所在段落与图注 jc=center;图形表用 tblLayout=fixed 锁列宽。
  22. 公式字体必须按字符集刷全量,不能"看到错的才改"(2026-10-23 用户第 11/12 条,已连续踩两次):
    - 判定有三层,缺任何一层都等于没做:
    ① m:r 的 w:rFonts —— 按字符集二选一(TNR / Cambria Math);
    ② m:ctrlPr 的 w:rFonts —— 一律 TNR。分数线、括号定界符、根号、上下标位置的字形全由它决定,
    它写 Cambria Math,每条公式看起来都不是 Times New Roman,哪怕 1000 个 m:r 全对;
    ③ w:rPr 的子元素顺序必须合法(CT_RPr 序列:rFonts → b → bCs → i → iCs → color → sz → szCs → lang)。
    顺序非法时 Word/WPS 会整块丢弃 rPr,字体写了也不生效 —— 表现为「XML 里明明是 TNR,屏幕上是 Cambria Math」,
    极易被误判成"根本没改"。3.2 节源文件里 375 个 m:r 就是 i/iCs 排在 lang 之后,属于非法顺序。
    - 源文档(WPS 导出)几乎 100% 把 ctrlPr 写成 Cambria Math,且常有 m:r 完全不写字体(继承=Cambria Math)、
    颜色写成 auto、rPr 子元素乱序。所以必须写成「遍历 → 重建 rPr」,不是「比对 → 不等才改」。
    - 实现:fix_math_fonts() 定字体 + normalize_rpr() 重建为黄金样本的 9 元素标准形态,两者都在 polish() 末尾执行
    (前面各步——选项字母拆分、碰撞空格——都会新建 run,先刷后建会漏)。
    - 校准基准(3.1 定稿实测):ctrlPr 全 TNR;m:r 中保留 Cambria Math 的只有 → ∈ ∞ ∃ ⊆ 这类符号,13/1297;
    每个 w:rPr 恰好 9 个子元素、顺序统一、无 highlight/vertAlign 残留。
    - 校验项 V10a / V10b / V11a / V11b,均已做故障注入。
  23. f() / g() / h() 参数内的一切内容一律 Times New Roman(2026-10-23 用户第 14 条):
    - 覆盖范围:函数名后那个 m:d 内部全部 m:r,含嵌套结构(如 f((x−y)/(1−xy)) 的
    分子分母)、含数字/运算符/换行符位;优先级高于第 22 条的字形覆盖判定。
    - 判定「是不是函数参数括号」:m:d 的 begChr 为空或 (,且向前跳过纯空白 run 后
    紧邻的非空 m:r 文本为 f/g/h(不能直接看 prev —— f (x) 中间有一个空格 run)。
    - 实现:qjy_polish.is_in_func_arg(r) 结构上溯 + _is_func_paren(d),在
    fix_math_fonts() 与 normalize_rpr() 里都优先判它。
    - 禁止用 id(element) 建集合做包含判断:lxml 的代理对象按需创建、回收后 id 会复用,
    同一节点两次取到的 id() 可能不同(实测把 308 个误判成 45 个)。一律走结构上溯。
    - 校验项 V12(统计参数内 run 数并逐个查字体),已做故障注入。
  24. 每个 m:r 必须带 <m:nor/> —— 「字体改了却不生效」的最隐蔽根因(用户第 15 条):
    - 原理:m:nor(Normal Text)决定该 run 走文本字体还是数学字体。
    有 m:nor → 按 w:rPr/w:rFonts 渲染;没有 → 属于数学文本,
    Word/WPS 一律按数学字体(Cambria Math)排版,w:rFonts 写了完全无效
    。
    - 症状:同一条公式里 f 是 TNR、紧挨着的 2024 却是 Cambria Math。
    源文档只有部分 run 缺 m:nor,肉眼极难定位;量字宽可辨——
    Cambria 数字比 TNR 宽约 20%(实测 2024 每位 14~16px,2021 只有 11~13px)。
    - 处理方式(三步):
    ① 定位:跑 qjy_verify_polish.py,V13 会直接列出缺 m:nor 的 run;
    手工排查也行——遍历 m:r,在 m:rPr 下找不到 m:nor 的即中招。
    ② 修复:调 qjy_polish.ensure_nor(doc),遍历全部 m:r,缺则插入。
    m:nor 必须排在 m:sty 之前(m:rPr 子元素顺序:
    m:lit → m:nor → m:scr → m:sty → m:brk → m:aln)。
    ③ 收尾:补完 m:nor 后 m:sty 不再生效,粗/斜改由 w:b / w:i 决定,
    必须交给 normalize_rpr() 按 m:sty 同步写死(见第 25 条),否则
    斜体字母会变正体、数字会变斜体。
    - 验证:V13 全绿(本文件实测 1077/1077 覆盖);
    故障注入——删掉某 run 的 m:nor,校验器必报 FAIL。
  25. 补 m:nor 时不得把缺失的 m:sty 一律写成 p(用户第 16 条,一次真实回归事故):
    - 原理:源文件里 x / y / m 这类变量 run 常常没有 m:sty,
    靠「缺省 = 数学斜体」显示;一旦补 m:nor 时统一填 m:sty="p",它们会集体变正体
    (实测截图:(x₁ , y₁) , (x₂ , y₂) , ⋯ 全正体,467 个字母 run 无一斜体)。
    - 处理方式(三步):
    ① 定位:跑 V14,它会列出所有「是拉丁字母但不是斜体」的 run;
    人工判断看 m:sty——值为 p 而文本是字母 → 中招。
    ② 修复:m:sty 按字符推导,用 qjy_polish._desired_sty(txt, bold):
    变量字母 → i(加粗时 bi);sin/cos/tan/log/ln/max/min/lim/arc…
    这类函数名按国标排正体(p / b,见 UPRIGHT_WORDS);
    数字/运算符/符号 → p(加粗时 b)。
    加粗位从原有 w:b 取,w:b 缺失一律视为不加粗——
    公式 run 不继承段落标记的加粗,写成"默认加粗"会把源文件本来不粗的变量刷粗。
    ③ 收尾:normalize_rpr() 把 w:b / w:i 与 m:sty 严格对齐
    (bi→粗+斜、b→粗不斜、i→斜不粗、p→都不)。
    - 验证:V14 全绿(本文件实测 467/467 字母均为斜体);
    故障注入——把某字母 run 的 m:sty 改成 b 并把 w:i 置 0,校验器必报 FAIL。
  26. 全文一律加粗(2026-10-23 用户第 17 条):题干黑粗、解析深绿粗 —— 整篇没有不加粗的内容。
    - 覆盖四类:w:r(正文)、m:r(公式字符)、m:ctrlPr(分数线/括号/根号)、w:pPr/w:rPr(段落标记)。
    - m:r 要同时把 m:sty 补上 b(保留原 i)并把 w:b/w:bCs 置开:
    补了 m:nor 之后不同渲染器取值不一,两处都写才不会出现"部分字不粗"。
    - 实现:qjy_polish.bold_all(),在 polish() 里紧接 normalize_rpr() 之后执行,
    再跑一次 normalize_rpr() 把 rPr 子元素顺序排回合法。
    - 校验项 V15(所有非空 run 必须加粗),已做故障注入。

  27. 解析首行必须是「解析:(考点)」,且独占该行(2026-10-24 用户第 21 条):
    - 禁止只写 解析:,也禁止 解析:(考点)由题意…… 这种首行夹带过程。
    - 过程从第二段起逐段展开,默认不写圆圈序号(见铁律 30);考点的措辞用 2–12 字概括,见 ㉕。
    - 考点里的字母/公式须拆成 ('m', ...) 段(f(ax+b)、R),否则变宋体正体。
    - 校验:qjy_verify.py 第 12.5 组,已做两种故障注入。

  28. 希腊字母强制 Cambria Math,小写强制斜体(2026-10-24 用户第 2 条,覆盖铁律 23 的 cmap 判据):
    - α β γ … ω 用小写斜体(m:sty="i"/"bi" + w:i 开);Γ Δ Σ … 用大写正体。
    - 字体与样式两处都要改(_want_font / _desired_sty):只改字体,m:nor 生效后会变成
    「Cambria Math 正体」,用户看到的仍是"没斜"。校验项 V20。

  29. 上标的避碰宽度要算,不能塞一个空格了事(2026-10-24 用户第 3 条):
    - 触发:m:sSup/m:sSubSup 基底末字符右外伸 ≥ 0.10em(实测只有 f)。
    - 修法:m:sup 首位补空格 run,并把该 run 的 w:sz +6 半磅
    (脚本位 0.708 缩放后 0.25em 空格只有 2.0pt,小于 f 的 2.32pt 外伸)。
    - 禁止补在 m:e 末尾(会把后续 (x) 一起推远)。校验项 V21。

  30. 解析段默认不写圆圈序号,只有分类讨论的分支保留(2026-10-24 用户截图反馈,见 ㉙):
    - 用户原话:如非分类讨论用于区分,否则无需用圆圈序号。
    - 默认动作:删掉段首的 ①②③④⑤(含其后紧跟的空白),段落结构不变。
    - 保留条件:同一题内并列分支(当 m=0 时 / 当 m≠0 时、当 −2≤x≤1 时 / 当 x<−2 或 x>1 时)
    用 ①② 标号,并从 ① 起重编号;铺垫段与结论段(综上、故选、故答案为)不带号。
    - 判据:文字本身能不能区分。已写 情形一/情形二、对于A、图①、(1)(2) 的段落一律不带号。
    - 删号后必须清理交叉引用:再由②得 → 再由该式得、将②代回原式 → 将上式代回原式,
    否则出现指向不存在的序号。校验靠人工通读(§㉙ 末)。
    - 校验:V18 保证「有号时号后无空格」;「该不该有号」由通读确认,不做自动断言。

  31. 解析里不许用裸 f / g 代替 f(x) / g(x)(2026-10-24 用户第 22 条,见 ㉚):
    - 描述单调性、值域、最值、奇偶性时,主语必须写全:f(x) 在 R 上递增,不是 f 在 R 上递增。
    - 换元后的函数写新自变量(g(t)),外层函数写 f(u),也算写全。
    - 校验项 V23:解析段落里若有数学段文字恰为 f/g/h(或大写),即 FAIL。

  32. 解析内小问行用「左缩进 + 悬挂」保证续行对齐(2026-10-24 用户第 23 条,见 ㉛):
    - (1) 行首行仍落在 1.5 字符,续行与序号后的第一个字齐平(4.0 字符)。
    - 该写 left=960 + hanging=600(twips),并清掉 firstLine/firstLineChars。
    - 这条只适用于解析区;题干区仍是「left=0 + firstLine」(铁律 8),两套口径分属两个区,不冲突。
    - 圈码 ①②③ 不走悬挂(2026-10-24 用户第 25 条):它只占 1 个全角字形,按普通解析段处理,
    写 left=0 + firstLine=480(2 字符首行缩进)即可;放 1.5 字符会让段首比上下段左移半个字。
    - 校验项 V22(两支:(N) 查悬挂、①② 查 2 字符缩进)。

> ⚠️ 2026-10-24 第 24 条(「写一道题的解析,一行不宜写太多」→ 按宽度自动断段)已被用户撤销
> (原话:「撤销写一道题的解析,一行不宜写太多命令,其他保持不变」)。
> 定稿:源码里一行 = 文档里一段,不做自动断段、不设行宽上限;V24 与
> qjy_format.split_long_segs 随之下线。要控制行长只能靠写解析时少写两句,
> 不靠程序切分——程序切分会让一道题的解析碎成十几行,读起来断断续续。

公式字体异常排查手册(2026-10-23 三轮返工沉淀)

「字体明明写了却没生效」有四道关卡,必须逐层过。任何一层不过,Word/WPS 都会回落到
Cambria Math,而且渲染出来的样子一模一样——所以只能靠校验项定位,不能靠肉眼。

序 关卡 出错表现 定位 处理函数
① w:rFonts 的值 字体名根本不对 / 是 Cambria Math V10a、V10b fix_math_fonts()(全量重设,不是"错才改")
② w:rPr 子元素顺序 整块 rPr 被丢弃,字体、字重、颜色全部失效 V11a normalize_rpr() 重建成 9 元素法定顺序
③ m:r 是否带 m:nor 单个 run 走数学字体:同一公式内字宽不一致 V13 ensure_nor()
④ m:sty 与 w:i/w:b 是否一致 字母变正体 / 数字变斜体 / 个别字不粗 V14、V15 _desired_sty() + normalize_rpr()

另加两条项目专属口径(优先级高于上面的字形判定):

规则 实现 校验
f() / g() / h() 参数内一律 Times New Roman is_in_func_arg() V12
全文一律加粗(正文 / 公式 / 结构件 / 段落标记) bold_all() V15

排查顺序固定为 V10 → V11 → V13 → V14 → V15 → V12。
前一层不过就不要看后一层——顺序错了会被表面现象带偏(例如 ② 不过时,
① 的字体值写得再对也没用;③ 不过时,④ 的字重再怎么调也不生效)。

第三个坑:收尾阶段新建的 run 也要排 rPr 顺序

add_collision_spaces() 会新造"碰撞空格"run,其 rPr 由 qjy_format._spacer_run() 构造。
早期实现把 w:i/w:iCs 直接 append 到末尾,排在 w:sz 之后 —— 非法顺序,
在 3.1 那类"碰撞位置多"的文件上必然被 V11a 抓到(3.2 因为收尾碰撞为 0 而侥幸没暴露)。
现已改为 _rpr_put() 按 CT_RPr 法定位置插入,并在收尾流程末尾再跑一次 normalize_rpr()。

两个真实案例(可作为排查起点)

现象 根因 处理
若 f(2024) = −2021 里 2024 是 Cambria Math,f 和 2021 却是 TNR 2024 这个 run 缺 m:nor,走了数学字体(源文件 44 个 run 都缺) ensure_nor() 补齐;V13 验证
(x₁ , y₁) , ⋯ 里 x y m 全是正体,没有斜体 补 m:nor 时把缺失的 m:sty 一律写成 p,变量失去了"缺省 = 数学斜体" _desired_sty() 按字符推导;V14 验证

格式速查

项目 规范
中文 宋体
英文/数字 Times New Roman
公式内 + − × ÷ = ≤ ≥ < > ≠ ∑ ∏ ∫ √ ( ) , ∩ ∞ → ← ↔ α β Δ Ω … Times New Roman
公式内 ∈ ∉ ⊂ ⊆ ⊃ ⊇ ∪ ∀ ∃ ⇒ ⇔ ∅ ⊥ ⋯ Cambria Math(TNR 无此字形,只能换字体)
结构件 m:ctrlPr(分数线、括号定界符、根号、上下标位置) 一律 Times New Roman(写 Cambria Math 会让整条公式看起来不是 TNR)
f() / g() / h() 参数内 一律 Times New Roman(含括号内全部内容,优先级高于字形覆盖判定)
m:nor 每个 m:r 必须带 <m:nor/>,否则按数学字体渲染,w:rFonts 无效
加粗 全文所有 run 一律加粗(正文/公式/结构件/段落标记)
斜体 只有拉丁字母斜体;数字/运算符/定界符/函数名正体
字号 正文小四(24);分式分子分母小三(30)
行距 1.5 倍(line=360 lineRule=auto)
段后 0(必须显式写,覆盖 Normal 样式的 200)
缩进 全篇 left=0,只用 firstLine 表达:题号段 firstLine=0(顶格不移);续行段 / 另起行的(1)/ ①②③ = firstLine 前缀宽或 1.5 字符(1、=360,15、=480);禁用悬挂(负 firstLine)和左缩进;选项表用 tblInd 对齐题干首字;解析普通段首行 2 字符
页面 A4 11906×16838;四边 1134(2 cm);docGrid lines/linePitch=312(不开字距网格)
表格 tblW auto、tblLayout autofit(图形表 fixed)、cellMar 左右 108、图形行 trHeight atLeast
图片 w:drawing 内嵌单元格,只读不缩放,所在段落 jc=center
选项表 4×1 / 2×2:tblInd = 前缀宽 − 108(A/C 与题干首字齐平);1×4:仅单元格居中,不动缩进列宽
题干 黑色 000000 + 加粗
选项字母 A./B./C./D. Times New Roman 斜体,字母与点号同 run
集合 {x|0≤x≤2},竖线两侧无空格,≤ 两侧保留空格
视觉碰撞 外伸 ≥0.10em 的斜体小写字母(f/j/p)与正体正文之间补公式空格
解析 深绿 00B050 + 加粗
标点 题干与解析全段不得出现中文句号 。,用中文逗号 , 衔接
括号 一律用公式编辑器定界符 m:d,禁止手打
减号 一律用公式编辑器减号 −(U+2212),禁止键盘连字符 -
加减乘除空格 二元 3x − 2 前后各一空格;一元 −2x、−x^4 贴紧其后
函数名与括号 f (x) 留空格;f(分式) 省略;g(x)/h(x)/y(x) 不留
分式 必须是 m:f 结构,禁止 (a)/(b) 斜杠形式;分子分母字号 30
跨行 段落设 w:overflowPunct + w:autoSpaceDE/DN + w:wordWrap="0"(允许西文/公式跨行,消除行内稀疏)

关键实现细节(踩过的坑)

  • 合法 OMML:m:r 内 = <m:rPr><m:nor/><m:sty m:val="i|bi|p|b"/></m:rPr> + 内嵌 <w:rPr>。m:nor 关闭数学自动斜体,保证 TNR 生效。
  • 禁用:m:rPr 内写 m:rFonts、<m:i>、m:sup/m:sub(须用 m:sSup/m:sSub)、m:shp 属性。
  • 结构件必须带 m:ctrlPr:括号/分数线/上下标否则渲染成不粗、黑色。
  • 生成空格 run 绝不能 deepcopy 整个相邻元素——若它是结构元素会复制整个结构(x₁ → x₁x₁)。新建干净 m:r,只从内部 m:r 借 w:rPr。
  • 空格 run 必须正体 + TNR:m:sty="p" + w:i w:val="0" + w:rFonts=Times New Roman。
  • lxml 元素真值测试:elem or default 会触发 FutureWarning,写 if inner is not None:。
  • 校验时空格 run 自身是 m:r,判原子前必须先剔除,否则前后关系错乱会误报。
  • 校验题目是否被改动要用 XML 指纹,不能用下标(插入后下标全变)。
  • 判定一元/二元运算符要看位置,不能只看字符:8 + 2x 的 + 是二元。
  • 局部观察不能直接推广为全局规则:用户对个别例子的偏好未必适用于全部。
  • m:d 无 begChr 时 Word 默认就是 ((2026-10-23 踩坑):判定"是不是左括号"必须把"无 begChr"当作左圆括号,否则题目段 618 个 m:d 全部漏判,函数名空格一个都补不上。
  • 空格判定必须基于"真正的空白 run",不能用下标差(2026-10-23 踩坑):j > i+1 会把 h′(x) 的撇号 run 当成空格 → 假阳性。正确写法是"跳过纯空白 run 计数"。
  • 校验器要递归进嵌套容器:m:e/m:num/m:den 里的原子同样要查(2026-10-23 踩坑):只看 m:oMath 顶层子节点会漏掉 12 处嵌套内的函数名空格。
  • w:i 元素存在但无 val 属性 = 斜体(2026-10-23 踩坑):w:i 缺 val 默认 true。判斜体必须写成 it.get(val) not in ('0','false'),不能只查 val=='1'。
  • 改 eq → = 时别继承斜体:原 eq run 可能是斜体,改完要同时把 m:sty 改 p、w:i 改 0。
  • 插入段落属性时 pPr 必须是 w:p 的第一个子元素:插到 w:r 后面 Word 会忽略。
  • 校验"相邻是否有空格"必须在原始子节点序列上判断:先过滤空格 run 再看相邻会全判成"有间隙"(曾误报 670 处)。
  • 题目指纹必须用「文本节点」而不是「整段 XML」(2026-10-23 踩坑):一旦允许修格式(字体/斜体/缩进/公式空格),整段 XML 比对会把 49 个题目元素全部误报成"被改动"。正确指纹 = 拼接 w:t + m:t 后去掉全部空白;定位时空文本元素会撞车,必须用"顺序消耗式"匹配(用过的不再复用)。
  • 选项字母与点号常被拆在两个 run(2026-10-23 踩坑):'C' + '. 7'。只切第一个 run 会让点号留在正体 run 里 → C 斜体、. 正体。要把下一个 run 的首个点号并回字母 run。
  • tblInd 不等于文字起点(2026-10-23 踩坑):文字 x = tblInd + 单元格左边距(默认 108)。要对齐到 360,tblInd 必须写 252,不是 360。
  • 字体度量阈值要实测,不能拍脑袋(2026-10-23 踩坑):0.05em 会把 A/M/N/V/T/X 全捞进来,导致「集合A到集合B」里只有 A 补空格、B 不补 → 割裂;0.10em 只留 f/j/p,与用户截图完全一致。
  • 2×2 选项表最容易被漏掉(2026-10-23 第三批踩坑):早期脚本只判 ncols==1 才修表缩进,两个 2×2 表带着 469/452 一路绿灯通过 → 文字 x 落在 577/560,比题干首字右偏 97/80 twips。形状判定必须覆盖 ncols==2 and nrows==2 且有 A–D 字母。
  • w:tblInd 必须按 tblPr 子元素顺序插(2026-10-23 踩坑):tblPr.append() 会把它排到 tblLook 之后,属于非法顺序,Word/WPS 可能整段忽略。正确位置在 jc 之后、tblBorders 之前。
  • 新增 pPr 子元素必须按法定顺序插(2026-10-23 踩坑):wordWrap 在 overflowPunct 之前;pPr.append() 会把它排到 rPr 之后,等于没写。用 PPR_ORDER 定位插入点。
  • 「行内稀疏」的锅不在字距,在断行(2026-10-23 第三批实测):截图 2 直接测像素,字距 22px vs 正常 3–4px、字距比 1.6 倍,且行尾并未顶到右边距 → 判定为「长行内公式不可断 → 整块跳行 → 两端对齐拉伸」。修 wordWrap=0,不要去调 w:spacing 或字号。
  • 子序号规则换代时,旧校验会误报(2026-10-23 踩坑):V6 原来只查「组内一致」,改成双分支后必须同时校验「与分支口径匹配的绝对值」,否则 480 也能过。
  • 「题干顶格」不能连续行段一起顶(2026-10-23 审计踩坑):早期 fix_stem_indent 把所有非(N)段落一律置 0,把第 10/11/25 题的第二段也顶了格。用户口径是「续行段第一个字符对齐上一行第一个中文字符」= 前缀宽度。凡是「XX 段缩进」类规则,先分「题号段 / 续行段 / 子序号 / 解析」四类再动手。
  • fix_option_gap / fix_option_numbering_gap 必须幂等(2026-10-24 3.2 节第一部分发现):两者都是
    「量宽度 → 插 N 个空格」,若段落里已有上一轮插入的空格就直接叠加,第二次 polish 必让 V17 报
    「短2长1」不符。修法是插之前先 _strip_leading_ws(kids) 清零(删纯空格 run + 剥首个文字 run 的前导空格)。
    凡是「按度量插空格」的步骤,都要先清旧再写新,否则 polish 不可重入,连跑两遍结果就不同。
  • WPS 手工拖拽会留下 335/139 这种非整值缩进(2026-10-23 踩坑):成品里出现 firstLine=335 firstLineChars=139(1.39 字符),是鼠标拖动产物。校验器必须给 ±6 twips 容差,但不能把 335 当成合法基准——归整时按规则重算。
  • fix_stem_indent 返回值变更会静默污染 stats(2026-10-23 踩坑):函数从「返回 int」改成「返回 (stem, cont)」后,stats['stem'] 会变成元组且不报错。改签名时同步改调用点。
  • 普查才能发现「技能没写但文档有」(2026-10-23 审计结论):逐段 pPr、逐 run rPr、逐个 m:* 结构件、逐张表 tblPr 做计数,比"看一遍文档"可靠。本次据此补入页面/网格、表格 tblLayout/trHeight、11 张内嵌图、m:rad、endChr 空、bCs/iCs、WPS 遗留 pPr 块。

脚本清单

脚本 职责
qjy_format.py OMML/段落构造引擎(v/n/frac/sup/sub/fA/auto_space/set_role/new_para)
qjy_tex.py 迷你 LaTeX → OMML
qjy_inspect.py 源文档结构勘察(粗看:段落/表格顺序)
qjy_census.py 全量格式普查(pPr/rPr/m:* /tblPr 逐项计数),用于核对「技能是否覆盖文档全部格式事实」
qjy_split.py 题目切分
qjy_polish.py 题目版式精修(题干顶格/选项斜体/选项表分类对齐/集合竖线/碰撞空格/子序号双分支/西文跨行/公式字体全量归位/解析内小问悬挂 2.5 字 fix_solution_subitem_indent)
qjy_metrics.py 字体度量(TTF glyf bbox → 外伸量判定),无第三方依赖
qjy_normalize.py 半公式化源文件规范化(见第 22 条):混合 run 拆分 / 公式原子化 / 相邻数学合并为单一 oMath / 上下标还原 / 手打括号结构化 / 自动编号转显式字母
qjy_zone.py 「散落字母」型半公式化重建(见 ㉔):数学区重组 / 非中文 run 拆分 / 子序号前缀拆开 / 全局括号 / 分式字号 / 括号贴紧
qjy_strip_seq.py 解析段去圆圈序号(默认全删;keep.json 指定分类讨论分支的保留与重编号;残留交叉引用告警)
qjy_verify.py 第 1–12 组总校验(含文本指纹;12.4 已删除,编号行缩进并入 V6)
qjy_verify_polish.py 第 13 组:精修二十五项校验(V1–V23,含 a/b 子项)
qjy_test_polish.py 第 13 组故障注入验证(32 个注入点,实测 29 抓获 / 3 本文件无该结构)

㉒ 「半公式化」源文件的规范化(2026-10-23 3.2 节实战)

上游文件常见三种半成品形态,qjy_verify.py 的整段 XML 指纹会全部判为"题目被改动",
必须改走「规范化」而不是「插入解析」:

形态 表现 处理
运算符外置 =、+、<、≥ 是普通 w:t run,夹在两个 m:oMath 之间 合并相邻数学片段为单个 oMath,再统一 auto_space
原子粘连 单个 m:r 里塞了 '− 4'、'4 − '、'x ' 先做「一原子一 run」拆分,否则空格引擎把整段当一个原子,间距全错
结构降级 x5(x⁵)、c1(c₁)、a1b11(a₁b₁₁)、(a)/(b) 退化成平排字符 按 token 重建 m:sSup / m:sSub / m:brk
自动编号 选项字母靠 w:numPr(numFmt=upperLetter),正文里没有 A. 文本 去掉 numPr,改为显式 A. run(TNR 斜体),否则 V2/V3/V8 全部失效

执行顺序(顺序错了要返工):

split_mixed_runs -> normalize_math_runs(+build_parens) -> merge_math_groups
-> build_parens -> auto_space_all -> clean_scripts -> trim_formula_tail
-> tidy_text_edges -> space_before_func -> numbering_to_letters
-> fix_char_format -> fix_paragraphs -> qjy_polish -> trim_formula_end

三条实战踩坑(本次验证):

  • is_math_text 必须允许空格:'= 0' 含内部空格,否则整段判成文字,公式永远合并不上。
  • 上下标判定要看「整组拼接文本」:源文档把 m 和 2 拆成两个 run,逐 run 判定得不到 m2。
  • numId 可能在同一个选项表里换号(实测一张表 r0/r1 用 numId=10、r1 用 numId=11),
    导致第二行选项重新从 A. 起编。必须按 行优先序号 重新分配字母,不能信 numPr。

验证用 --src 做宽松文本指纹:m:d 的 begChr/endChr 算正文(结构升级不算改字),
并剔除选项表开头的 A./B./C./D.。

㉓ TNR 与 Cambria Math 的分工:判据是「字形覆盖」,不是「符号类别」

唯一规则:某个字符在 Times New Roman 里有没有字形。
有 → Times New Roman(与宋体正文同族,教材观感);没有 → Cambria Math(否则会回退成方框/宋体,更难看)。

实测方法:读 C:\Windows\Fonts\times.ttf(及 timesbd/timesi/timesbi)的 cmap 表,
看码位是否落在任一 format 4/12 子表的区段内。脚本 qjy_polish._read_cmap() 零依赖实现。

实测结果(2026-10-23,Windows 自带 Times New Roman):

字符
TNR 有字形 → 用 TNR + − = < > ≤ ≥ ≠ ± × ÷ · √ ∑ ∏ ∫ ′ ( ) [ ] , . : ; ∞ ∩ → ← ↔ … ° ′ ″ ℃ α β γ δ ε ζ η θ λ μ π ρ σ φ χ ψ ω Γ Δ Θ Λ Ξ Π Σ Φ Ψ Ω A–Z a–z 0–9
TNR 无字形 → 只能 Cambria Math ∈ ∉ ⊂ ⊃ ⊆ ⊇ ∪ ∀ ∃ ⇒ ⇔ ∅ ⊥ ⋯

历史错误(两次):
1. 早期按"看起来像数学符号"人工分类,把 ∞ ∩ → 希腊字母 判成 Cambria Math —— 它们 TNR 都有,白白失真;
2. 又漏掉 ⋯(U+22EF,TNR 没有),被写成 TNR → 回退成宋体。

现在 needs_cam(ch) 运行时查 cmap,不维护任何人工字符表;读不到字体文件时才退到
_CAM_FALLBACK(与上表一致)。

> 附带结论:m:ctrlPr(分数线/括号/根号/上下标位)全部 TNR —— 结构件不渲染字符,
> 只借字体度量决定线粗与位置,用 TNR 才与正文一致。

> 例外(2026-10-24 用户第 2 条):希腊字母不走这条判据,一律 Cambria Math,
> 小写强制斜体 —— 见 ㉗。上表里「希腊字母 → TNR」已作废。

迭代与改进

用户提出新要求时:
1. 两处都要写:本 SKILL.md(判据 + 用户原话)与 references/format_spec.md(参数 + 示例)。
只改一处必然分叉 —— 2026-10-24 审计发现 format_spec.md 仍停留在「首行 2 字符 /
源文档缩进保持不动 / 希腊字母→Cambria Math」三处旧口径,与定稿直接打架。
2. 同步在 scripts/qjy_verify.py(或 qjy_verify_polish.py)加对应检查项
(否则回归会一路绿灯通过);新条目前先确认不与既有检查互斥
(12.4 与 V6 就是两把尺子互斥,导致「全绿」永远达不成)。
3. 更新 scripts/qjy_format.py / qjy_polish.py 实现
4. 重跑两个校验器确认全绿
5. 做故障注入验证:把成品文档复制一份,注入该条规则的缺陷
(例如:把 − 改成 ASCII -、把 f (x) 的空格删掉、去掉一个 m:ctrlPr、
把某个数字 run 的字体改成 Cambria Math),确认校验器会报错。
不报错的检查项等于空转,必须修到能报错为止。

㉔ 「散落字母」型半公式化:按数学区重建(2026-10-24 3.2 节第一部分实战)

qjy_normalize.py 的主链只在已有 oMath 上工作;若源文件的字母根本是普通 w:t run
(1、设f (x)是定义在R上的函数,f/x/R 全是文字),整条链不会把它们变成公式
—— V10~V15 全绿,文档却仍是「假公式」。这类文件要走 qjy_zone.py:

# 1) 先出候选清单(上下标逐处判定)
python scripts/qjy_zone.py report <源文件.docx>
# 2) 按语义填 decisions.json(键 = 候选序号,值 = sub / sup / 平)
python scripts/qjy_zone.py apply <源文件.docx> <输出.docx> decisions.json

它按「数学区」重建:把连续的 [oMath | 纯数学文字 run | 空白 run] 合并成一个 oMath,
文字段逐字符转成 m:r,再统一括号结构化、上下标还原、auto_space。

五条实测踩坑(顺序即执行顺序):

# 坑 处理
1 Z.split_mixed_runs 只拆含 CJK 的 run,'= __________.' 整段留在文字里 自写 split_nonmath_runs:无 CJK 的 run 也按 MATHCHARS 边界拆(纯空白并入相邻数学段,末尾孤立 . , 归回文字)
2 (1)若0 < a < … 整段被 ^(\d+) 护栏挡住不拆 先 split_sub_prefix 把「(N)」前缀单独切出来,剩下的再按数学/文字切
3 选项单元格 A / . 0 两个 run → A 被判成纯数学,整个选项变成公式 find_groups 里用段落字符偏移把 ^[A-D]\s*[..] 覆盖的 run 一律锁成文字
4 单个 = 因不含字母而组不进数学区,留在中文里(则实数a=____) 组的成立条件是「含 oMath 或 含字母」,相邻的纯运算符 run 会随 oMath 一起入组
5 已有 m:f/m:d 内部不参与重建,(a−2)、f(x_(1)) 仍是手打括号 重建后必须补跑 Z.build_parens(doc)(覆盖 oMath/e/num/den/sub/sup 全部容器),再 atomize_math 全量拆 m:r

配套三条收尾(缺一项 V11 组审计就会挂):set_fraction_size(分子分母 run 统一 sz=30)、
tighten_cl_ord((3k − 2)x 的右括号与 Ord 之间不保留空格)、
space_after_trailing_rel(a = __________ 的等号后补空格)。

引擎级 bug(已修):qjy_format.auto_space 原来写
for cont in om.iter(): auto_space_container(cont) —— 边遍历边增删子节点,
om.iter() 会跳过后续兄弟容器,分式分子/分母的空格因此常年不生效(27% 的段落受影响)。
必须写 for cont in list(om.iter())。同理,凡「遍历文档并修改」一律先 list()。

判定口径:x1 / x2 既可能是下标也可能是平方,不能靠上下文启发式(kx²+2kx
与 总存在 x₂ 在同一段里)。做法是先把全部候选按序号列出、逐条按数学语义填表,
再按序号应用;序号在 report 与 apply 两趟里严格一致。

㉖ 上下标公式的边界空格 + 选项间距(2026-10-24 用户第 5、6 条 + 第 11 条两次强调)

(1)上下标公式与汉字之间必须空一格 —— 本条第 11 点重申,此前的实现只覆盖了三分之一。

> 用户原话(第 5 条):凡是带有下标的公式与左侧正体文字有点视觉碰撞,
> 可以在下标公式的前方加一个公式空格,拉开距离

>
> 用户原话(第 11 条,本次):正文与上下标的公式紧邻的时候要加公式空格来使视觉舒服一点,
> 但我发现本次修改并没有执行
—— 附 若正数 a , b 满足 a² + b²/2 = 1 与
> 则 x² + y² 的最小值是 两张样板。

项 规定(第 11 条定稿,覆盖三向)
触发结构 公式首原子或末原子是 m:sSup / m:sSub / m:sSubSup 三者之一
触发位置 左侧与右侧都要管(早期只写了「前方」,右侧贴汉字同样挤)
相邻字符 只对汉字([\u3400-\u9fff]);中文标点自带留白,加了变双空
动作 公式内部首端 om.insert(0, spacer) / 尾端 om.append(spacer),用 Q._spacer_run
实现 qjy_polish._is_script_edge() + add_collision_spaces() 左右两个边界分支
校验 V16(左/右双分支)

第 5 条为什么没生效(根因,别重犯):旧实现叫 _starts_with_subscript(),
里面对原子 tag 的判断是 return tag == 'sSub' ——
- 只认 sSub(下标),sSup(上标)整类漏掉;而题目里上标出现频率远高于下标(x²、a²、b² 满地都是);
- 只判公式首端,右侧不判;则x² + y²的最小值 这种「上标贴汉字」正是右端漏网。

配上 _visible_leaf 取到的是上标里的数字 2 → _is_italic_letter 判 False → 连斜体度量那一路也不会兜底。
两条路都断,于是 8 处全漏。 结论:上下标的外伸来自字号缩放(默认 65%),
不是字形自带的 side bearing,永远只能结构判定,不要指望度量表。

实测(第 9 题,300dpi):修复前 则|x² 与 y²|的 均贴死;修复后两侧各留 ~1 格,与用户样板一致。
全篇 9 个边界(8 处位置,其中第 9 题左右各一)。

(2)选项字母与内容的间距(用户原话:选项符号与答案距离过近,如 A. 与 0 的距离过近,
建议中间用几个空格来拉开距离
;2026-10-24 第 7 条修正为按内容长短分档)

内容长短(视觉宽度) 间距 例
很短:≤ 8 个半角当量 2 个半角空格 A. 0、B. 1、C. [0,1]
长文本:> 8 1 个半角空格 A. “存在 a,b∈[1,2],使得 a<b 且 f(a)=f(b)”

> 用户原话(第 7 条):如果选项内容是长文本则需要加一个空格,如果选项内容很短需要加两个空格。
> 第 2 条那版按「首字符是否中文」分档是错的 —— A. “存在…” 是中文开头但内容很长,
> 给 2 个空格会显松;正确判据是整条内容的视觉宽度。

  • 宽度计算必须量整个选项内容(w:t + 公式 m:t 按顺序拼),不能只量第一个 run:
    选项常被拆成 r + oMath + r + oMath … 十几个节点,只量首个 run 会把
    「在−2,0上是严格增函数」(w=21)误判成「很短」。
  • 实测分布:短项 w=1(0/1/2/3),长项 w≥21,中间无模糊地带,阈值 8 很稳。
  • 根因:一个 12pt 的 TNR 空格只有 0.25em(3pt),顶在句点后面几乎看不见,
    而数字本身就有 6pt 宽,看起来就像"没空格"。
  • 实现:qjy_polish.fix_option_gap() + _want_option_gap() + _kids_text(),
    在 fix_option_letters() 之后跑(那时 A. 才成形)。
  • 校验:V17(同一套宽度判据,避免「生成」和「校验」两套口径打架)。
    3.2 节实测 12 处(三张 1×4 选项表),2×2 / 4×1 长文本表各 1 处保持 1 个空格。

⚠️ 混合制陷阱:A 手写、B/C/D 自动编号(2026-10-24 第 7 条连带修复)

同一道题的选项可能是两套机制拼起来的:源文件里 A 项是手写 A.(numId=0),
B/C/D 项靠 w:numPr + numbering.xml 的 upperLetter 自动编号生成。

现象 根因
A 有 2 个空格、B/C/D 只有 1 个 自动编号的间隔由 w:suff 决定,只支持 tab/space/nothing,space = 固定 1 个半角空格,给不出 2 个
校验器以为 B/C/D「没有间距」 自动编号段没有手写字母 run,^[A-D]\.$ 匹配不到 → V17 整类段落空转

修法(fix_option_numbering_gap()):
1. 把用到的 abstractNum/lvl 的 w:suff 改成 nothing(suff 的法定位置紧跟 w:numFmt 之后);
2. 在段落文本前显式插 _spacer_w_run() 空格 run,空格数走同一套 _want_option_gap()。

校验必须分两支:手写段查「字母 run 之后 run 的前导空格数」,自动编号段查
整段文本的前导空格数(inj_v17 与 inj_v17b 分别注入一次,否则必有一支空转)。

(3)序号与正文之间不留空格(2026-10-24 第 7 条)

用户原话:同一个文档中处理该序号与文本的方式不一致,第一张截图是有空格,第二张截图是没有截图,
需要重新指定一下,现在规定两者之间没有空格
。

项 规定
范围 题号 12、 / 子序号 (1) / 圈码 ①②③ 之后
规定 不留任何空格(半角空格、全角空格、NBSP、Tab 全删)
根因 源文件本身就两种写法混用(第 1 题写 ① 若存在,第 3 题写 ①若存在),不是重建引入的
实现 qjy_polish.fix_seq_gap():按段落字符流定位序号结尾,跨 run 删 [i,j) 区间
校验 V18

⚠️ 只能用字符偏移删,不能用「对首个 run 做 lstrip」——序号和空格常分属两个 run
(① 在一个 run,空格在下一个 run),只 strip 首个 run 会漏。

㉕ 解析首行必须写「解析:(考点)」(2026-10-24 第 21 条)

用户原话:
> 现在我要加一条特殊命令,题目的解析的第一行不能单独写解析,要写:「解析:(题目的考点)」。

项 规定
首行内容 解析:(考点),考点用 2–12 字概括本题考什么
排版 独占一行(整段只有这一句),过程从第二段起,逐段 ①②③ 展开
颜色/加粗 与其他解析段一致:深绿 00B050 + 加粗
反例 解析:(空考点)、解析:(考点)由题意……(首行夹带过程)
  • 考点写在中文括号内;考点里若有字母或公式(f(ax+b)、R、f(f(x))=x),
    必须拆成 ('m', ...) 段,不能留在中文 run 里 —— 否则渲染成宋体正体。
  • 校验:qjy_verify.py 第 12.5 组(解析首行 %d 段,不合「解析:(考点)」的: 0);
    故障注入两种(只写 解析:、考点后夹带过程)均已抓获。
  • 3.1 节实测:27 题全部按「解析:(考点)」+ ① ② ③ 分步展开,共 160 段解析。

> 2026-10-23 实测:8 项故障注入(ASCII 减号 / 裸 eq / 中文句号 / 缩进缺失 /
> 斜杠分式 / 函数名空格 / 缺 ctrlPr / 字体错位)全部被捕获,检查器非空转。
>
> 2026-10-23 第二批:第 13 组六项(题干缩进 / 选项斜体 / 表缩进 / 集合竖线 /
> 碰撞空格 / 子序号对齐)故障注入 6/6 抓获。
>
> 2026-10-23 第三批(对齐三修订 + 续行段):第 13 组扩到九项,新增 V7 西文跨行、V8 一行四列居中、V9 续行段,
> 并把 V3 从「仅 4×1」扩到「4×1 + 2×2」、V6 从「组内一致」改到「与双分支口径相符」,
> 故障注入 8/8 抓获。
>
> 2026-10-23 第四批(公式字体):用户第二次指出「该 TNR 的公式还是 Cambria Math」。
> 根因不在 m:r,而在 639 个 m:ctrlPr 全是 Cambria Math —— 分数线/括号/根号/上下标的字形
> 全由 ctrlPr 决定。现已把 fix_math_fonts 做成 polish() 的最后一步全量重刷(不是"错才改"),
> 并新增 V10a / V10b 两项校验 + 故障注入(11/11 抓获)。
> 教训:"某类字符字体错"要往结构件追,不能只看叶子 run。
>
> 2026-10-24 第五批(3.2 节第一部分:函数的单调性):用户指出「f()/g()/h() 参数内不是 TNR」
> 「全文没有加粗」。实测这两条在源文件里根本没跑过 polish(615 个 ctrlPr 全 Cambria、
> 117 个 run 未加粗);只跑 polish_file 一遍即 17/17 全绿。真正的结构性欠账是
> 180 处散落字母(见 ㉔),重建后 m:r 1082→1953、Cambria Math 2111→114(只剩无 TNR 字形的符号)。
>
> 2026-10-24 第六批(同文件的第二轮截图反馈,3 条):
> ① 上下标与汉字碰撞(x²、x₁ 贴中文)→ 首原子或末原子为 m:sSup/m:sSub/m:sSubSup
> 时补公式空格,左右两侧都补(见 ㉖-1,V16);
> ② 选项字母与数字过近 → 非中文内容改 2 个半角空格(见 ㉖-2,V17);
> ③ 公式把行撑高、自动换行后回到页边 → 题号段改悬挂缩进 left=前缀宽 / firstLine=−前缀宽,
> 任何换行位置都自动对齐题干首字(见 ㉑)。顺手修掉 _set_ind 的一个静默失效:
> 带 firstLineChars 时负 firstLine 会被忽略。
> 教训:「让文字对齐」优先用段落属性(左缩进),不要用插换行符或按估算宽度拆段 ——
> 段落属性对所有换行位置一次性生效,估宽只能赌一次。

>
> 2026-10-24 第七批(同文件第三轮截图反馈,3 条,部分推翻第六批):
> ① 序号与正文之间不留空格(见 ㉖-3,V18)。根因不是我们插入的,是源文件本身混用
> 两种写法(第 1 题 ① 若存在、第 3 题 ①若存在)。修法只能按字符流偏移跨 run 删,
> lstrip 首个 run 会漏(序号和空格常分属两个 run)。实测删 3 处。
> ② 选项间距改按内容长短分档(见 ㉖-2,V17 改判据)。第六批按「首字符是否中文」是错的:
> A. “存在…” 中文开头但很长,2 个空格显松。新判据 = 整条内容视觉宽度 ≤ 8 → 2 格,否则 1 格。
> 关键坑:宽度必须量整格内容(w:t + m:t),只量首个 run 会把 w=21 的长项误判成短项
> —— 第一版就是这么错的,V17 跟着一起错,两边口径一致反而互相掩护。
> ④ 混合制选项:A 手写字母、B/C/D 自动编号(w:numPr),而 w:suff=space 固定 1 个空格,
> 给不出「短 2 格」。改 suff=nothing + 显式插空格 run 才统一(见 ㉖-2 的「混合制陷阱」)。
> V17 因此拆成手写 / 自动编号两支,inj_v17b 专门测后者。
> ③ 悬挂缩进全部废弃,改整段左缩进(见 ㉑,V1/V9 改判据)。用户原话:「能采用首行缩进的,
> 不要采用悬挂缩进」「正常跨行无需更改,只有公式跨行影响美观时才另起一行且与上一行对齐」。
> → left=前缀宽, firstLine=0,首行与续行齐平。
> 可行性结论(重要):「公式跨行是否影响美观」无法在 OOXML 层预判。实测按 m:t 文本
> 估宽,某段算出 49620 twips、而页面可用行宽只有 9638 twips(高估 5 倍),因为分式/根式/上下标
> 的真实占宽与文本长度不是线性关系、还受字体度量与断行算法影响。所以不做自动拆行,
> 一律用「整段左缩进」让任何断点都落在同一 x;个别段落确需另起一行时手工处理。
> 教训:能靠段落属性一次性解决的,绝不靠估算渲染结果去拆段落。
>
> 2026-10-24 第八批(用户对第七批方案只回四个字:「不可接受」):
> 第七批的整段左缩进把题号整体推右 1.5–2 字符,用户不接受。
> → 定稿:题号段完全归零(left=0 / firstLine=0),缩进一律用 firstLine,
> 续行段写 firstLine=前缀宽(见 ㉑ 的三版对照表)。
> 认识升级:「续行与题干首字对齐」是续行段自己的事,不需要动题号段。
> 前两版都在折腾题号段的 left,所以怎么调都不对 ——
> left 是「整段推右」,firstLine 才是「只动第一行」。
> 同步:fix_subitem_indent() 的(1)(2)(3)也由 left=anchor 改为 firstLine=anchor;
> V6 增加「left 必须 0 + 禁止悬挂」两条分支,注入点 21 → 25(V1b/V6b/V9b/V9c)。

详细规范见 references/format_spec.md。


㉗ 希腊字母:强制 Cambria Math + 小写强制斜体(2026-10-24 用户第 2 条)

用户原话:「希腊字母 α 和 β 要采用 Cambria Math 斜体」。

㉓ 的「TNR 有字形就用 TNR」对希腊字母不成立。TNR 里的 α/β 是正文级字形:
字重偏轻、斜度小、x-height 低。同一条公式里 α 和 x 并排时,α 会显得又小又硬,
在加粗教材体里尤其明显(用户反馈的就是「视觉碰撞」的那一类不协调)。

规则
字体 α β γ δ ε ζ η θ λ μ π ρ σ φ χ ψ ω 与 Γ Δ Θ Λ Ξ Π Σ Φ Ψ Ω 一律 Cambria Math
样式 小写斜体(m:sty="i",加粗时 "bi";w:i 开)
样式 大写正体(m:sty="b";w:i 关)—— Γ Δ Σ 是集合/算子名,教材惯例直立
例外 位于 f()/g()/h() 参数内 → 仍 TNR(第 23 条,函数参数字体统一优先)

实现:qjy_polish._want_font(txt)(字体)+ _desired_sty()(样式)双处改;
normalize_rpr() 由 m:sty 反推 w:i,两处必须同时改,否则 m:nor 生效后
字体对了、斜体没生效(这正是本条的故障注入点)。校验项 V20,故障注入已做。

> 排查口诀:Cambria Math 的字形斜不斜由 m:sty 决定,不由字体文件决定 ——
> Cambria Math 只有一套字形,正/斜是数学排版引擎按 m:sty 挑的。


㉘ 上标避碰:f² 的 0.193em 外伸(2026-10-24 用户第 3 条)

用户原话:「对于 f²(x) 要注意 f 与 2 的距离,现在存在视觉碰撞」。

成因:OMML 把上标锚在基底的 advance width(字身框右缘),
而斜体 f 的墨迹右侧还要溢出 0.193em(qjy_metrics.overshoot('f'))。
上标抬高约 65%、缩小到 8.5pt,那个 2 的左边就正落在 f 的上钩里 ——
实测重叠 2.12pt(12pt 正文)。x²/e^x 不犯:x、e 右上角是空的(外伸 0.045em / 0)。

为什么不在 m:e 末尾补空格:那会把整个基底撑宽,后面的 (x) 一起被推远
(f² (x));补在 m:sup 首位只推上标,f²(x) 整体仍紧凑。

宽度必须算,不能用一个空格了事:
脚本位是等比缩放的(12pt → 8.5pt,比例 0.708),一个 0.25em 空格只有 2.0pt,
仍小于 2.32pt 的外伸 —— 第一版就栽在这里(量出来还差 0.08pt 才不碰)。
定稿做法:w:sz 加 6 半磅(12 → 15pt),脚本位渲染成 10.6pt,空格宽 2.65pt,
扣掉 2 自身的左边距后留出约 0.5pt 光学间隙。实测坐标(out3):
f 原点 336.48 + 字宽 3.96 + 外伸 2.32 = 墨迹右缘 342.76,2 墨迹左缘 343.28。

判据 值
触发条件 m:sSup/m:sSubSup 基底末字符为斜体拉丁小写字母,且 FM.collides_right(ch) ≥ 0.10em
实测命中 只有 f(j 右伸 0.002、p 右伸 0、d/k 0.04 —— 都不够)
修法 m:sup 首位插一个 Q._spacer_run(),再 _widen_spacer(run, +6)
不处理 m:sSub(下标在基线下方,冲突的是基线以上)

实现 qjy_polish.fix_sup_kern(),在 polish() 里紧跟 add_collision_spaces;
校验项 V21(只看右侧外伸,与 V5 同一把尺子)。


㉙ 解析段「去圆圈序号」(2026-10-24 用户截图反馈,铁律 30)

用户原话(附截图:① 判断说法… / ②A错… / ③B错… / ④C错… / ⑤D对…):
> 如非分类讨论用于区分,否则无需用圆圈序号,现在的题目解析中出现大量的序号。

问题不在"有号",在"每段都挂号":解析的每一段都以 ①②③ 开头时,读者看到的是一份
编号清单,而不是一段论证;而中文里"另起一段 + 首行缩进"本身已经承担了分段的功能。

情形 处理 例(3.1 节实测)
默认:步骤段、选项辨析段、结论段 删号(连同号后空白) ②A错:… → A错:…;① 图③中… → 图③中…
分类讨论的并列分支 留号,并从 ① 重编号 ② 当m = 0时… / ③ 当m ≠ 0时… → ①当m = 0时… / ②当m ≠ 0时…
段落自带区分文字(情形一、对于A、图②、(1)) 删号,文字已足够区分 第 4、21、24、26 题
铺垫段(推导前提)与结论段(综上、故选D、故答案为) 删号 全部

判定口径一句话:看"文字本身能不能区分"。能区分就不需要号;只有"当…时/若…"这类
并列分支,号才真正承担区分功能。

实施(scripts/qjy_strip_seq.py <源> <输出> [keep.json],要点三条):

  1. 按字符偏移跨 run 删,不能 lstrip 首个 run —— 号与正文常分属两个 run
    ('②' 一个 run、' 当' 下一个 run,或 '② 当' 同 run)。删完再清行首空白。
  2. 删空的 run 要 remove(),但只删不含 w:tab/w:br/w:drawing 的空 run。
  3. 保留分支的段落:改首字符 + 去掉号后空格(V18 要求号后无空白)。

删号会引出两类连锁问题,必须一起处理:

连锁问题 现象 处理
交叉引用失效 再由②得、将②代回原式 指向已删除的号 改成 再由该式得、将上式代回原式(实测 2 处)
(N) 小问被误判成子序号 原来以 ①② 开头的解析段删号后暴露成 (1)由…,V6 拿"1.5 字符"的尺子来量,报「首行 480 期望 360」 V6 只查非解析段(_is_green() 跳过);解析里的(N)小问与解析正文统一 2 字符缩进

校验器的两处口径修正(本次定稿):

  • qjy_verify_polish.v6_subitem:跳过深绿段落 —— "子序号对齐题号前缀宽"是题目区的规则,
    解析区有自己的缩进口径,两把尺子不能同时要求同一段落。
  • qjy_verify.font_for:改为直接调 qjy_polish._want_font()(含希腊字母 → Cambria Math),
    不再走 needs_cam()(只查 TNR 有无字形)。否则 Δ 会被判成 TNR,与 V10b/V20 互斥。
  • qjy_test_polish.inj_v20b:只对小写希腊字母注入 —— 大写 Δ 本来就是正体,
    把 m:sty 改成 b 恰好是正确状态,注入等于没注入,会误报"检查项空转"。

实测(3.1 节练习题解析):107 个带号段 → 删 101、保留 6(第 7、8、22 题各 2 个分支)。
其余 8 处一起修掉的继承缺陷:题号段 *Chars 压制 27 处、续行段 7 处、子序号锚点 3 处、
Δ 字体 4 处、选项间距 2 处、第 2 题集合名与题干不一致(A/B → M/N)8 个字母。
交付前两个校验器全绿(V1–V21 全 OK、12 组全 PASS、题目指纹 51/51 一致),
故障注入 29 项全部有效。

> 通读是最后一道关:删号后逐题读一遍,重点看段首是否自成一句、
> 有没有指向不存在序号的"②/③"。机器查不出这类语义问题。

㉚ 函数记号写全:描述单调性不许用裸 f(2026-10-24 用户第 22 条,铁律 31)

用户原话 + 截图(…一支递增,故 f 在 R 上只能是整体递增,f 是裸的):
> 写解析的时候,描述单调性不可以用 f 来代替 f(x),要保持完整。

口径:只要这句话在说「函数的性质」,主语就写全 f(x) / g(x):

场景 错 对
区间单调性 f 在 [0,2] 上递增 f(x) 在 [0,2] 上递增
值域 / 最值 f 的值域是 (a,a+1/6] f(x) 的值域是 (a,a+1/6]
整体性质 故 f 在 R 上整体递增 故 f(x) 在 R 上整体递增
两函数比较 f 的最大值小于 g 的最大值 f(x) 的最大值小于 g(x) 的最大值
换元之后 g 在 [1,2] 上递减 g(t) 在 [1,2] 上递减(自变量是新元)
复合外层 外层 f 在 [-9,9] 上递增 外层 f(u) 在 [-9,9] 上递增

为什么:f 只是函数名,f(x) 是它在一个一般点上的值;「f 递增」严格说是把函数名
当变量用,学生照抄会养成 f<g 这种写法。写全也顺手交代了自变量是谁(换元后尤其重要)。

实现:在解析源码里把 $f$ → $f(x)$、$g$ → $g(x)$ 全量替换;
$h$关于$a$递增 这类写成 $h(a)$随$a$增大而增大(连「关于…递增」一起改掉更清楚)。
校验项 V23:解析段落中任何数学段文字恰为 f/g/h/F/G 即 FAIL(实测 639 个公式段)。

㉛ 解析内小问行:序号在 1.5 字,正文与续行在 4.0 字(2026-10-24 用户第 23 条,铁律 32)

用户原话(附截图,红竖线画在 (1)│令 处):
> 序号(1)所在行首行缩进 1.5 字符;后续还是按照首行缩进 2.0 字符,
> 从而保证两行的首个字符是对齐的。

先用字形度量把话说准(WPS 导出 PDF 实测,12pt):

片段 起点 → 终点(pt) 折算字符
( 74.64 → 86.64 1.0(全角左括号)
1 86.64 → 92.64 0.5(半角数字)
) 92.64 → 104.64 1.0(全角右括号)
令(标记后的第一个字) 104.64 起 4.0(= 1.5 + 2.5)

所以「标记宽」= 2.5 字,「序号后第一个字」= 1.5 + 2.5 = 4.0 字。
要让续行与它对齐,只能用左缩进 + 悬挂这一种写法:

w:ind  w:left="960"  w:hanging="600"      (960 = 4.0 字,600 = 2.5 字,1 字 = 240 twips)
行 位置 算式
首行((1)令…) 1.5 字 left − hanging = 960 − 600 = 360
正文与续行 4.0 字 left

为什么不能用 firstLine 表达:firstLine 只动第一行,续行永远回到 left;
若 left=0,续行就掉到页边(本次实测的原始病灶:firstLineChars=200 + left=0,
续行落到 0 字符处,与「令」差了 4 个字)。要正文与续行同处一位,只能靠悬挂。

⚠️ 两个坑:
1. firstLineChars / hangingChars 一旦与 firstLine / hanging 并存会互相压制,
写悬挂前必须把 firstLine、firstLineChars、hangingChars 全部 pop 掉(_set_ind() 已处理)。
2. 这条只管解析区。题干区的子序号仍是「left=0 + firstLine=360」(铁律 8/⑲B),
V6 会跳过深绿段落——两套尺子量两个区,别互相套用。

实现:qjy_polish.fix_solution_subitem_indent()(挂在 polish() 里,
统计键 subsol)。标记宽由文本推:(N) → 240×(2+0.5×位数),① → 240。

校验:V22(左 = 360+标记宽、悬挂 = 标记宽、不得残留 firstLine)。
实证:把某 (1) 段塞满填充文字强制折行后渲染,首行 74.64pt、续行 104.64pt,
与「令」字严格齐平。

圈码 ①②③ 是例外:走普通 2 字符首行缩进(2026-10-24 用户第 25 条)

用户原话(附①②③三行截图):
> 圈①、圈②作为一行的首个字符,正常采用首行缩进 2 字符即可。

标记 标记宽度 首个字符落点 写法
(1) 2.5 字(全角( + 半角数字 + 全角)) 1.5 字 left=960 + hanging=600
①②③ 1 字(单个全角字形) 2.0 字 left=0 + firstLine=480

判据是"标记占多宽",不是"看起来像不像序号":悬挂的作用是让续行落到标记右缘,
标记只有 1 字宽时,4.0 字符的正文起点根本没有第二行可对齐(这类段落也很少折行),
硬套悬挂只会把段首从 2.0 拽到 1.5 —— 肉眼看到的就是「这一行缩进不足」。

实现:_mark_width_tw() 只认 (N),不再把圈码当标记;圈码段在
fix_solution_subitem_indent() 里显式归位 left=0 + firstLine=480
(显式写是为了纠正历史文档里已存在的悬挂值)。_set_ind() 写 firstLine 时同步
pop 掉 hanging/hangingChars,两个方向互斥才算干净。

V22 因此拆两支:(N) 查「左 360+标记宽 / 悬挂=标记宽 / 无 firstLine」,
①② 查「left=0 / 无悬挂 / firstLine=480」。注入点同步加 inj_v22b(给圈码段塞悬挂),
否则新分支空转 —— 第一次跑就报了「漏检」,原因是 ALIAS 里没登记 V22b → V22。

常见问题

5 问
答案由本站依据条目字段整理
数学讲义Word解析排版怎么安装?

在支持 Skills 的客户端里执行:skillhub install user_3789394c/qjy-math-formatting。安装方式为未标注。装完刷新技能列表或重开客户端。

数学讲义Word解析排版支持哪些客户端?

资料没有标注宿主客户端,但只要客户端支持 Skills(Claude、Codex、Cursor 等),一般都能加载。

数学讲义Word解析排版权限高吗,安全吗?

权限级别为未标注权限。安装前先看它会读取或写入哪些内容。

数学讲义Word解析排版收费吗?

本站不代收费用,也没有标注具体价格。是否收费取决于技能本身和所用的模型或客户端,正式使用前按作者说明确认。

数学讲义Word解析排版用什么许可证?

许可证标注为 未说明(许可证不明确)。商用或二次分发前请到仓库确认条款原文。

热门专题

按场景挑技能
全部专题 →

技能内容以作者仓库为准;分类、权限级别与许可证状态的中文说明为中文整理,最近核验 2026-11-06。