
1. 从一份混乱的修复清单说起LaTeX 项目写久了最怕的不是写公式而是改完一轮之后编译日志里躺着几十条 warning辅助文件散落一地.aux、.log、.out、.bbl混在源码目录里分不清哪些是真正需要提交的哪些是编译产物。更麻烦的是当你想把一次修复过程整理成可复用的经验时会发现改动散落在多个文件里靠记忆根本说不清楚。我最近在整理一个论文模板项目时就遇到了这个问题。项目里有自定义的页眉字号、上标引用格式、图片路径引用还有一堆历史遗留的辅助文件。手动改完一轮之后我意识到如果能把“候选修复”和“确认修复”这两个阶段分开用一套结构化的技能Agent Skills来组织整个流程后续维护会轻松很多。TeXada Studio 这个思路正好切中了这个痛点——它不是单纯做一个 LaTeX 编辑器而是把修复动作拆成“候选生成”和“确认落地”两个阶段中间用 Monaco diff 做可视化比对底层用 Python 脚本驱动文件操作。这篇文章不打算讲空泛的概念而是把我实际跑通的一套流程拆开来讲。从环境准备、技能定义、候选生成、diff 比对到确认写入和辅助文件清理每一步都会给出可复现的操作和参数说明。如果你也在维护 LaTeX 模板或者想用 Agent Skills 的思路来组织重复性修复工作这篇内容可以直接抄作业。2. 整体设计思路为什么要把修复拆成两个阶段2.1 候选与确认分离的核心逻辑传统做法是发现一个问题直接改文件改完编译编译不过再回滚。这个流程在单人、单次修改时没问题但一旦涉及批量修复或者多人协作就会暴露两个问题。第一改动不可追溯你不知道这次改的是哪几个文件、哪几行第二回滚成本高改错了只能靠版本控制或者手动撤销。TeXada Studio 的思路是把修复动作拆成两个独立阶段。第一阶段叫“候选生成”Python 脚本扫描项目文件根据预设规则找出需要修改的位置生成一份候选清单但不直接写入源文件。第二阶段叫“确认落地”用户在 Monaco diff 界面里逐条查看候选改动确认无误后再写入。这两个阶段之间用一份结构化的候选数据做衔接格式可以是 JSON 或者 YAML。这样做的好处很明显。候选阶段可以反复跑不会污染源文件确认阶段可以逐条比对避免误改整个流程的中间产物可以存档方便回溯。我实测下来对于一个包含 30 多个.tex文件、200 多条待修复项的模板项目这套流程把误改率从原来的大概 15% 降到了接近零。2.2 为什么选 Monaco diff 做比对界面比对界面有很多选择比如直接用diff命令输出、用 VS Code 内置的 diff、或者自己写一个简单的文本对比。我最终选 Monaco diff原因有三个。第一Monaco 是 VS Code 的底层编辑器组件对 LaTeX 这种混合了文本和公式的语法有天然的高亮支持。你不需要额外配置就能看到\section、\cite、$...$这些结构的颜色区分。第二Monaco diff 支持行内 diff也就是在同一行里标出具体哪个字符变了这对于修改页眉字号这种只改一个数字的场景特别有用。第三它是纯前端组件可以嵌在任意 Web 界面里不依赖本地编辑器环境。当然Monaco 也有代价。它的包体积不小首次加载大概需要 2 到 3 秒。如果你的项目只有十几个文件用简单的文本对比就够了。但如果你要处理的是几十个文件、上百条改动Monaco 的行内 diff 和折叠功能会省很多时间。2.3 Python 在流程里的角色定位Python 在这套流程里不是用来做“智能修复”的而是做“规则化扫描和文件操作”。具体来说它负责三件事扫描项目目录、匹配修复规则、生成候选数据。修复规则可以用正则表达式写也可以用简单的字符串匹配。比如“把页眉字号从\small改成\footnotesize”这种规则用正则匹配\small出现的位置就行。为什么不把修复逻辑放在前端因为文件读写、目录遍历这些操作在前端做不安全也不方便做批量处理。Python 脚本跑在本地或者服务端生成候选 JSON 之后传给前端展示确认后再由 Python 执行写入。这样前后端职责清晰也方便后续把规则做成可配置的。3. 环境准备与基础配置3.1 LaTeX 环境的最小可用配置这套流程不依赖特定的 LaTeX 发行版TeX Live 或者 MiKTeX 都可以。我本地用的是 TeX Live 2024安装的时候选的是scheme-full因为模板里用到了ctex、geometry、fancyhdr这些包完整安装省得后面缺包再补。如果你磁盘空间紧张可以先装scheme-basic然后按需用tlmgr install补包。安装完之后验证一下pdflatex和latexmk是否可用。latexmk不是必须的但它能自动处理多次编译和 bib 引用后面清理辅助文件的时候会方便很多。在终端里跑pdflatex --version latexmk --version如果这两个命令都能输出版本号说明基础环境没问题。接下来配置 VS Code 的 LaTeX 插件。我用的组合是 LaTeX Workshop 加latexmk作为编译工具。在settings.json里加一段配置{ latex-workshop.latex.tools: [ { name: latexmk, command: latexmk, args: [ -synctex1, -interactionnonstopmode, -file-line-error, -pdf, %DOC% ] } ], latex-workshop.latex.recipes: [ { name: latexmk, tools: [latexmk] } ] }这段配置的作用是让 LaTeX Workshop 用latexmk编译并且开启-synctex1这样在 PDF 和源码之间可以双向跳转。-interactionnonstopmode是让编译遇到错误不中断方便一次性看到所有问题。3.2 Python 环境与依赖安装Python 版本建议 3.10 以上因为后面用到的pathlib和dataclasses在 3.10 里更稳定。安装 Python 的时候记得勾选“Add Python to PATH”不然后面在终端里调python会找不到。装完之后验证python --version pip --version依赖方面核心只需要两个库pyyaml用来读写候选数据watchdog用来监听文件变化可选。安装命令pip install pyyaml watchdog如果你打算把候选数据存成 JSONpyyaml也可以不装用标准库的json就行。我选 YAML 是因为它支持注释候选文件里可以写清楚每条规则的来源和意图后面回溯的时候方便。3.3 项目目录结构约定为了让扫描脚本能准确识别哪些是源文件、哪些是辅助文件目录结构需要有个约定。我用的结构是这样的project/ src/ main.tex chapters/ intro.tex method.tex styles/ header.sty build/ main.pdf main.aux main.log candidates/ fix-2024-01-15.yaml scripts/ scan.py apply.pysrc/放所有源文件build/放编译产物candidates/放候选数据scripts/放 Python 脚本。这个结构不是强制的但有了它之后扫描脚本只需要遍历src/目录清理脚本只需要删build/目录里的内容不会误伤源文件。注意如果你的项目已经在版本控制里记得把build/和candidates/加到.gitignore。候选文件虽然有用但它是中间产物不需要提交到仓库。4. Agent Skills 的定义与候选生成4.1 什么是 Agent Skills 以及为什么用它Agent Skills 这个概念最近在自动化工具圈里讨论得比较多简单说就是把一个可复用的操作封装成“技能”每个技能有明确的输入、输出和执行逻辑。在 LaTeX 修复这个场景里一个技能可以是一条修复规则比如“把页眉字号从\small改成\footnotesize”也可以是一组相关规则的集合比如“统一所有章节文件的引用格式”。用技能的方式来组织修复逻辑好处是规则和代码分离。你不需要为了加一条新规则去改 Python 脚本只需要在技能配置文件里加一段 YAML。这样即使不懂 Python 的人也能通过改配置来调整修复行为。我实测下来一个包含 20 多条规则的技能配置从零开始写大概需要半小时但后续维护成本几乎为零。4.2 技能配置文件的字段设计技能配置文件我用 YAML 写每个技能包含以下字段skills: - id: header-font-size description: 统一页眉字号为 footnotesize target: src/**/*.tex match: \\\\small replace: \\\\footnotesize context: fancyhdr severity: warning enabled: true字段说明id技能唯一标识用于在候选数据里引用。description人类可读的描述方便在 diff 界面里展示。target匹配的文件路径支持 glob 模式。match匹配的正则表达式。注意 YAML 里反斜杠需要转义所以\small要写成\\\\small。replace替换后的内容。context可选的上下文关键词只有包含这个关键词的文件才会被扫描避免误改。severity严重级别用于在候选列表里排序。enabled是否启用方便临时关闭某条规则。这个设计的核心是context字段。LaTeX 项目里\small可能出现在很多地方不只是页眉。加上context: fancyhdr之后只有同时包含fancyhdr的文件才会被匹配误报率大幅降低。4.3 扫描脚本的核心逻辑扫描脚本用 Python 写核心逻辑分四步遍历目标文件、读取内容、逐条应用技能规则、生成候选数据。下面是简化后的代码import re import yaml from pathlib import Path from dataclasses import dataclass, asdict dataclass class Candidate: skill_id: str file_path: str line_number: int original: str replacement: str description: str def load_skills(config_path): with open(config_path, r, encodingutf-8) as f: data yaml.safe_load(f) return [s for s in data[skills] if s.get(enabled, True)] def scan_file(file_path, skills): candidates [] content file_path.read_text(encodingutf-8) lines content.splitlines() for skill in skills: if skill.get(context) and skill[context] not in content: continue pattern re.compile(skill[match]) for i, line in enumerate(lines, start1): if pattern.search(line): candidates.append(Candidate( skill_idskill[id], file_pathstr(file_path), line_numberi, originalline, replacementpattern.sub(skill[replace], line), descriptionskill[description] )) return candidates def scan_project(src_dir, skills): all_candidates [] for tex_file in Path(src_dir).rglob(*.tex): all_candidates.extend(scan_file(tex_file, skills)) return all_candidates这段代码的关键点是context检查放在行遍历之前先判断整个文件是否包含上下文关键词不包含就直接跳过。这样对于大项目来说能省不少时间。另外line_number从 1 开始计数和 Monaco diff 的行号对齐。4.4 候选数据的存储格式扫描完成后候选数据存成 YAML 文件结构如下generated_at: 2024-01-15T10:30:00 project_root: /path/to/project total_candidates: 42 candidates: - skill_id: header-font-size file_path: src/styles/header.sty line_number: 15 original: \\small replacement: \\footnotesize description: 统一页眉字号为 footnotesize status: pendingstatus字段初始为pending用户在 diff 界面确认后改成accepted或rejected。这个字段是后续写入操作的依据只有accepted的候选才会被应用到源文件。实操心得候选文件建议按日期命名比如fix-2024-01-15.yaml。这样每次扫描生成一份新文件不会覆盖之前的记录。如果某次扫描结果不理想可以直接删掉对应的 YAML不影响源文件。5. Monaco diff 比对界面的实现5.1 界面布局与交互设计Monaco diff 界面我做成左右分栏左边是原始内容右边是替换后的内容中间用 diff 装饰器标出改动行。顶部放一个候选列表每条候选显示文件名、行号、技能描述和状态。点击某条候选下面的 diff 区域自动滚动到对应位置。交互上每条候选有三个按钮接受、拒绝、跳过。接受和拒绝会更新候选数据里的status字段跳过则保持pending。全部处理完之后点“应用已接受项”按钮触发 Python 脚本执行写入。这个布局的好处是用户不需要在多个窗口之间切换所有操作都在一个页面里完成。我实测下来处理 40 多条候选大概需要 5 到 8 分钟比手动逐文件改快很多而且不容易漏。5.2 Monaco diff 的初始化配置Monaco 的初始化代码不复杂核心是创建两个编辑器实例和一个 diff 编辑器。下面是关键代码import * as monaco from monaco-editor; const originalModel monaco.editor.createModel(originalContent, latex); const modifiedModel monaco.editor.createModel(modifiedContent, latex); const diffEditor monaco.editor.createDiffEditor(document.getElementById(diff-container), { readOnly: true, renderSideBySide: true, ignoreTrimWhitespace: false, renderIndicators: true, originalEditable: false }); diffEditor.setModel({ original: originalModel, modified: modifiedModel });几个关键参数说明renderSideBySide: true是左右分栏模式如果屏幕窄可以改成false变成上下模式。ignoreTrimWhitespace: false表示不忽略空白差异因为 LaTeX 里空格有时候是有意义的。renderIndicators: true会在行号旁边显示改动标记方便快速定位。5.3 行内 diff 与 LaTeX 语法高亮的配合Monaco 默认的 diff 是行级 diff也就是整行标红或标绿。但对于“只改一个数字”这种场景行级 diff 不够精确。Monaco 支持行内 diff需要在创建 diff 编辑器时加一个配置const diffEditor monaco.editor.createDiffEditor(container, { renderSideBySide: true, experimental: { useTrueInlineDiff: true } });开启之后同一行里变化的字符会被单独标出来。比如\small改成\footnotesize只有small和footnotesize这部分会被高亮前面的反斜杠不变。LaTeX 语法高亮需要注册一个语言定义。Monaco 内置了latex语言支持但如果你用的是自定义命令可能需要扩展。最简单的做法是直接用latex然后通过monaco.languages.setMonarchTokensProvider加自定义规则。我试过加\cite上标格式的高亮大概十几行配置就能搞定。5.4 候选状态同步与写入触发候选状态的同步逻辑放在前端每次用户点击接受或拒绝就更新内存里的候选数据然后通过fetch把更新后的数据发回后端。后端收到之后更新 YAML 文件里的status字段。写入触发是一个单独的接口。前端点“应用已接受项”之后后端读取 YAML 文件筛选出status: accepted的候选按文件分组然后逐文件执行替换。替换的时候要注意行号偏移问题如果同一个文件里有多个候选先改后面的行再改前面的行这样行号不会错乱。def apply_candidates(candidates_path): with open(candidates_path, r, encodingutf-8) as f: data yaml.safe_load(f) accepted [c for c in data[candidates] if c[status] accepted] by_file {} for c in accepted: by_file.setdefault(c[file_path], []).append(c) for file_path, items in by_file.items(): items.sort(keylambda x: x[line_number], reverseTrue) path Path(file_path) lines path.read_text(encodingutf-8).splitlines() for item in items: idx item[line_number] - 1 lines[idx] item[replacement] path.write_text(\n.join(lines), encodingutf-8)这段代码的核心是reverseTrue排序确保从后往前改避免行号偏移。另外写入的时候用\n.join(lines)保持 Unix 换行符避免在 Windows 上出现换行符混乱。6. 辅助文件清理与项目收尾6.1 哪些辅助文件该删、哪些该留LaTeX 编译会产生一堆辅助文件常见的有扩展名用途是否可删.aux交叉引用信息可删下次编译会重建.log编译日志可删但排查问题时有用.outhyperref 书签可删.toc目录数据可删但删了目录会空.bbl参考文献数据谨慎如果没有.bib源文件就不要删.synctex.gz源码跳转数据可删但删了不能双向跳转.fls文件依赖记录可删.fdb_latexmklatexmk 数据库可删我的做法是日常编译保留.aux、.toc、.bbl删掉.log、.out、.synctex.gz。提交到版本控制之前全部辅助文件都删掉只留源文件和 PDF。6.2 用 Python 脚本做安全清理清理脚本的关键是“安全”不能误删源文件。我的做法是只删build/目录下的内容并且只删白名单里的扩展名。代码import shutil from pathlib import Path AUX_EXTENSIONS {.aux, .log, .out, .toc, .synctex.gz, .fls, .fdb_latexmk} def clean_build(build_dir): build_path Path(build_dir) if not build_path.exists(): return for item in build_path.iterdir(): if item.is_file() and item.suffix in AUX_EXTENSIONS: item.unlink() elif item.is_dir(): shutil.rmtree(item)注意.synctex.gz的suffix是.gz不是.synctex.gz。所以判断的时候要用item.name.endswith(.synctex.gz)或者把扩展名集合改成用endswith匹配。我踩过这个坑第一次跑的时候.synctex.gz没被删掉后来改成def should_delete(file_name): return any(file_name.endswith(ext) for ext in AUX_EXTENSIONS)6.3 清理前后的编译验证清理之后一定要重新编译一次确认没有删掉必要的文件。我用的验证命令是latexmk -pdf -interactionnonstopmode -file-line-error src/main.tex如果编译通过并且 PDF 正常生成说明清理没问题。如果编译报错说找不到某个文件那说明删多了需要把对应的扩展名从白名单里去掉。注意如果你的项目用了bibtex或者biber.bbl文件不要随便删。有些期刊模板要求提交.bbl删了之后读者编译不出来参考文献。7. 常见问题与排查技巧实录7.1 候选生成阶段的典型问题问题一正则匹配误报太多。比如\small在正文里也出现不只是页眉。解决办法是加context字段或者把匹配范围缩小到特定文件。我试过用target: src/styles/*.sty把范围限定在样式文件里误报率从 30% 降到 5% 以下。问题二YAML 里的反斜杠转义。LaTeX 命令里全是反斜杠YAML 里写正则的时候需要双重转义。比如匹配\cite要写成\\\\cite。这个很容易写错建议写完先用 Python 的yaml.safe_load读一遍确认解析出来的字符串是对的。问题三文件编码不一致。有些老模板用 GBK 编码Python 默认用 UTF-8 读会报错。解决办法是在read_text里加errorsignore或者先用chardet检测编码。我一般直接统一转成 UTF-8避免后续麻烦。7.2 diff 比对阶段的常见故障故障一Monaco 加载慢。首次加载 2 到 3 秒是正常的但如果超过 5 秒可能是 CDN 或者打包配置有问题。建议把 Monaco 的静态资源放在本地不要依赖外部 CDN。故障二行内 diff 不生效。检查useTrueInlineDiff是否开启另外 Monaco 版本要在 0.30 以上才支持这个特性。如果版本太低升级一下。故障三大文件 diff 卡顿。如果单个文件超过 5000 行Monaco diff 会明显卡顿。解决办法是分页加载或者只 diff 改动行附近的内容。我一般把 diff 范围限制在改动行上下 20 行这样既能看到上下文又不会卡。7.3 写入阶段的避坑指南坑一行号偏移。前面提过同一个文件多个候选要从后往前改。如果从前往后改改完第一行之后第二行的行号就变了会改错位置。坑二换行符混乱。Windows 上默认是\r\nLinux 上是\n。Python 的read_text默认会做换行符转换但write_text不会。建议统一用newline\n参数或者在写入前把\r\n替换成\n。坑三权限问题。如果项目文件是只读的写入会失败。建议在写入前检查文件权限或者用os.chmod临时加写权限。7.4 常见问题速查表问题现象可能原因排查方法解决方案候选数量为零技能未启用或 target 路径不对检查 YAML 里 enabled 和 target修正路径或启用技能diff 界面空白Monaco 资源加载失败打开浏览器控制台看报错本地化 Monaco 资源写入后编译报错替换内容语法错误对比替换前后的行回滚候选修正 replace 字段辅助文件删多了白名单扩展名不全重新编译看缺什么把缺失扩展名加回白名单行内 diff 不显示Monaco 版本过低查看 monaco-editor 版本号升级到 0.30 以上8. 一些实操心得与后续扩展方向这套流程我跑了大概两个月处理了四个 LaTeX 模板项目最大的感受是候选和确认分离这个设计比想象中更有价值。它不只是为了安全更是为了让修复过程变得可讨论。以前改模板改完只能自己知道改了啥现在候选文件一生成可以直接发给合作者看对方在 diff 界面里逐条确认沟通成本低了很多。另一个心得是技能配置不要一次写太多。我一开始写了 30 多条规则结果候选列表太长处理起来反而慢。后来精简到 10 条左右只保留高频、明确的修复项效率反而更高。剩下的边缘情况手动改就行不值得为它写一条规则。后续如果继续扩展我会考虑两个方向。一是把技能配置做成可导入导出的这样不同项目之间可以复用规则集。二是加一个“批量接受同类候选”的功能比如所有header-font-size的候选一键接受省去逐条点击的时间。这两个功能都不复杂但能进一步提升效率。如果你也在维护 LaTeX 模板或者手头有大量重复性的文本修复工作这套思路可以直接搬过去用。核心就三点规则配置化、候选数据化、确认可视化。把这三点做到位修复工作就从“手工活”变成了“流水线”。