
Make Latex Model
- 67 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Legacy alias of make-latex-model: optimizes ChineseResearchLaTeX projects into templates across NSFC, paper, thesis, and CV lines with pixel-level alignment.
About
Refines a ChineseResearchLaTeX project into a polished template, deciding whether to edit the project layer or shared packages and verifying via official build entries. This is the underscore alias of make-latex-model, kept for the older command spelling.
- Alias for make-latex-model (old underscore spelling)
- Requires regression planning before editing shared packages
Make Latex Model by the numbers
- 67 all-time installs (skills.sh)
- Ranked #350 of 688 Office & Documents skills by installs in the Skillselion catalog
- Data as of Aug 4, 2026 (Skillselion catalog sync)
npx skills add https://github.com/huangwb8/chineseresearchlatex --skill make_latex_modelAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 67 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Legacy alias of make-latex-model: optimizes ChineseResearchLaTeX projects into templates across NSFC, paper, thesis, and CV lines with pixel-level alignment.
Files
ChineseResearchLaTeX 模板落地与高保真对齐器
与 bensz-collect-bugs 的协作约定
- 当用户环境中出现因本 skill 设计缺陷导致的 bug 时,优先使用
bensz-collect-bugs按规范记录到~/.bensz-skills/bugs/,严禁直接修改用户本地 Claude Code / Codex 中已安装的 skill 源码。 - 若 AI 仍可通过 workaround 继续完成用户任务,应先记录 bug,再继续完成当前任务。
- 当用户明确要求“report bensz skills bugs”等公开上报动作时,调用本地
gh与bensz-collect-bugs,仅上传新增 bug 到huangwb8/bensz-bugs;不要 pull / clone 整个 bug 仓库。
先读什么
- 产品线标准:
docs/for-developers/*-template-standard.md - 本 skill 工作流:
docs/WORKFLOW.md - 产品线识别:
references/PRODUCT_LINE_RULES.md - 脚本职责:
references/SCRIPT_SCOPE.md - 工具说明:
scripts/README.md - 基线准备:
docs/BASELINE_GUIDE.md
定位
- 让
ChineseResearchLaTeX中的目标项目按当前真实架构落成高质量模板。 - 先判断该改
projects/*还是packages/bensz-*。 - 若必须改公共包,先做回归计划,再跑受影响模板的官方验证。
- 验收始终以各产品线官方构建入口为准。
适用任务
- 把某个项目对齐到官方 PDF、Word 导出 PDF 或既有 baseline
- 判断问题属于项目层差异还是共享样式/共享脚本
- 做像素级 PDF 比对、标题对齐、参数抽取
- 新增或重构 NSFC / paper / thesis / cv 模板
工作流
1. 判断验收口径
- 用户要“像某份 PDF/Word 一样”
- 还是“按当前仓库标准做成好模板”
- 还是“新增一套模板能力”
2. 判断修改层级
projects/*:示例内容、薄封装、项目资源、局部差异packages/bensz-*:共享样式、共享字体、profile、统一构建逻辑
3. 最小范围实现
- 只改与当前任务直接相关的文件
- 除非用户明确要求,否则默认不改正文语义内容
4. 包层安全门禁
当必须改 packages/ 时,额外执行:
1. 先证明项目层方案不够 2. 运行 python3 skills/make-latex-model/scripts/plan_package_regression.py <packages/bensz-*> 3. 优先把改动收敛到最窄的模板专属 profile/style/template 4. 改完先验目标项目,再回归该公共包直接覆盖的全部现有项目
5. 官方入口验证
- NSFC:
nsfc_project_tool.py - Paper:
paper_project_tool.py - Thesis:
thesis_project_tool.py - CV:
cv_project_tool.py
辅助脚本
analyze_pdf.pycompare_headings.pycompare_pdf_pixels.pyoptimize_heading_linebreaks.pyplan_package_regression.py
这些脚本是辅助工具箱,不是唯一工作流;NSFC 专项工具不能默认替代 paper / thesis / cv 的官方入口。
边界
允许:
- 调整项目层版式参数、标题体系、入口装配
- 把共享实现沉淀到
packages/bensz-* - 修改 profile、style、wrapper、官方 compare 验收链
避免:
- 把共享实现复制回单个项目
- 绕过官方构建入口只跑裸
xelatex - 为了像素对齐破坏仓库真实分层
- 默认改写用户正文语义
验收标准
1. 改动落在正确层级 2. 通过对应产品线官方入口 3. warning 需要说明是已有还是新增 4. 若改了公共包,必须说明回归了哪些模板 5. 若用户给 baseline,完成必要 compare 6. paper 默认兼顾 PDF 与 DOCX;cv 默认兼顾中英文;thesis 默认兼顾 profile/style 与项目入口一致性
Changelog
格式基于 Keep a Changelog。
[Unreleased]
Added(新增)
- 新增
scripts/plan_package_regression.py:可按config.yaml的公共包回归规则输出受影响项目、官方 build 命令,以及在可用时附带 compare 建议,作为修改packages/bensz-*前的确定性安全门禁。 - 新增
references/SCRIPT_SCOPE.md:按“跨产品线辅助脚本 / NSFC 专项工具”重新整理脚本职责矩阵,不再用 legacy 叙事描述当前 skill 的能力边界。
Changed(变更)
- 将
make-latex-model升级到v3.1.1,把SKILL.md、README.md、docs/WORKFLOW.md、docs/FAQ.md、docs/BASELINE_GUIDE.md、scripts/README.md与根级索引里的历史过渡口径改写为“当前状态直述”:validate.sh、optimize.py、templates/nsfc/*.yaml等脚本统一定义为 NSFC 专项工具,而不是把当前 skill 表述成旧版 NSFC 流程的改良或继承。 - 将
make-latex-model升级到v3.1.2:删除templates/nsfc/*.yaml这层按年度固化 NSFC 标题文字的模板设计,改为由scripts/core/template_catalog.py提供稳定结构默认值;config_loader.py、extract_headings.py、setup_wizard.py与相关 README/索引同步去除对这些 YAML 的硬依赖,项目级.template.yaml仍可保留局部覆盖能力。
Fixed(修复)
- 修复
scripts/check_state.py仍把所有项目都按NSFC + extraTex/@config.tex初始化的误判问题:现改为从config.yaml的product_line_rules读取产品线识别、初始化标记与官方构建命令,paper / thesis / cv不再被错误标记为“未初始化”。 - 修复基线与建议文案过度绑定基金委/
word.pdf的问题:config.yaml新增baseline.preferred_candidates与analysis_command作为单一真相来源,状态检查输出改为通用 PDF 基线口径,同时继续兼容 legacyword.pdf。
Changed(变更)
- 统一对外名称为
make-latex-model,README / 索引 / 示例命令同步保留对旧写法make_latex_model的兼容提示。 - 将版本号按
SKILL.md同步回config.yaml、README.md与项目级索引,当前统一为v3.0.1。 - 将产品线判定与脚本边界从
SKILL.md下沉到references/PRODUCT_LINE_RULES.md与后续统一收口的脚本职责文档,减少核心工作文档冗余,并把当前版本提升为v3.0.1。
[3.0.0] - 2026-03-27
Added(新增)
- 新增面向
NSFC / paper / thesis / cv四条产品线的分层判定与官方验证矩阵。 - 新增 Skill 级
CHANGELOG.md,把make-latex-model的版本演进落到技能目录内维护。
Changed(变更)
- 将
make-latex-model从“NSFC 专用@config.tex微调器”重定位为“ChineseResearchLaTeX 模板落地与高保真对齐 skill”。 SKILL.md、README.md、docs/WORKFLOW.md、docs/FAQ.md全面改为基于当前packages/ + projects/ + 官方构建脚本的真实架构。config.yaml升级到3.0.0,增加product_line、target_scope、baseline_pdf、acceptance_mode等面向当前仓库的参数语义。scripts/README.md明确辅助脚本为“可选工具箱”,并将旧版 NSFC 专用脚本降级为 legacy 入口。
# make-latex-model 默认配置
#
# 说明:
# 1. 当前仓库的权威工作流是各产品线官方构建脚本。
# 2. 本文件既服务于 skill 元信息,也兼容保留的辅助脚本。
# 3. 如脚本能力与当前 packages/ + projects/ 真实结构冲突,以真实结构为准。
skill_info:
name: make-latex-model
version: 3.1.2
description: ChineseResearchLaTeX 模板落地与高保真对齐 skill,适配 NSFC / paper / thesis / cv 四条产品线;优先使用 make-latex-model 触发,也兼容旧写法 make_latex_model;先按 packages/ 与 projects/ 的真实分层判断修改范围,再依据官方构建入口完成样式对齐、基线比对和验收;若必须修改 packages 下公共包,需先生成受影响模板回归计划并完成相关回归;NSFC 专项工具仅在明确属于 NSFC 参数对齐场景时按需使用。
author: "Bensz Conan"
category: normal
parameters:
project:
type: string
required: true
description: 项目名称或路径(如 projects/NSFC_Young、projects/paper-sci-01、projects/thesis-nju-master)
default: null
product_line:
type: string
required: false
description: 产品线类型;默认 auto,根据项目路径和真实入口自动判断
allowed_values:
- auto
- nsfc
- paper
- thesis
- cv
default: auto
target_scope:
type: string
required: false
description: 修改层级;默认 auto,优先选最小正确层
allowed_values:
- auto
- project_only
- package_only
- mixed
default: auto
baseline_pdf:
type: string
required: false
description: 验收基线 PDF 的路径;可为空
default: null
baseline_kind:
type: string
required: false
description: 基线来源说明
allowed_values:
- auto
- official_pdf
- word_export_pdf
- rendered_pdf
- none
default: auto
template:
type: string
required: false
description: 模板标识;主要供专项脚本选择内置结构默认值与项目级 `.template.yaml` 覆盖,留空则自动检测
default: null
word_template_year:
type: string
required: false
description: NSFC Word 基线年份提示;仅供专项参数脚本参考,不作为默认决策依据
pattern: "^\\d{4}$"
default: null
optimization_level:
type: string
required: false
description: 优化级别
allowed_values:
- minimal
- moderate
- thorough
default: moderate
acceptance_mode:
type: string
required: false
description: 验收强度
allowed_values:
- build_only
- compare_if_available
- require_compare
default: compare_if_available
dry_run:
type: boolean
required: false
description: 预览模式,不实际修改文件
default: false
optimization_strategies:
minimal:
description: "最小改动:仅修复明显错误或阻塞构建的问题"
actions:
- fix_critical_layout_issues
- keep_existing_structure
moderate:
description: "中等优化:按当前仓库分层完成样式和结构对齐(默认)"
actions:
- align_layout_parameters
- choose_correct_layer
- verify_with_official_tool
thorough:
description: "彻底重构:允许必要的包层/项目层联动调整,以达到稳定交付"
actions:
- refactor_shared_styles
- adjust_project_wrappers
- perform_regression_checks
style_reference:
colors:
MsBlue: "RGB 0,112,192"
header_color: "RGB 0,0,0"
footer_color: "RGB 0,0,0"
text_color: "RGB 0,0,0"
compile_config:
engine: xelatex
interaction_mode: nonstopmode
max_runs: 4
sequence:
- xelatex
- bibtex
- xelatex
- xelatex
official_build_commands:
nsfc: "python packages/bensz-nsfc/scripts/nsfc_project_tool.py build --project-dir <project>"
paper: "python packages/bensz-paper/scripts/paper_project_tool.py build --project-dir <project>"
thesis: "python packages/bensz-thesis/scripts/thesis_project_tool.py build --project-dir <project>"
cv: "python packages/bensz-cv/scripts/cv_project_tool.py build --project-dir <project> --variant all"
official_compare_commands:
thesis: "python packages/bensz-thesis/scripts/thesis_project_tool.py compare --project-dir <project> --baseline-pdf <baseline>"
product_line_rules:
nsfc:
display_name: "NSFC"
detect_patterns:
- "NSFC_"
- "nsfc_"
required_markers:
- "main.tex"
- "extraTex/@config.tex"
official_build_key: nsfc
paper:
display_name: "Paper"
detect_patterns:
- "paper-"
required_markers:
- "main.tex"
- "extraTex"
official_build_key: paper
thesis:
display_name: "Thesis"
detect_patterns:
- "thesis-"
required_markers:
- "main.tex"
- "template.json"
official_build_key: thesis
official_compare_key: thesis
cv:
display_name: "CV"
detect_patterns:
- "cv-"
required_markers:
- "main-zh.tex"
- "main-en.tex"
official_build_key: cv
package_change_policy:
require_regression_plan_before_edit: true
project_layer_bias: true
escalation_rules:
- "仅当问题确属共享样式、共享字体、profile 或统一构建逻辑时,才从项目层升级到包层。"
- "若项目层修复只会复制共享逻辑,应改包层;但要先缩小到最具体的 style / profile / template 文件,而不是直接改共享核心入口。"
- "若无法精确判断影响范围,默认扩大回归范围,而不是假设只影响当前模板。"
preferred_isolation_order:
- "新增或调整模板专属 profile / style 文件。"
- "在共享包中增加仅对目标模板生效的条件分支。"
- "最后才修改共享核心入口、跨模板通用宏或字体 API。"
verification_rules:
- "先验证当前目标项目,确认改动确实解决了本次问题。"
- "再回归该公共包直接覆盖的全部现有项目。"
- "若某个项目已有 baseline,优先补跑官方 compare;没有 compare 入口时至少完成官方 build。"
- "未完成相关回归前,不得宣称包层改动安全。"
forbidden_actions:
- "在没有回归计划的情况下直接修改 packages 下的共享实现。"
- "为了赶进度而把共享逻辑复制回单个项目。"
- "在未验证其它现有模板前宣称不会产生副作用。"
shared_packages:
bensz-fonts:
project_globs:
- "projects/NSFC_*"
- "projects/paper-*"
- "projects/thesis-*"
- "projects/cv-*"
prefer_paths:
- "packages/bensz-fonts/"
rationale: "字体 API 与字体资源会跨 NSFC / paper / thesis / cv 多条产品线生效;若影响范围无法再缩小,默认回归全部现有项目。"
bensz-nsfc:
project_globs:
- "projects/NSFC_*"
prefer_paths:
- "packages/bensz-nsfc/profiles/"
- "packages/bensz-nsfc/templates/"
rationale: "NSFC 公共包直接服务三套 NSFC 项目;包层改动默认回归全部现有 NSFC 项目。"
bensz-paper:
project_globs:
- "projects/paper-*"
prefer_paths:
- "packages/bensz-paper/profiles/"
- "packages/bensz-paper/"
rationale: "论文公共包负责 PDF / DOCX 共享样式与导出链路;回归时至少验证全部现有 paper 项目。"
bensz-thesis:
project_globs:
- "projects/thesis-*"
prefer_paths:
- "packages/bensz-thesis/profiles/"
- "packages/bensz-thesis/styles/"
rationale: "毕业论文公共包同时服务多所学校模板;优先把改动收敛到模板专属 profile / style,再回归全部现有 thesis 项目。"
bensz-cv:
project_globs:
- "projects/cv-*"
prefer_paths:
- "packages/bensz-cv/profiles/"
- "packages/bensz-cv/"
rationale: "简历公共包影响中英文双入口与共享样式;包层改动后需回归所有现有 cv 项目。"
validation:
max_iterations: 3
required_checks:
- official_build_success
- no_new_unexplained_warnings
- correct_layer_selection
- baseline_alignment_if_requested
tolerance:
font_size_diff: 0.5
color_diff: 2
spacing_diff: 0.05
margin_diff: 0.5
line_height_diff: 0.1
chars_per_line_diff: 1
line_position_diff: 2
pixel_changed_ratio: 0.01
output:
format: markdown
language: zh-CN
include_diff: true
include_validation_report: true
backup_before_modification: false
compatibility:
preserve_old_commands: true
preserve_if_conditions: true
support_os:
- windows
- macos
- linux
workspace:
root: ".make_latex_model"
location: project_level
auto_cleanup: true
cache_max_age_hours: 24
keep_iterations: true
max_iterations_kept: 30
auto_migrate_legacy: true
verbose_migration: true
iteration:
max_iterations: 30
convergence_threshold: 0.01
no_improvement_limit: 5
adjustment_granularity:
font_size_pt: 0.1
line_spacing: 0.05
margin_cm: 0.05
color_rgb: 1
rollback_on_worsening: true
save_best_config: true
pixel_comparison:
dpi: 150
tolerance: 2
mode: paragraph
min_similarity: 0.85
focus_areas:
- title_area
- body_area
- page_margins
baseline:
preferred_candidates:
- "template/baseline.pdf"
- ".make_latex_model/baselines/baseline.pdf"
- ".make_latex_model/baselines/word.pdf"
analysis_command: "python skills/make-latex-model/scripts/analyze_pdf.py <baseline.pdf>"
converter_priority:
- word
- libreoffice
- quicklook
nsfc_specialized_tools:
enabled: true
note: "validate.sh、core/template_catalog.py、config_loader、optimize.py 等入口主要服务 NSFC 参数对齐与专项分析;其中内置模板目录只保留稳定结构信息,不再固化年度标题文案。"
PDF 基线制作指南
本指南说明如何为 make-latex-model 准备可靠的 PDF baseline。
适用范围
可用于:
- NSFC 官方模板对齐
- thesis / paper / cv 的学校、期刊、既有样例 PDF 对齐
- 任意需要像素级或视觉级回归的模板任务
基线优先级
推荐按下面的优先级选择:
1. 官方直接提供的 PDF 2. 用 Microsoft Word 导出的 PDF 3. 用 LibreOffice 导出的 PDF 4. 其他可信渲染链路生成的 PDF
如果你需要做像素级比对,尽量不要使用 QuickLook、截图或预览器导出的伪 PDF。
方法 1:直接使用官方 PDF
如果用户已经提供:
- 学校官方 PDF
- 期刊官方 PDF
- 既有验收版 PDF
- Release 包里的 baseline PDF
那么它通常就是最好的 baseline,不必再绕回 Word 转 PDF。
方法 2:用 Microsoft Word 导出 PDF
步骤
1. 打开 Word 模板 2. 选择“文件 -> 导出 -> 创建 PDF” 3. 把导出的 PDF 保存到便于引用的位置
推荐保存方式:
- 项目内长期保留的基线:放到项目自己的
template/、assets/source/、tests/baselines/等真实目录 - 一次性调试基线:放到本轮测试目录或
.make_latex_model/工作区
方法 3:用 LibreOffice 导出 PDF
转换命令
soffice --headless --convert-to pdf --outdir <输出目录> <word-file>例如:
soffice --headless --convert-to pdf \
--outdir tests/baselines \
projects/thesis-nju-master/assets/source/nju_mem_2023_2.docx如何检查 PDF 是否靠谱
可以用 pdfinfo 看元信息:
pdfinfo <baseline.pdf>重点关注:
- 页面大小是否正确(通常是 A4)
- 是否存在加密
- Creator / Producer 是否来自可信渲染链路
不推荐的做法
- 用 QuickLook 预览截图代替 PDF
- 用预览器的缩略图或截图代替 baseline
- 用不明来源的在线工具处理敏感模板
与当前 skill 的关系
基线准备好后,make-latex-model 会优先:
1. 判断这次修改该落在 projects/* 还是 packages/bensz-* 2. 用对应产品线的官方构建脚本验证 3. 仅在需要时再做标题比对、像素比对或参数提取
也就是说,baseline 很重要,但它只决定验收参照物,不决定你必须走哪一套实现路径。
make-latex-model 常见问题
Q1:这个 skill 现在还是只给 NSFC 用吗?
不是。
它现在面向整个 ChineseResearchLaTeX:
NSFCpaperthesiscv
NSFC 仍然是重要场景,但它只是四条产品线之一。
Q2:它现在还只改 extraTex/@config.tex 吗?
不是。
extraTex/@config.tex 只是 NSFC 项目层的一个具体入口。现在更重要的是先选对层级:
- 单项目问题改
projects/* - 共享样式问题改
packages/bensz-*
例如:
thesis的共享版式通常应该落在packages/bensz-thesis/styles/paper的共享样式与 DOCX 链路通常应该落在packages/bensz-paper/cv的共享类与双语支持通常应该落在packages/bensz-cv/
Q3:什么时候应该改公共包,而不是项目层?
当问题满足以下任一条件时,优先考虑公共包:
- 会影响多个项目
- 属于共享样式、profile、字体接入、统一构建逻辑
- 本来就应该是模板能力,而不是某个示例项目的私有参数
但注意:这不等于“发现是共享问题就直接改”。现在的硬规则是:
1. 先说明为什么项目层方案不够 2. 先生成包层回归计划 3. 改完后回归该公共包覆盖的全部现有模板
推荐命令:
python3 skills/make-latex-model/scripts/plan_package_regression.py packages/bensz-thesisQ4:如果我只有 Word 模板怎么办?
可以继续把 Word 导出成 PDF 作为 baseline。
优先顺序:
1. 官方 PDF 2. Word 导出 PDF 3. LibreOffice 导出 PDF
尽量不要用 QuickLook 之类的非 Word 渲染链路做像素级基线。
Q5:还需要看 scripts/README.md 吗?
需要,但要带着新口径看:
- 那些脚本现在是辅助工具
- 它们不是当前仓库的权威工作流
- 一旦脚本假设与真实目录结构冲突,应直接以真实项目结构和官方构建命令为准
Q6:paper 场景最容易忽略什么?
最容易只盯 PDF,忘了 DOCX。
当前仓库里,paper 模板默认要关注:
- PDF 是否正常
- DOCX 是否还能导出
extraTex/**/*.tex是否仍然是唯一正文真相来源
Q7:thesis 场景最容易忽略什么?
最容易只改项目示例,而忘了真正的模板身份在包层。
尤其是新增学校或学位模板时,通常要同步考虑:
packages/bensz-thesis/profiles/packages/bensz-thesis/styles/projects/thesis-*/template.json
Q8:cv 场景最容易忽略什么?
最容易只看一个入口。
当前标准是:
- 中文入口:
main-zh.tex - 英文入口:
main-en.tex
默认应一起验证。
Q9:如果我必须改 packages/,怎么保证不伤到其它模板?
不要靠主观判断,要靠回归矩阵。
做法是:
1. 先运行 plan_package_regression.py 看这个公共包覆盖了哪些现有项目 2. 优先把改动收敛到模板专属 profile / style,而不是先改共享核心入口 3. 改完后先验证当前目标项目,再逐个回归这些受影响项目 4. 若受影响项目已有 baseline,再补跑官方 compare
如果没有完成这些验证,就不能把结果表述成“不会影响其它模板”。
make-latex-model 工作流
本文件描述 make-latex-model 在当前 ChineseResearchLaTeX 仓库中的推荐执行方式。
核心原则只有一句话:
- 直接按
packages/ + projects/ + 官方构建脚本的真实结构工作
0. 先判定产品线
根据目标项目路径先判断你在处理哪条产品线:
projects/NSFC_*->nsfcprojects/paper-*->paperprojects/thesis-*->thesisprojects/cv-*->cv
随后优先阅读对应标准文档:
docs/for-developers/nsfc-template-standard.mddocs/for-developers/paper-template-standard.mddocs/for-developers/thesis-template-standard.mddocs/for-developers/cv-template-standard.mdskills/make-latex-model/references/PRODUCT_LINE_RULES.md
1. 再判定验收口径
常见目标有三类:
1. 对齐某份官方模板或 baseline PDF 2. 把当前项目整理成符合仓库标准的“好模板” 3. 新增或修复一套共享模板能力
可接受的 baseline 输入:
- 官方 PDF
- Word 导出 PDF
- 既有 baseline PDF
- 学校或期刊给出的公开样例 PDF
2. 选择修改层级
优先用下面这条规则:
- 只影响单项目:改
projects/* - 影响多个项目共享行为:改
packages/bensz-* - 两边都有:联动修改,但每层职责要清楚
当前仓库里的典型位置
NSFC
- 项目层:
main.tex、extraTex/@config.tex - 包层:
packages/bensz-nsfc/profiles/、impl/、scripts/
Paper
- 项目层:
main.tex、extraTex/**/*.tex、artifacts/reference.docx、artifacts/manuscript.csl - 包层:
packages/bensz-paper/profiles/、bml-*.sty、scripts/
Thesis
- 项目层:
main.tex、baseline.tex、editable.tex、extraTex/、template.json - 包层:
packages/bensz-thesis/profiles/、styles/
CV
- 项目层:
main-zh.tex、main-en.tex、assets/ - 包层:
packages/bensz-cv/、profiles/
3. 实施修改
执行时遵守以下准则:
- 优先最小正确修改,不做无关重构
- 共享逻辑不要复制回项目层
- 不要把 thesis / paper / cv 简化成
@config.tex问题 - 默认不重写用户正文语义内容;除非用户明确要求,或正文装配本身就是模板工作的一部分
3.5 如果必须改 packages/
这是本次 workflow 新增的硬门禁:
1. 先证明为什么项目层修复不够,避免一上来就动共享包 2. 先生成回归计划:
python3 skills/make-latex-model/scripts/plan_package_regression.py packages/bensz-thesis3. 优先改最窄的模板专属文件,例如:
packages/bensz-thesis/profiles/packages/bensz-thesis/styles/packages/bensz-nsfc/templates/
4. 只有在这些位置都无法承载需求时,才改共享核心入口或字体 API 5. 改完后按“当前目标项目 -> 同包覆盖的全部现有项目”的顺序回归 6. 如果某个受影响项目已有 baseline,再追加官方 compare;没有 compare 入口时至少完成官方 build
这一步的目标不是“尽量不影响其它模板”,而是“用明确的回归矩阵证明没有把其它现有模板带偏”。
4. 用官方入口验证
NSFC
python packages/bensz-nsfc/scripts/nsfc_project_tool.py build --project-dir <项目路径>Paper
python packages/bensz-paper/scripts/paper_project_tool.py build --project-dir <项目路径>Thesis
python packages/bensz-thesis/scripts/thesis_project_tool.py build --project-dir <项目路径>如需回归比对:
python packages/bensz-thesis/scripts/thesis_project_tool.py compare --project-dir <项目路径> --baseline-pdf <baseline.pdf>CV
python packages/bensz-cv/scripts/cv_project_tool.py build --project-dir <项目路径> --variant all如需回归比对:
python packages/bensz-cv/scripts/cv_project_tool.py compare --project-dir <项目路径> --variant zh --baseline-pdf <baseline.pdf>5. 需要时使用辅助脚本
以下脚本可以用来补充判断,但不是默认主流程:
check_state.pyplan_package_regression.pyanalyze_pdf.pycompare_headings.pycompare_pdf_pixels.pyoptimize_heading_linebreaks.py
脚本职责说明见:skills/make-latex-model/references/SCRIPT_SCOPE.md
推荐使用场景:
- 你手里只有 PDF baseline,想先抽取参数
- 你想快速对比标题文本或标题换行
- 你要做像素级差异分析
不推荐的用法:
- 用
validate.sh替代当前产品线官方构建命令 - 强迫
paper / thesis / cv套入 NSFC 专项参数工具链
6. 收尾
输出时至少说明:
- 这次问题属于哪条产品线
- 改动落在项目层、包层,还是两者联动
- 使用了哪条官方验证命令
- 如果动了包层,回归了哪些现有模板,剩余哪些风险
- 是否执行了 compare / 像素比对;如果没有,为什么没做
Output 目录
此目录用于存放技能运行时生成的临时输出文件,包括:
benchmark_results.json: 验证器基准测试结果*.log: 运行日志文件
注意: 此目录下的文件不应提交到版本控制。
你是一位 LaTeX 排版与模板对齐专家,正在将 LaTeX 渲染结果与 Word 打印/导出 PDF 做像素级对齐。
## 当前状态
- 差异比例: $diff_ratio
- 迭代次数: $iteration
- 目标阈值: $target_ratio
## 差异分析(结构化特征)
$diff_analysis
## 历史调整记录(最近 N 条)
$history
## 当前配置(@config.tex 摘要/片段)
```latex
$current_config
```
## 任务
请分析差异根因,给出下一步调整策略,并输出 **严格 JSON**(不要输出任何其他文字)。
JSON 格式:
```json
{
"analysis": {
"root_cause": "line_break_mismatch|vertical_offset|margin_mismatch|heading_area_mismatch|unknown",
"key_evidence": ["..."]
},
"adjustments": [
{
"parameter": "xiaosi_font_size|baselinestretch|margin_right|margin_left|margin_top|margin_bottom|parskip|arraystretch|list_leftmargin|caption_skip|title_indent",
"delta": -0.05,
"new_value": 11.95,
"confidence": 0.0,
"reasoning": "..."
}
],
"fallback": [
{
"parameter": "...",
"delta": -0.01,
"new_value": null
}
]
}
```
make-latex-model - ChineseResearchLaTeX 模板落地与高保真对齐
本 README 面向使用者:如何触发并正确使用 make-latex-model。当前版本:v3.1.2。执行边界与硬性规范见 SKILL.md,默认参数见 config.yaml。兼容旧写法 make_latex_model,但后续文档统一使用连字符名称。
现在它是干什么的
make-latex-model 当前是面向整个 ChineseResearchLaTeX 的模板落地与高保真对齐 skill:
- 支持
NSFC / paper / thesis / cv四条产品线 - 会先判断应该改
projects/*还是packages/bensz-* - 若必须改
packages/bensz-*,会先生成受影响模板回归计划,避免把其它现有模板带偏 - 默认走各产品线官方构建入口验收
- 对
validate.sh、optimize.py、core/template_catalog.py这类脚本,统一按“NSFC 专项工具”理解,而不是把整个 skill 视为它们的延伸
推荐用法
最推荐直接用自然语言触发:
请使用 make-latex-model skill。
目标项目:projects/thesis-nju-master
参考基线:projects/thesis-nju-master/assets/source/nju_mem_2023_2.pdf
目标:根据当前 ChineseResearchLaTeX 的真实分层,把这套模板调到可交付状态;如果问题属于共享样式,请优先改 packages/bensz-thesis,而不是只改项目层。
输出:直接修改代码并用官方构建入口验证;最后告诉我你改到了哪一层、为什么这样改。常见场景
1. NSFC 新模板对齐
请使用 make-latex-model skill 对 projects/NSFC_General 做样式对齐。
输入:官方 PDF 或 Word 导出 PDF
输出:按当前 packages/bensz-nsfc + projects/NSFC_General 的真实结构完成修改,并用官方构建命令验收。2. 新 thesis 模板打磨
请使用 make-latex-model skill 处理 projects/thesis-nju-master。
输入:学校 Word/PDF 模板、当前 baseline、现有 style 文件
输出:把需要共享的版式沉淀到 packages/bensz-thesis/styles/ 或 profiles/,并验证 thesis_project_tool.py 构建通过。
如果必须改 `packages/bensz-thesis/`,先生成回归计划并逐个验证现有 thesis 项目。3. 论文模板 PDF / DOCX 一起对齐
请使用 make-latex-model skill 优化 projects/paper-sci-01。
目标:既保证 PDF 版式更贴近参考模板,也不要破坏 DOCX 导出链路。
输出:按当前仓库标准完成修改,并通过 paper_project_tool.py 验证 PDF + DOCX。4. 简历模板双语回归
请使用 make-latex-model skill 优化 projects/cv-01。
目标:同时检查中文和英文入口,并在需要时修改 packages/bensz-cv 的共享样式。
输出:通过 cv_project_tool.py build --variant all 验证。它现在默认怎么判断改哪里
| 情况 | 优先修改位置 |
|---|---|
| 只影响单个项目的正文装配、局部参数、项目资源 | projects/* |
| 影响多个项目共享的样式、profile、构建逻辑、字体接入 | packages/bensz-* |
| 既有项目入口问题,也有共享样式问题 | 项目层 + 包层联动 |
如果判断必须改公共包,额外增加一条硬规则:
python3 skills/make-latex-model/scripts/plan_package_regression.py packages/bensz-thesis先用这个脚本生成“受影响模板 + 官方回归命令”列表,再真正编辑 packages/。没有完成这些回归前,不应把结果表述成“不会影响其它模板”。
官方验证命令
python packages/bensz-nsfc/scripts/nsfc_project_tool.py build --project-dir projects/NSFC_General
python packages/bensz-paper/scripts/paper_project_tool.py build --project-dir projects/paper-sci-01
python packages/bensz-thesis/scripts/thesis_project_tool.py build --project-dir projects/thesis-nju-master
python packages/bensz-cv/scripts/cv_project_tool.py build --project-dir projects/cv-01 --variant all备选用法
如果你只是需要 PDF 参数提取、标题比对或像素比对,也可以单独用辅助脚本:
python3 skills/make-latex-model/scripts/check_state.py projects/thesis-nju-master
python3 skills/make-latex-model/scripts/plan_package_regression.py packages/bensz-thesis
python3 skills/make-latex-model/scripts/analyze_pdf.py <baseline.pdf> --project projects/NSFC_Young
python3 skills/make-latex-model/scripts/compare_headings.py <baseline.pdf> <main.tex>
python3 skills/make-latex-model/scripts/compare_pdf_pixels.py <baseline.pdf> <rendered.pdf>这些脚本现在更适合做“辅助分析”或“NSFC 专项参数任务”。其中 check_state.py 已支持按产品线识别入口和官方构建命令;validate.sh、optimize.py、core/template_catalog.py 等脚本只应在明确属于 NSFC 专项参数对齐时使用,不应替代各产品线官方构建链路。
重要边界
- 不要默认把所有模板问题都塞回
extraTex/@config.tex - 不要把共享实现从
packages/bensz-*复制回项目层 - 改公共包前先生成回归计划,并回归该包覆盖的现有模板
paper场景要记得 PDF 与 DOCX 一起看cv场景要记得中文与英文双入口一起看- 如果没有用户要求,默认不重写正文语义内容
更多文档
- 总规范:
skills/make-latex-model/SKILL.md - 工作流:
skills/make-latex-model/docs/WORKFLOW.md - 常见问题:
skills/make-latex-model/docs/FAQ.md - 基线制作:
skills/make-latex-model/docs/BASELINE_GUIDE.md - 产品线规则:
skills/make-latex-model/references/PRODUCT_LINE_RULES.md - 脚本职责矩阵:
skills/make-latex-model/references/SCRIPT_SCOPE.md - 辅助脚本:
skills/make-latex-model/scripts/README.md
产品线判定与初始化规则
本文件把 make-latex-model 的产品线判定、初始化标记与官方构建入口从 SKILL.md 中拆出,避免核心工作文档继续膨胀。
单一真相来源
- 机器可读规则以 `config.yaml` 中的
product_line_rules、official_build_commands、baseline.preferred_candidates为准。 - 本文件负责给 AI 和维护者解释“为什么这样判定”,不重复承载脚本细节。
当前规则
| 产品线 | 目录/名称识别 | 初始化标记 | 官方验证命令 |
|---|---|---|---|
nsfc | projects/NSFC_* | main.tex + extraTex/@config.tex | python packages/bensz-nsfc/scripts/nsfc_project_tool.py build --project-dir <project> |
paper | projects/paper-* | main.tex + extraTex/ | python packages/bensz-paper/scripts/paper_project_tool.py build --project-dir <project> |
thesis | projects/thesis-* | main.tex + template.json | python packages/bensz-thesis/scripts/thesis_project_tool.py build --project-dir <project> |
cv | projects/cv-* | main-zh.tex + main-en.tex | python packages/bensz-cv/scripts/cv_project_tool.py build --project-dir <project> --variant all |
基线文件建议
若用户提供 PDF 基线,优先检查以下位置:
1. template/baseline.pdf 2. .make_latex_model/baselines/baseline.pdf 3. .make_latex_model/baselines/word.pdf
其中:
baseline.pdf是当前推荐命名。word.pdf仅作为兼容旧命名的兜底路径,不应再作为文档默认口径。
维护约束
- 新增产品线时,先更新
config.yaml,再同步本文件与SKILL.md的摘要说明。 - 不要再把“是否初始化完成”的判定硬编码为
extraTex/@config.tex是否存在。
公共包改动的默认回归范围
如果任务已经明确需要修改 packages/ 下公共包,默认按下表规划受影响项目:
| 公共包 | 默认回归范围 |
|---|---|
packages/bensz-fonts/ | projects/NSFC_*、projects/paper-*、projects/thesis-*、projects/cv-* |
packages/bensz-nsfc/ | 全部 projects/NSFC_* |
packages/bensz-paper/ | 全部 projects/paper-* |
packages/bensz-thesis/ | 全部 projects/thesis-* |
packages/bensz-cv/ | 全部 projects/cv-* |
推荐先运行:
python3 skills/make-latex-model/scripts/plan_package_regression.py packages/bensz-thesis该脚本会从 config.yaml 读取单一真相来源,输出受影响项目、官方 build 命令,以及在可用时附带 compare 建议。
脚本职责矩阵
本文件说明 make-latex-model 当前有哪些脚本能力,以及它们分别适合什么任务。
跨产品线辅助脚本
以下脚本可作为通用辅助工具使用:
scripts/check_state.py
用于识别项目产品线、检查初始化标记、基线与官方构建入口。
scripts/plan_package_regression.py
用于在修改 packages/bensz-* 前生成回归矩阵。
scripts/analyze_pdf.py
用于提取 PDF 基线参数,不限定产品线。
scripts/compare_headings.py
用于标题文本或格式比对。
scripts/compare_pdf_pixels.py
用于像素级 PDF 对比。
scripts/optimize_heading_linebreaks.py
用于根据 PDF 基线优化标题换行。
NSFC 专项工具
以下脚本主要服务 NSFC 参数对齐、批量校验或基于模板配置的专项分析:
scripts/validate.shscripts/validate.batscripts/benchmark.shscripts/benchmark.batscripts/optimize.shscripts/optimize.batscripts/optimize.pyscripts/enhanced_optimize.pyscripts/run_ai_optimizer.pyscripts/intelligent_adjust.pyscripts/sync_config.pyscripts/core/template_catalog.pycore/config_loader.py
这些工具适合下列场景:
1. 目标明确是 projects/NSFC_*。 2. 任务需要做 NSFC 参数级调优、批量实验或基于模板 YAML 的专项分析。 3. 官方产品线脚本已覆盖主构建,但你还需要额外的 NSFC 诊断能力。
使用约束
- 不要拿 NSFC 专项工具替代
paper_project_tool.py、thesis_project_tool.py或cv_project_tool.py。 - 不要把 thesis / paper / cv 强行解释成“只要改
@config.tex就行”的问题。 - 对任何产品线,官方构建脚本始终优先于本目录的分析脚本。
# make_latex_model scripts package
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
PDF 样式分析工具
提取 PDF 中的关键样式信息:字号、颜色、间距等
用于分析 Word 导出的 PDF 基准,自动提取样式参数
"""
import sys
import json
import argparse
from pathlib import Path
from collections import defaultdict
SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
sys.path.insert(0, str(SKILL_DIR))
# 检查依赖
try:
import fitz # PyMuPDF
except ImportError:
print("❌ 错误: 缺少依赖库 PyMuPDF")
print("请运行: pip install PyMuPDF")
sys.exit(1)
# 导入 WorkspaceManager
try:
from scripts.core.workspace_manager import WorkspaceManager
except ImportError:
print("⚠️ 警告: 无法导入 WorkspaceManager,将使用当前目录保存结果")
WorkspaceManager = None
def extract_color_info(color):
"""提取颜色信息 (RGB 0-255)"""
if color is None:
return None
# 处理不同格式的颜色数据
if isinstance(color, (list, tuple)):
if len(color) >= 3:
# 如果已经是 0-255 范围
if color[0] > 1:
return (int(color[0]), int(color[1]), int(color[2]))
# 如果是 0-1 范围,转换为 0-255
else:
return (int(color[0] * 255), int(color[1] * 255), int(color[2] * 255))
return None
def analyze_pdf_fonts(pdf_path):
"""分析 PDF 中的字体使用情况"""
doc = fitz.open(pdf_path)
font_stats = defaultdict(lambda: {
"count": 0,
"sizes": set(),
"colors": set(),
"flags": set()
})
for page_num in range(len(doc)):
page = doc[page_num]
blocks = page.get_text("dict")["blocks"]
for block in blocks:
if "lines" not in block:
continue
for line in block["lines"]:
for span in line["spans"]:
font = span["font"]
size = span["size"]
color = extract_color_info(span.get("color"))
flags = span.get("flags", 0)
font_stats[font]["count"] += 1
font_stats[font]["sizes"].add(round(size, 2))
if color:
font_stats[font]["colors"].add(color)
font_stats[font]["flags"].add(flags)
# 转换 sets 为 sorted lists 以便 JSON 序列化
result = {}
for font, stats in font_stats.items():
result[font] = {
"count": stats["count"],
"sizes": sorted(list(stats["sizes"])),
"colors": [list(c) for c in sorted(stats["colors"])],
"is_bold": bool(2**4 in stats["flags"]) # 16 = bold
}
doc.close()
return result
def analyze_page_layout(pdf_path):
"""分析页面布局信息"""
doc = fitz.open(pdf_path)
page = doc[0] # 分析第一页
# 获取页面尺寸
rect = page.rect
width_pt = rect.width
height_pt = rect.height
# 转换为 cm (1 pt = 0.0352778 cm)
width_cm = round(width_pt * 0.0352778, 2)
height_cm = round(height_pt * 0.0352778, 2)
# 分析文本边界来确定边距
blocks = page.get_text("dict")["blocks"]
if blocks:
# 找到文本块的边界
text_left = min(b["bbox"][0] for b in blocks if "lines" in b)
text_right = max(b["bbox"][2] for b in blocks if "lines" in b)
text_top = min(b["bbox"][1] for b in blocks if "lines" in b)
text_bottom = max(b["bbox"][3] for b in blocks if "lines" in b)
# 计算边距 (pt 转 cm)
margin_left = round(text_left * 0.0352778, 2)
margin_right = round((width_pt - text_right) * 0.0352778, 2)
margin_top = round(text_top * 0.0352778, 2)
margin_bottom = round((height_pt - text_bottom) * 0.0352778, 2)
else:
margin_left = margin_right = margin_top = margin_bottom = None
doc.close()
return {
"page_size_cm": (width_cm, height_cm),
"margins_cm": {
"left": margin_left,
"right": margin_right,
"top": margin_top,
"bottom": margin_bottom
}
}
def analyze_line_spacing(pdf_path, page_num=0):
"""分析行距"""
doc = fitz.open(pdf_path)
page = doc[page_num]
blocks = page.get_text("dict")["blocks"]
line_heights = []
for block in blocks:
if "lines" not in block:
continue
prev_y = None
for line in block["lines"]:
y0 = line["bbox"][1]
y1 = line["bbox"][3]
height = y1 - y0
if prev_y is not None:
line_spacing = y0 - prev_y
line_heights.append(line_spacing)
prev_y = y0
# 计算平均行距和字体大小的比率
if line_heights:
avg_line_spacing = sum(line_heights) / len(line_heights)
else:
avg_line_spacing = 0
doc.close()
return round(avg_line_spacing, 2)
def main():
parser = argparse.ArgumentParser(
description="PDF 样式分析工具 - 提取 PDF 中的关键样式信息",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
示例:
python analyze_pdf.py word_baseline.pdf
python analyze_pdf.py projects/NSFC_General/template/word.pdf --project NSFC_General
# 输出将保存到:projects/NSFC_General/.make_latex_model/baselines/<stem>_analysis.json
python analyze_pdf.py word.pdf --output custom_analysis.json
"""
)
parser.add_argument("pdf_path", help="PDF 文件路径")
parser.add_argument("--project", help="项目名称(如 NSFC_General),用于保存到 projects/<project>/.make_latex_model/baselines/")
parser.add_argument("--output", "-o", help="自定义输出 JSON 文件路径")
parser.add_argument("--no-workspace", action="store_true", help="不使用工作空间,直接保存到当前目录")
args = parser.parse_args()
pdf_path = args.pdf_path
pdf_file = Path(pdf_path)
if not pdf_file.exists():
print(f"❌ 错误: 文件不存在: {pdf_path}")
sys.exit(1)
if not pdf_file.suffix.lower() == '.pdf':
print(f"⚠️ 警告: 文件扩展名不是 .pdf: {pdf_path}")
print("继续分析...\n")
print(f"\n{'='*60}")
print(f"PDF 样式分析工具")
print(f"{'='*60}")
print(f"分析文件: {pdf_path}")
print(f"文件大小: {pdf_file.stat().st_size / 1024:.1f} KB")
print(f"{'='*60}\n")
# 分析页面布局
print("=" * 60)
print("页面布局")
print("=" * 60)
layout = analyze_page_layout(pdf_path)
print(f"页面尺寸: {layout['page_size_cm'][0]} cm x {layout['page_size_cm'][1]} cm")
print(f"边距:")
print(f" 左: {layout['margins_cm']['left']} cm")
print(f" 右: {layout['margins_cm']['right']} cm")
print(f" 上: {layout['margins_cm']['top']} cm")
print(f" 下: {layout['margins_cm']['bottom']} cm")
# 分析字体
print("\n" + "=" * 60)
print("字体使用统计")
print("=" * 60)
fonts = analyze_pdf_fonts(pdf_path)
# 按使用频率排序
sorted_fonts = sorted(fonts.items(), key=lambda x: x[1]["count"], reverse=True)
for font, stats in sorted_fonts[:10]: # 显示前 10 个字体
print(f"\n字体: {font}")
print(f" 使用次数: {stats['count']}")
print(f" 字号: {stats['sizes']}")
print(f" 颜色 (RGB): {stats['colors']}")
print(f" 是否加粗: {stats['is_bold']}")
# 分析行距
print("\n" + "=" * 60)
print("行距分析")
print("=" * 60)
line_spacing = analyze_line_spacing(pdf_path)
print(f"平均行距: {line_spacing} pt")
# 导出为 JSON
output_path = None
workspace_info = ""
# 优先级 1: 用户指定了自定义输出路径
if args.output:
output_path = Path(args.output)
workspace_info = "(自定义路径)"
# 优先级 2: 用户指定了项目名称且 WorkspaceManager 可用
elif args.project and WorkspaceManager and not args.no_workspace:
ws_manager = WorkspaceManager(SKILL_DIR)
baseline_dir = ws_manager.get_baseline_path(args.project)
output_path = baseline_dir / f"{pdf_file.stem}_analysis.json"
workspace_info = f"(工作空间: {baseline_dir})"
# 默认: 使用 PDF 所在目录(向后兼容)
else:
# NOTE: keep backward-compatible default (next to the PDF), but ensure Path
output_path = Path(pdf_path).with_name(Path(pdf_path).stem + "_analysis.json")
if WorkspaceManager and not args.no_workspace:
workspace_info = "(当前目录,建议使用 --project 参数保存到 projects/<project>/.make_latex_model/baselines/)"
output_data = {
"source_file": str(pdf_file),
"file_size_kb": round(pdf_file.stat().st_size / 1024, 2),
"layout": layout,
"fonts": fonts,
"line_spacing_pt": line_spacing
}
# 确保输出目录存在
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, "w", encoding="utf-8") as f:
json.dump(output_data, f, indent=2, ensure_ascii=False)
print(f"\n{'='*60}")
print(f"✅ 分析完成")
print(f"{'='*60}")
print(f"详细分析结果已保存到: {output_path} {workspace_info}")
print(f"{'='*60}\n")
if __name__ == "__main__":
main()
@echo off
REM ================================
REM make_latex_model 性能基准测试 (Windows)
REM ================================
setlocal enabledelayedexpansion
set SCRIPT_DIR=%~dp0
set SKILL_DIR=%SCRIPT_DIR%..
set BASE_DIR=%SKILL_DIR%\..\..
set PROJECT=%~1
if "%PROJECT%"=="" set PROJECT=NSFC_Young
echo ========================================
echo 性能基准测试
echo ========================================
echo.
echo 项目: %PROJECT%
echo.
REM 设置项目路径
if exist "%BASE_DIR%\projects\%PROJECT%" (
set PROJECT_PATH=%BASE_DIR%\projects\%PROJECT%
) else (
set PROJECT_PATH=%PROJECT%
)
echo 工作目录: %PROJECT_PATH%
echo.
REM 测试编译时间
echo 测试编译性能...
cd /d "%PROJECT_PATH%"
echo [1/3] 清理旧文件...
del /q main.aux main.bbl main.blg main.log main.out main.pdf 2>nul
echo [2/3] 编译 LaTeX...
echo %time% > compile_time.txt
xelatex -interaction=nonstopmode main.tex >nul 2>&1
if errorlevel 1 (
echo 编译失败
goto :eof
)
echo %time% >> compile_time.txt
echo [3/3] 获取文件大小...
for %%F in (main.pdf) do set PDF_SIZE=%%~zF
echo.
echo ========================================
echo 测试结果
echo ========================================
echo.
echo PDF 文件大小: %PDF_SIZE% bytes
echo.
REM 显示编译时间
echo 编译时间:
type compile_time.txt
echo.
del /q compile_time.txt
echo 测试完成
#!/bin/bash
# ================================
# make_latex_model 性能基准测试脚本
# ================================
# 配置
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SKILL_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
BASE_DIR="$(cd "$SKILL_DIR/../.." && pwd)"
PROJECT="$BASE_DIR/projects/NSFC_Young"
TIMES=3
OUTPUT_DIR="$SCRIPT_DIR/../output"
OUTPUT_FILE="$OUTPUT_DIR/benchmark_results.json"
# 创建输出目录
mkdir -p "$OUTPUT_DIR"
# 开始测试
echo "=== make_latex_model 性能基准测试 ==="
echo "测试时间: $(date)"
echo "测试次数: $TIMES"
echo ""
# 保存当前目录
CURRENT_DIR=$(pwd)
# 检查项目是否存在
if [ ! -d "$PROJECT" ]; then
echo "❌ 错误: 项目目录不存在: $PROJECT"
exit 1
fi
cd "$PROJECT"
# 清理临时文件
rm -f main.aux main.log main.out main.bbl main.blg
# 编译时间测试
echo "📊 编译性能测试..."
TOTAL_TIME=0
for i in $(seq 1 $TIMES); do
echo " [测试 $i/$TIMES] 编译 main.tex..."
# 测量编译时间(毫秒)
START=$(python3 -c "import time; print(int(time.time() * 1000))")
xelatex -interaction=nonstopmode main.tex > /dev/null 2>&1
END=$(python3 -c "import time; print(int(time.time() * 1000))")
DURATION=$((END - START))
TOTAL_TIME=$((TOTAL_TIME + DURATION))
# 转换为秒
DURATION_SEC=$(awk "BEGIN {printf \"%.2f\", $DURATION/1000}")
echo " ⏱️ 耗时: ${DURATION_SEC} 秒"
done
# 计算平均时间
AVG_TIME=$((TOTAL_TIME / TIMES))
AVG_TIME_SEC=$(awk "BEGIN {printf \"%.2f\", $AVG_TIME/1000}")
echo ""
echo "📈 平均编译时间: ${AVG_TIME_SEC} 秒"
# 检查 PDF 文件大小
PDF_SIZE=$(ls -l "$PROJECT/main.pdf" | awk '{print $5}')
PDF_SIZE_MB=$(awk "BEGIN {printf \"%.2f\", $PDF_SIZE/1024/1024}")
echo "📄 PDF 文件大小: ${PDF_SIZE_MB} MB"
cd "$CURRENT_DIR"
# 生成 JSON 报告
cat > "$OUTPUT_FILE" << EOF
{
"test_info": {
"test_time": "$(date -u +"%Y-%m-%dT%H:%M:%SZ")",
"platform": "$(uname -s) $(uname -r)",
"machine": "$(uname -m)"
},
"compilation": {
"times": $TIMES,
"total_time_ms": $TOTAL_TIME,
"average_time_ms": $AVG_TIME,
"average_time_sec": ${AVG_TIME_SEC}
},
"pdf": {
"size_bytes": $PDF_SIZE,
"size_mb": ${PDF_SIZE_MB}
}
}
EOF
# 输出结果
echo ""
echo "✅ 测试完成!"
echo ""
echo "📄 结果已保存到: $OUTPUT_FILE"
echo ""
if command -v python3 &> /dev/null; then
python3 -m json.tool "$OUTPUT_FILE"
else
cat "$OUTPUT_FILE"
fi
echo ""
echo "=== 性能基准测试完成 ==="
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
项目状态检查工具
AI 调用技能前执行此脚本,了解项目当前状态
"""
import sys
import json
from pathlib import Path
from datetime import datetime
import yaml
SCRIPT_DIR = Path(__file__).parent
SKILL_DIR = SCRIPT_DIR.parent
REPO_ROOT = SKILL_DIR.parent.parent
PROJECTS_ROOT = (REPO_ROOT / "projects").resolve()
sys.path.insert(0, str(SKILL_DIR))
from scripts.core.workspace_manager import WorkspaceManager
def load_skill_config() -> dict:
"""读取 skill 配置。"""
config_path = SKILL_DIR / "config.yaml"
if not config_path.exists():
return {}
try:
return yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def detect_product_line(project_path: Path, config: dict) -> str:
"""根据 config.yaml 的规则识别产品线。"""
rules = config.get("product_line_rules") or {}
haystacks = [project_path.name.lower()]
try:
haystacks.append(str(project_path.relative_to(PROJECTS_ROOT)).lower())
except Exception:
pass
for product_line, rule in rules.items():
for pattern in rule.get("detect_patterns", []):
pattern_lc = str(pattern).lower()
if any(pattern_lc in haystack for haystack in haystacks):
return product_line
return "unknown"
def get_required_markers(config: dict, product_line: str) -> list[str]:
rules = config.get("product_line_rules") or {}
rule = rules.get(product_line) or {}
return [str(marker) for marker in rule.get("required_markers", [])]
def get_official_build_command(project_path: Path, config: dict, product_line: str) -> str:
rules = config.get("product_line_rules") or {}
commands = config.get("official_build_commands") or {}
rule = rules.get(product_line) or {}
command_key = rule.get("official_build_key", product_line)
command = commands.get(command_key)
if not command:
return ""
return command.replace("<project>", str(project_path.relative_to(REPO_ROOT)))
def check_project_state(project_path: Path) -> dict:
"""检查项目当前状态"""
project_path = project_path.resolve()
config = load_skill_config()
product_line = detect_product_line(project_path, config)
required_markers = get_required_markers(config, product_line)
marker_status = {marker: (project_path / marker).exists() for marker in required_markers}
official_build_command = get_official_build_command(project_path, config, product_line)
state = {
"project_path": str(project_path),
"check_time": datetime.now().isoformat(),
"status": {},
"recommendations": []
}
ws_manager = WorkspaceManager(SKILL_DIR)
ws_root = ws_manager.get_project_workspace(project_path)
# 1. 检查项目是否已初始化(按产品线规则,而不是硬编码 NSFC)
state["status"]["product_line"] = product_line
state["status"]["required_markers"] = marker_status
state["status"]["official_build_command"] = official_build_command
state["status"]["initialized"] = all(marker_status.values()) if marker_status else (project_path / "main.tex").exists()
if not state["status"]["initialized"]:
missing_markers = [marker for marker, exists in marker_status.items() if not exists]
if missing_markers:
state["recommendations"].append(
f"项目初始化标记不完整(产品线: {product_line}),缺少: {', '.join(missing_markers)}"
)
else:
state["recommendations"].append("项目未初始化,请先补齐该产品线的入口文件")
# 2. 检查是否有 PDF 基准(推荐 baseline.pdf;兼容 word.pdf)
baseline_dir = ws_root / "baselines"
pdf_files = list(baseline_dir.glob("*.pdf")) if baseline_dir.exists() else []
state["status"]["has_baseline"] = len(pdf_files) > 0
state["status"]["baseline_source"] = "unknown"
state["status"]["baseline_dir"] = str(baseline_dir)
if pdf_files:
# 检测基准来源
baseline_pdf = next(
(p for p in pdf_files if p.name.lower() == "baseline.pdf"),
next((p for p in pdf_files if p.name.lower() == "word.pdf"), pdf_files[0]),
)
baseline_info = detect_baseline_source(baseline_pdf)
state["status"]["baseline_source"] = baseline_info["source"]
state["status"]["baseline_quality"] = baseline_info["quality"]
if baseline_info["source"] == "quicklook":
state["recommendations"].append(
"⚠️ 检测到 QuickLook 基准,像素对比结果可能不准确,建议使用 Word 导出 PDF"
)
if not state["status"]["has_baseline"]:
preferred_candidates = config.get("baseline", {}).get("preferred_candidates", [])
preferred_text = "、".join(preferred_candidates) if preferred_candidates else "template/baseline.pdf"
state["recommendations"].append(
f"缺少 PDF 基准。可优先提供官方 PDF / Word 导出 PDF / 已验收 baseline PDF,并放到 `{preferred_text}` 之一;旧版 `word.pdf` 路径仍兼容。"
)
# 3. 检查编译状态
main_pdf = project_path / "main.pdf"
if main_pdf.exists():
# 检查修改时间
pdf_time = datetime.fromtimestamp(main_pdf.stat().st_mtime)
state["status"]["last_compilation"] = pdf_time.isoformat()
state["status"]["compilation_status"] = "success" # 简化判断
else:
state["status"]["compilation_status"] = "not_compiled"
if official_build_command:
state["recommendations"].append(f"项目未编译,建议先执行官方构建命令:{official_build_command}")
else:
state["recommendations"].append("项目未编译,建议先执行该产品线的官方构建测试")
# 4. 检查是否有 PDF 分析结果
analysis_files = list(baseline_dir.glob("*_analysis.json")) if baseline_dir.exists() else []
state["status"]["has_analysis"] = len(analysis_files) > 0
if state["status"]["has_analysis"]:
latest_analysis = max(analysis_files, key=lambda p: p.stat().st_mtime)
state["status"]["latest_analysis"] = str(latest_analysis.name)
else:
analysis_cmd = config.get("baseline", {}).get("analysis_command", "python skills/make-latex-model/scripts/analyze_pdf.py <baseline.pdf>")
state["recommendations"].append(
f"缺少 PDF 分析结果,建议执行: {analysis_cmd}"
)
return state
def detect_baseline_source(pdf_path: Path) -> dict:
"""检测 PDF 基准来源"""
# 简化判断:通过文件名或元数据
filename = pdf_path.name.lower()
if "quicklook" in filename or "ql" in filename:
return {"source": "quicklook", "quality": "low"}
elif "baseline" in filename:
return {"source": "baseline_pdf", "quality": "high"}
elif "word" in filename:
return {"source": "word_pdf", "quality": "high"}
else:
return {"source": "unknown", "quality": "medium"}
def print_report(state: dict):
"""打印状态报告"""
print(f"\n{'='*60}")
print("项目状态检查报告")
print(f"{'='*60}")
print(f"项目路径: {state['project_path']}")
print(f"检查时间: {state['check_time']}")
print(f"产品线: {state.get('status', {}).get('product_line', 'unknown')}")
build_cmd = state.get("status", {}).get("official_build_command")
if build_cmd:
print(f"官方构建命令: {build_cmd}")
print(f"\n状态概览:")
status_map = {
"initialized": ("✅ 已初始化", "❌ 未初始化"),
"has_baseline": ("✅ 有基准", "❌ 无基准"),
"compilation_status": ("✅ 编译成功", "⚠️ 未编译"),
"has_analysis": ("✅ 有分析", "⚠️ 无分析"),
}
for key, (yes, no) in status_map.items():
if key in state["status"]:
value = state["status"][key]
if isinstance(value, bool):
print(f" {yes if value else no}")
elif isinstance(value, str):
print(f" {key}: {value}")
baseline_source = state.get("status", {}).get("baseline_source")
if baseline_source:
print(f"\n基准来源: {baseline_source}")
print(f"基准质量: {state.get('status', {}).get('baseline_quality', 'unknown')}")
required_markers = state.get("status", {}).get("required_markers", {})
if required_markers:
print("\n初始化标记:")
for marker, exists in required_markers.items():
print(f" {'✅' if exists else '❌'} {marker}")
if state["recommendations"]:
print(f"\n建议:")
for i, rec in enumerate(state["recommendations"], 1):
print(f" {i}. {rec}")
print(f"{'='*60}\n")
def main():
if len(sys.argv) < 2:
print("用法: python check_state.py <project_path>")
sys.exit(1)
raw = str(sys.argv[1]).strip()
p = Path(raw)
if p.exists():
project_path = p
else:
if p.is_absolute() or any(sep in raw for sep in ("/", "\\")):
candidate = p if p.is_absolute() else (REPO_ROOT / p)
else:
candidate = REPO_ROOT / "projects" / raw
project_path = candidate
project_path = project_path.resolve()
if not project_path.exists():
print(f"❌ 错误: 项目路径不存在: {project_path}")
sys.exit(1)
try:
project_path.relative_to(PROJECTS_ROOT)
except Exception:
print(f"❌ 错误: 项目必须位于 {PROJECTS_ROOT} 下: {project_path}")
sys.exit(1)
# 检查状态
state = check_project_state(project_path)
# 打印报告
print_report(state)
# 导出 JSON(供 AI 程序化读取)
ws_root = WorkspaceManager(SKILL_DIR).get_project_workspace(project_path)
output_file = ws_root / "reports" / "state_check.json"
output_file.parent.mkdir(parents=True, exist_ok=True)
output_file.write_text(json.dumps(state, indent=2, ensure_ascii=False))
print(f"✅ 状态已保存到: {output_file}")
if __name__ == "__main__":
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
标题文字对比工具
对比“基准模板(推荐:PDF)”与 LaTeX 文件的标题文字差异
使用方法:
# 对比两个文件
python scripts/compare_headings.py baseline.pdf main.tex
# 输出为 HTML 报告
python scripts/compare_headings.py baseline.pdf main.tex --report output.html
# 输出为 Markdown 报告
python scripts/compare_headings.py baseline.pdf main.tex --report output.md
"""
import argparse
import json
import re
import sys
from pathlib import Path
from typing import Dict, List, Tuple
from datetime import datetime
import warnings
# 允许在任何 cwd 下运行时都能导入同目录脚本
SCRIPT_DIR = Path(__file__).parent
if str(SCRIPT_DIR) not in sys.path:
sys.path.insert(0, str(SCRIPT_DIR))
def extract_from_latex(tex_file: Path, check_format: bool = False) -> Dict[str, any]:
"""
从 LaTeX 文件中提取标题文字
Args:
tex_file: LaTeX 文件路径
check_format: 是否检查格式(加粗)
Returns:
如果 check_format=False: Dict[str, str] - 标题文本
如果 check_format=True: Dict[str, Dict] - 包含文本和格式信息
"""
headings = {}
with open(tex_file, 'r', encoding='utf-8') as f:
content = f.read()
# 预处理:去掉注释(避免把被注释掉的 \subsection{...} 误识别为标题)
content_no_comments_lines = []
for line in content.splitlines():
cleaned = re.sub(r"(?<!\\)%.*$", "", line)
content_no_comments_lines.append(cleaned)
content_no_comments = "\n".join(content_no_comments_lines)
def _extract_braced_arg(src: str, brace_start_idx: int) -> Tuple[str, int]:
"""从 src[brace_start_idx] == '{' 开始提取配对花括号内容(支持嵌套)。"""
if brace_start_idx < 0 or brace_start_idx >= len(src) or src[brace_start_idx] != "{":
return "", brace_start_idx
depth = 1
i = brace_start_idx + 1
arg_start = i
while i < len(src) and depth > 0:
ch = src[i]
# 跳过转义字符(避免把 \{ \} 误判为结构花括号)
if ch == "\\" and i + 1 < len(src):
i += 2
continue
if ch == "{":
depth += 1
elif ch == "}":
depth -= 1
i += 1
if depth != 0:
# 括号不平衡,回退为空
return "", brace_start_idx
return src[arg_start : i - 1], i
def _iter_command_args(src: str, command: str):
# 允许命令与 { 之间有空白
for m in re.finditer(rf"\\{re.escape(command)}\s*\{{", src):
brace_idx = m.end() - 1 # 指向 '{'
arg, end_idx = _extract_braced_arg(src, brace_idx)
if arg:
yield (m.start(), arg)
# 以“文档顺序”为准同时提取 \section{} 与(NSFC 模板常用的)\NSFCSubsection{} / \subsection{}
tokens = []
for pos, arg in _iter_command_args(content_no_comments, "section"):
tokens.append((pos, "section", arg))
for pos, arg in _iter_command_args(content_no_comments, "NSFCSubsection"):
tokens.append((pos, "nsfc_subsection", arg))
for pos, arg in _iter_command_args(content_no_comments, "subsection"):
tokens.append((pos, "subsection", arg))
tokens.sort(key=lambda x: x[0])
section_num = 0
subsection_num = 0
for _, kind, raw in tokens:
if kind == "section":
section_num += 1
subsection_num = 0
key = f"section_{section_num}"
else:
if section_num <= 0:
# 忽略“没有 section 上下文”的二级标题(通常不应出现)
continue
subsection_num += 1
key = f"subsection_{section_num}_{subsection_num}"
if check_format:
headings[key] = {
"text": clean_latex_text(raw),
"fragments": extract_formatted_text_from_latex(raw),
}
else:
headings[key] = clean_latex_text(raw)
return headings
def clean_latex_text(text: str) -> str:
"""清理 LaTeX 文本中的格式标记"""
try:
from core.latex_format_parser import LatexFormatParser
cleaned = LatexFormatParser.clean_latex_text(text)
cleaned = cleaned.replace("~", " ")
cleaned = re.sub(r"\s+", " ", cleaned).strip()
return cleaned
except Exception:
# fallback: 旧版正则(不支持嵌套/声明式格式)
text = re.sub(r'\\[a-zA-Z]+', '', text)
text = re.sub(r'\{|\}', '', text)
# 渲染层面的空白归一:~ 在 TeX 中等价于不换行空格
text = text.replace('~', ' ')
text = re.sub(r'\s+', ' ', text)
text = text.strip()
return text
def clean_latex_commands(text: str) -> str:
"""清理 LaTeX 命令,但保留 \textbf 和 \bfseries"""
# 删除除 \textbf、\bfseries 外的所有命令
text = re.sub(r'\\(?!textbf|bfseries)[a-zA-Z]+', '', text)
text = re.sub(r'\{|\}', '', text)
# 渲染层面的空白归一:~ 在 TeX 中等价于不换行空格
text = text.replace('~', ' ')
# 格式对比需要保留片段边界处的空格(例如 "1. "),因此不做 strip()。
text = re.sub(r'\s+', ' ', text)
return text
def extract_formatted_text_from_word(paragraph) -> List[Dict[str, any]]:
"""
从 Word 段落中提取带格式信息的文本片段
Args:
paragraph: python-docx 的段落对象
Returns:
[
{"text": "立项依据", "bold": True},
{"text": "与研究内容", "bold": False}
]
"""
fragments = []
for run in paragraph.runs:
text = run.text
if not text:
continue
fragments.append({
"text": text,
"bold": run.bold if run.bold is not None else False
})
return fragments
def extract_formatted_text_from_latex(latex_text: str) -> List[Dict[str, any]]:
"""
从 LaTeX 文本中提取带格式信息的片段
支持的格式:
- \textbf{文本} (推荐)
- {\bfseries 文本} (传统方式)
Args:
latex_text: LaTeX 标题文本
Returns:
[
{"text": "立项依据", "bold": True},
{"text": "与研究内容", "bold": False}
]
"""
try:
from core.latex_format_parser import LatexFormatParser
parsed = LatexFormatParser.extract_formatted_text(latex_text)
# compare_headings 的格式对比目前只关心 bold;但保留其它字段不影响结果
out: List[Dict[str, any]] = []
for frag in parsed:
t = str(frag.get("text", "") or "")
t = t.replace("\u00a0", " ").replace("~", " ")
if not t:
continue
out.append({"text": t, "bold": bool(frag.get("bold", False))})
# 合并相邻同样 bold 的片段,减少字符级对齐噪声
merged: List[Dict[str, any]] = []
for frag in out:
if not merged:
merged.append(frag)
continue
if bool(merged[-1].get("bold")) == bool(frag.get("bold")):
merged[-1]["text"] += frag["text"]
else:
merged.append(frag)
return merged
except Exception:
# fallback: 旧版只识别 \textbf{...}
fragments: List[Dict[str, any]] = []
textbf_pattern = r'\\textbf\{([^}]+)\}'
bold_segments = []
for match in re.finditer(textbf_pattern, latex_text):
bold_segments.append(
{"start": match.start(), "end": match.end(), "text": match.group(1), "bold": True}
)
bold_segments.sort(key=lambda x: x["start"])
last_end = 0
for seg in bold_segments:
if seg["start"] > last_end:
normal_text = latex_text[last_end:seg["start"]]
normal_text = clean_latex_commands(normal_text)
if normal_text:
fragments.append({"text": normal_text, "bold": False})
fragments.append({"text": seg["text"].replace("~", " "), "bold": True})
last_end = seg["end"]
if last_end < len(latex_text):
normal_text = latex_text[last_end:]
normal_text = clean_latex_commands(normal_text)
if normal_text:
fragments.append({"text": normal_text, "bold": False})
return fragments
def compare_formatted_text(word_fragments: List[Dict],
latex_fragments: List[Dict]) -> Dict[str, any]:
"""
对比 Word 和 LaTeX 的格式化文本
Args:
word_fragments: Word 格式片段列表
latex_fragments: LaTeX 格式片段列表
Returns:
{
"match": true/false,
"word_text": "立项依据与研究内容",
"latex_text": "立项依据与研究内容",
"differences": [
{
"type": "bold_mismatch",
"word_fragment": {"text": "立项依据", "bold": True},
"latex_fragment": {"text": "立项依据", "bold": False},
"position": "0-4"
}
]
}
"""
# 提取纯文本进行初步对比
word_text = "".join(f["text"] for f in word_fragments)
latex_text = "".join(f["text"] for f in latex_fragments)
if word_text != latex_text:
return {
"match": False,
"reason": "text_mismatch",
"word_text": word_text,
"latex_text": latex_text,
"word_fragments": word_fragments,
"latex_fragments": latex_fragments,
}
# 对齐片段并对比格式
differences = []
word_pos = 0
word_idx = 0
latex_idx = 0
# 创建可修改的片段副本
word_frags = [f.copy() for f in word_fragments]
latex_frags = [f.copy() for f in latex_fragments]
while word_idx < len(word_frags) and latex_idx < len(latex_frags):
word_frag = word_frags[word_idx]
latex_frag = latex_frags[latex_idx]
# 计算当前片段的文本长度
word_len = len(word_frag["text"])
latex_len = len(latex_frag["text"])
# 找到最小长度
min_len = min(word_len, latex_len)
# 对比前 min_len 个字符的格式
for i in range(min_len):
if word_frag["bold"] != latex_frag["bold"]:
char_pos = word_pos + i
differences.append({
"type": "bold_mismatch",
"position": char_pos,
"char": word_frag["text"][i],
"word_bold": word_frag["bold"],
"latex_bold": latex_frag["bold"]
})
# 更新位置
word_pos += min_len
word_frag["text"] = word_frag["text"][min_len:]
latex_frag["text"] = latex_frag["text"][min_len:]
word_len -= min_len
latex_len -= min_len
# 如果 Word 片段用完了,移到下一个
if word_len == 0:
word_idx += 1
# 如果 LaTeX 片段用完了,移到下一个
if latex_len == 0:
latex_idx += 1
return {
"match": len(differences) == 0,
"word_text": word_text,
"latex_text": latex_text,
"differences": differences,
"word_fragments": word_fragments,
"latex_fragments": latex_fragments,
}
def extract_from_word(doc_file: Path, check_format: bool = False) -> Dict[str, any]:
"""
⚠️ 兼容保留:建议改用 PDF 作为标题/格式基准。
从 Word 文档中提取标题文字
Args:
doc_file: Word 文档路径
check_format: 是否检查格式(加粗)
Returns:
如果 check_format=False: Dict[str, str] - 标题文本
如果 check_format=True: Dict[str, Dict] - 包含文本和格式信息
"""
warnings.warn(
"Word(.docx) 标题提取仅为向后兼容保留;推荐使用 PDF 基准(Single Source of Truth)。",
DeprecationWarning,
stacklevel=2,
)
try:
from docx import Document
except ImportError:
print("错误: 需要安装 python-docx 库")
print("安装命令: pip install python-docx")
sys.exit(1)
if not doc_file.suffix == '.docx':
print(f"警告: {doc_file} 是 .doc 格式,建议转换为 .docx")
sys.exit(1)
doc = Document(doc_file)
def _add_heading(out: Dict[str, any], key: str, paragraph):
if check_format:
out[key] = {
"text": paragraph.text.strip(),
"fragments": extract_formatted_text_from_word(paragraph),
}
else:
out[key] = paragraph.text.strip()
headings: Dict[str, any] = {}
# 1) 优先走“标准标题样式”路径(适配常规 Word 文档)
section_count = 0
subsection_count = 0
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if not text:
continue
style_name = paragraph.style.name if paragraph.style else ""
if "Heading 1" in style_name or "标题 1" in style_name:
section_count += 1
subsection_count = 0
_add_heading(headings, f"section_{section_count}", paragraph)
elif "Heading 2" in style_name or "标题 2" in style_name:
if section_count <= 0:
continue
subsection_count += 1
_add_heading(headings, f"subsection_{section_count}_{subsection_count}", paragraph)
if headings:
return headings
# 2) 回退:NSFC 等模板常把提纲标题设为 Normal 样式(用文本模式识别)
section_re = re.compile(r"^([一二三四五六七八九十]+)")
subsection_re = re.compile(r"^\s*\d+\s*[\..、]")
section_count = 0
subsection_count = 0
for paragraph in doc.paragraphs:
text = paragraph.text.strip()
if not text:
continue
if section_re.match(text):
section_count += 1
subsection_count = 0
_add_heading(headings, f"section_{section_count}", paragraph)
continue
if subsection_re.match(text):
if section_count <= 0:
continue
subsection_count += 1
_add_heading(headings, f"subsection_{section_count}_{subsection_count}", paragraph)
return headings
def extract_from_pdf(pdf_file: Path, check_format: bool = False) -> Dict[str, any]:
"""从 PDF 中提取标题文字(可选:加粗片段、换行点)。"""
try:
from extract_headings_from_pdf import extract_headings_from_pdf
except Exception as e:
raise RuntimeError(f"无法导入 extract_headings_from_pdf.py: {e}")
return extract_headings_from_pdf(pdf_file, check_format=check_format)
def extract_from_source(source_file: Path, check_format: bool = False) -> Dict[str, any]:
"""
从基准源提取标题(推荐:PDF;兼容:DOCX)。
优先级:
- .pdf → extract_from_pdf
- .docx → extract_from_word(deprecated)
"""
suf = source_file.suffix.lower()
if suf == ".pdf":
return extract_from_pdf(source_file, check_format=check_format)
if suf == ".docx":
return extract_from_word(source_file, check_format=check_format)
raise ValueError(f"不支持的基准文件格式: {source_file}")
def compare_headings(word_headings: Dict[str, str], latex_headings: Dict[str, str]) -> Tuple[List, List, List]:
"""
对比两个标题字典(仅文本对比)
Returns:
(完全匹配的列表, 有差异的列表, 仅在一方存在的列表)
"""
all_keys = set(word_headings.keys()) | set(latex_headings.keys())
matched = []
differences = []
only_in_one = []
for key in sorted(all_keys):
word_value = word_headings.get(key, '')
latex_value = latex_headings.get(key, '')
if word_value == latex_value:
if word_value: # 两者都有且相同
matched.append((key, word_value))
else:
if word_value and latex_value: # 两者都有但不同
differences.append((key, word_value, latex_value))
elif word_value: # 仅在 Word 中
only_in_one.append(('word', key, word_value))
elif latex_value: # 仅在 LaTeX 中
only_in_one.append(('latex', key, latex_value))
return matched, differences, only_in_one
def compare_headings_with_format(word_headings: Dict[str, Dict],
latex_headings: Dict[str, Dict]) -> Tuple[List, List, List, List]:
"""
对比两个标题字典(包含格式对比)
Returns:
(完全匹配的列表, 文本差异列表, 格式差异列表, 仅在一方存在的列表)
"""
all_keys = set(word_headings.keys()) | set(latex_headings.keys())
matched = []
text_diff = []
format_diff = []
only_in_one = []
for key in sorted(all_keys):
word_data = word_headings.get(key)
latex_data = latex_headings.get(key)
if not word_data and not latex_data:
continue
if not word_data:
only_in_one.append(('latex', key, latex_data["text"]))
elif not latex_data:
only_in_one.append(('word', key, word_data["text"]))
else:
# 两者都存在,对比文本和格式
word_text = word_data["text"]
latex_text = latex_data["text"]
if word_text != latex_text:
# 文本不一致
text_diff.append((key, word_text, latex_text))
else:
# 文本一致,对比格式
format_result = compare_formatted_text(
word_data["fragments"],
latex_data["fragments"]
)
if format_result["match"]:
matched.append((key, word_text, format_result))
else:
format_diff.append((key, word_text, format_result))
return matched, text_diff, format_diff, only_in_one
def generate_text_report_with_format(matched: List, text_diff: List, format_diff: List, only_in_one: List) -> str:
"""生成文本格式报告(包含格式对比)"""
lines = []
lines.append('=' * 60)
lines.append(' 标题文字对比报告(包含格式)')
lines.append('=' * 60)
lines.append('')
# 统计
total = len(matched) + len(text_diff) + len(format_diff)
match_count = len(matched)
text_diff_count = len(text_diff)
format_diff_count = len(format_diff)
only_count = len(only_in_one)
lines.append(f'总标题数: {total}')
lines.append(f'✅ 完全匹配(文本+格式): {match_count}')
lines.append(f'⚠️ 文本差异: {text_diff_count}')
lines.append(f'🔶 格式差异: {format_diff_count}')
lines.append(f'❌ 仅在一方: {only_count}')
lines.append('')
# 完全匹配的标题
if matched:
lines.append('# 完全匹配的标题')
lines.append('')
for key, value, _ in matched:
lines.append(f'✅ {key}: {value}')
lines.append('')
# 文本差异
if text_diff:
lines.append('# 文本差异')
lines.append('')
for key, word_value, latex_value in text_diff:
lines.append(f'⚠️ {key}:')
lines.append(f' Word: {word_value}')
lines.append(f' LaTeX: {latex_value}')
lines.append('')
# 格式差异
if format_diff:
lines.append('# 格式差异(加粗)')
lines.append('')
for key, text, result in format_diff:
lines.append(f'🔶 {key}: {text}')
lines.append(' 格式差异:')
# 显示 Word 格式
word_display = []
for frag in result.get("word_fragments", []):
marker = '**' if frag["bold"] else ''
word_display.append(f'{marker}{frag["text"]}{marker}')
lines.append(f' Word: {"".join(word_display)}')
# 显示 LaTeX 格式
latex_display = []
for frag in result.get("latex_fragments", []):
marker = '**' if frag["bold"] else ''
latex_display.append(f'{marker}{frag["text"]}{marker}')
lines.append(f' LaTeX: {"".join(latex_display)}')
# 显示差异详情
if result.get("differences"):
lines.append(' 差异位置:')
for diff in result["differences"]:
char = diff.get("char", "")
word_bold = "加粗" if diff.get("word_bold") else "正常"
latex_bold = "加粗" if diff.get("latex_bold") else "正常"
lines.append(f' 位置 {diff.get("position")}: "{char}" - Word:{word_bold}, LaTeX:{latex_bold}')
lines.append('')
# 仅在一方的标题
if only_in_one:
lines.append('# 仅在一方的标题')
lines.append('')
for source, key, value in only_in_one:
source_label = 'Word' if source == 'word' else 'LaTeX'
lines.append(f'❌ 仅在 {source_label}: {key}')
lines.append(f' {value}')
lines.append('')
return '\n'.join(lines)
def generate_text_report(matched: List, differences: List, only_in_one: List) -> str:
"""生成文本格式报告"""
lines = []
lines.append('=' * 60)
lines.append(' 标题文字对比报告')
lines.append('=' * 60)
lines.append('')
# 统计
total = len(matched) + len(differences)
match_count = len(matched)
diff_count = len(differences)
only_count = len(only_in_one)
lines.append(f'总标题数: {total}')
lines.append(f'✅ 完全匹配: {match_count}')
lines.append(f'⚠️ 有差异: {diff_count}')
lines.append(f'❌ 仅在一方: {only_count}')
lines.append('')
# 完全匹配的标题
if matched:
lines.append('# 完全匹配的标题')
lines.append('')
for key, value in matched:
lines.append(f'✅ {key}: {value}')
lines.append('')
# 有差异的标题
if differences:
lines.append('# 有差异的标题')
lines.append('')
for key, word_value, latex_value in differences:
lines.append(f'⚠️ {key}:')
lines.append(f' Word: {word_value}')
lines.append(f' LaTeX: {latex_value}')
lines.append('')
# 仅在一方的标题
if only_in_one:
lines.append('# 仅在一方的标题')
lines.append('')
for source, key, value in only_in_one:
source_label = 'Word' if source == 'word' else 'LaTeX'
lines.append(f'❌ 仅在 {source_label}: {key}')
lines.append(f' {value}')
lines.append('')
return '\n'.join(lines)
def generate_html_report(matched: List, differences: List, only_in_one: List,
word_file: Path, latex_file: Path) -> str:
"""生成 HTML 格式报告(仅文本对比)"""
match_count = len(matched)
diff_count = len(differences)
only_count = len(only_in_one)
def _esc(s: str) -> str:
return (s or "").replace("&", "&").replace("<", "<").replace(">", ">")
html = f"""<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>标题文字对比报告</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 24px;
border-radius: 10px;
margin-bottom: 20px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}}
.meta {{
opacity: 0.9;
font-size: 14px;
margin-top: 8px;
}}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 20px;
}}
.stat-card {{
background: white;
padding: 16px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}}
.stat-card h3 {{
margin: 0 0 8px 0;
font-size: 13px;
color: #666;
font-weight: 600;
}}
.stat-card .value {{
font-size: 28px;
font-weight: bold;
}}
.matched .value {{ color: #10b981; }}
.differences .value {{ color: #f59e0b; }}
.only .value {{ color: #ef4444; }}
.section {{
background: white;
padding: 20px;
border-radius: 8px;
margin-bottom: 16px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.section h2 {{
margin: 0 0 14px 0;
padding-bottom: 10px;
border-bottom: 2px solid #e5e7eb;
font-size: 18px;
}}
.item {{
padding: 12px;
margin-bottom: 10px;
border-left: 4px solid #ddd;
background: #f9fafb;
border-radius: 4px;
}}
.item.matched {{
border-left-color: #10b981;
background: #f0fdf4;
}}
.item.difference {{
border-left-color: #f59e0b;
background: #fffbeb;
}}
.item.only {{
border-left-color: #ef4444;
background: #fef2f2;
}}
.key {{
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace;
font-size: 13px;
color: #374151;
margin-bottom: 6px;
}}
.value-block {{
font-size: 14px;
margin: 2px 0;
white-space: pre-wrap;
}}
.label {{
display: inline-block;
min-width: 52px;
font-weight: 600;
color: #111827;
}}
</style>
</head>
<body>
<div class="header">
<h1 style="margin:0; font-size: 24px;">标题文字对比报告(仅文本)</h1>
<div class="meta">Word: {_esc(str(word_file))}<br>LaTeX: {_esc(str(latex_file))}</div>
</div>
<div class="stats">
<div class="stat-card matched"><h3>完全匹配</h3><div class="value">{match_count}</div></div>
<div class="stat-card differences"><h3>有差异</h3><div class="value">{diff_count}</div></div>
<div class="stat-card only"><h3>仅在一方</h3><div class="value">{only_count}</div></div>
</div>
"""
if matched:
html += '<div class="section"><h2>完全匹配</h2>'
for key, value in matched:
html += f'<div class="item matched"><div class="key">{_esc(key)}</div><div class="value-block">{_esc(value)}</div></div>'
html += "</div>"
if differences:
html += '<div class="section"><h2>有差异</h2>'
for key, word_value, latex_value in differences:
html += (
f'<div class="item difference"><div class="key">{_esc(key)}</div>'
f'<div class="value-block"><span class="label">Word</span>{_esc(word_value)}</div>'
f'<div class="value-block"><span class="label">LaTeX</span>{_esc(latex_value)}</div>'
f"</div>"
)
html += "</div>"
if only_in_one:
html += '<div class="section"><h2>仅在一方</h2>'
for source, key, value in only_in_one:
source_label = "Word" if source == "word" else "LaTeX"
html += (
f'<div class="item only"><div class="key">{_esc(key)}</div>'
f'<div class="value-block"><span class="label">来源</span>{_esc(source_label)}</div>'
f'<div class="value-block">{_esc(value)}</div>'
f"</div>"
)
html += "</div>"
html += "</body></html>"
return html
def render_formatted_text_html(fragments: List[Dict]) -> str:
"""
将格式片段渲染为 HTML
Args:
fragments: 格式片段列表
Returns:
HTML 字符串,加粗文本用 <b> 标签
"""
html_parts = []
for frag in fragments:
text = frag["text"]
# HTML 转义
text = text.replace('&', '&').replace('<', '<').replace('>', '>')
if frag.get("bold"):
html_parts.append(f'<b>{text}</b>')
else:
html_parts.append(text)
return ''.join(html_parts)
def generate_html_report_with_format(matched: List, text_diff: List, format_diff: List, only_in_one: List,
word_file: Path, latex_file: Path) -> str:
"""生成 HTML 格式报告(包含格式对比)"""
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>标题文字对比报告</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
max-width: 1200px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 20px;
margin-bottom: 30px;
}}
.stat-card {{
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}}
.stat-card h3 {{
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
}}
.stat-card .value {{
font-size: 32px;
font-weight: bold;
}}
.matched .value {{ color: #10b981; }}
.differences .value {{ color: #f59e0b; }}
.only .value {{ color: #ef4444; }}
.section {{
background: white;
padding: 25px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.section h2 {{
margin-top: 0;
padding-bottom: 15px;
border-bottom: 2px solid #e5e7eb;
}}
.item {{
padding: 15px;
margin-bottom: 15px;
border-left: 4px solid #ddd;
background: #f9fafb;
border-radius: 4px;
}}
.item.matched {{
border-left-color: #10b981;
background: #f0fdf4;
}}
.item.difference {{
border-left-color: #f59e0b;
background: #fffbeb;
}}
.item.only {{
border-left-color: #ef4444;
background: #fef2f2;
}}
.key {{
font-weight: bold;
color: #1f2937;
margin-bottom: 5px;
}}
.value {{
color: #4b5563;
}}
.diff-pair {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 10px;
}}
.diff-box {{
padding: 10px;
background: white;
border-radius: 4px;
border: 1px solid #e5e7eb;
}}
.diff-box.word {{
border-left: 3px solid #3b82f6;
}}
.diff-box.latex {{
border-left: 3px solid #8b5cf6;
}}
.label {{
font-size: 12px;
color: #6b7280;
margin-bottom: 5px;
}}
.meta {{
color: #9ca3af;
font-size: 14px;
margin-top: 30px;
text-align: center;
}}
</style>
</head>
<body>
<div class="header">
<h1>📋 标题文字对比报告</h1>
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="stats">
<div class="stat-card matched">
<h3>✅ 完全匹配</h3>
<div class="value">{len(matched)}</div>
</div>
<div class="stat-card differences">
<h3>⚠️ 有差异</h3>
<div class="value">{len(differences)}</div>
</div>
<div class="stat-card only">
<h3>❌ 仅在一方</h3>
<div class="value">{len(only_in_one)}</div>
</div>
</div>
'''
# 完全匹配的标题
if matched:
html += '<div class="section"><h2>✅ 完全匹配的标题</h2>'
for key, value in matched:
html += f'''
<div class="item matched">
<div class="key">{key}</div>
<div class="value">{value}</div>
</div>'''
html += '</div>'
# 有差异的标题
if differences:
html += '<div class="section"><h2>⚠️ 有差异的标题</h2>'
for key, word_value, latex_value in differences:
html += f'''
<div class="item difference">
<div class="key">{key}</div>
<div class="diff-pair">
<div class="diff-box word">
<div class="label">Word 模板</div>
<div class="value">{word_value}</div>
</div>
<div class="diff-box latex">
<div class="label">LaTeX 文件</div>
<div class="value">{latex_value}</div>
</div>
</div>
</div>'''
html += '</div>'
# 仅在一方的标题
if only_in_one:
html += '<div class="section"><h2>❌ 仅在一方的标题</h2>'
for source, key, value in only_in_one:
source_label = 'Word 模板' if source == 'word' else 'LaTeX 文件'
html += f'''
<div class="item only">
<div class="key">仅在 {source_label}: {key}</div>
<div class="value">{value}</div>
</div>'''
html += '</div>'
html += f'''
<div class="meta">
<p>Word 文件: {word_file.name}</p>
<p>LaTeX 文件: {latex_file.name}</p>
</div>
</body>
</html>'''
return html
def generate_html_report_with_format(matched: List, text_diff: List, format_diff: List, only_in_one: List,
word_file: Path, latex_file: Path) -> str:
"""生成 HTML 格式报告(包含格式对比)"""
html = f'''<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>标题文字对比报告(含格式)</title>
<style>
body {{
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
line-height: 1.6;
max-width: 1400px;
margin: 0 auto;
padding: 20px;
background: #f5f5f5;
}}
.header {{
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 30px;
border-radius: 10px;
margin-bottom: 30px;
box-shadow: 0 4px 6px rgba(0,0,0,0.1);
}}
.stats {{
display: grid;
grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 20px;
margin-bottom: 30px;
}}
.stat-card {{
background: white;
padding: 20px;
border-radius: 8px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
text-align: center;
}}
.stat-card h3 {{
margin: 0 0 10px 0;
font-size: 14px;
color: #666;
}}
.stat-card .value {{
font-size: 32px;
font-weight: bold;
}}
.matched .value {{ color: #10b981; }}
.text-diff .value {{ color: #f59e0b; }}
.format-diff .value {{ color: #f97316; }}
.only .value {{ color: #ef4444; }}
.section {{
background: white;
padding: 25px;
border-radius: 8px;
margin-bottom: 20px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}}
.section h2 {{
margin-top: 0;
padding-bottom: 15px;
border-bottom: 2px solid #e5e7eb;
}}
.item {{
padding: 15px;
margin-bottom: 15px;
border-left: 4px solid #ddd;
background: #f9fafb;
border-radius: 4px;
}}
.item.matched {{
border-left-color: #10b981;
background: #f0fdf4;
}}
.item.text-diff {{
border-left-color: #f59e0b;
background: #fffbeb;
}}
.item.format-diff {{
border-left-color: #f97316;
background: #fff7ed;
}}
.item.only {{
border-left-color: #ef4444;
background: #fef2f2;
}}
.key {{
font-weight: bold;
color: #1f2937;
margin-bottom: 8px;
font-size: 14px;
}}
.diff-pair {{
display: grid;
grid-template-columns: 1fr 1fr;
gap: 15px;
margin-top: 10px;
}}
.diff-box {{
padding: 12px;
background: white;
border-radius: 4px;
border: 1px solid #e5e7eb;
}}
.diff-box.word {{
border-left: 3px solid #3b82f6;
}}
.diff-box.latex {{
border-left: 3px solid #8b5cf6;
}}
.label {{
font-size: 12px;
color: #6b7280;
margin-bottom: 8px;
font-weight: 500;
}}
.rendered-text {{
font-size: 15px;
line-height: 1.8;
color: #1f2937;
}}
.rendered-text b {{
font-weight: 700;
color: #1e3a8a;
}}
.diff-marker {{
margin-top: 12px;
padding: 10px;
background: #fef3c7;
border-radius: 4px;
font-size: 13px;
color: #92400e;
}}
.diff-marker-item {{
padding: 4px 0;
border-bottom: 1px solid #fde68a;
}}
.diff-marker-item:last-child {{
border-bottom: none;
}}
.meta {{
color: #9ca3af;
font-size: 14px;
margin-top: 30px;
text-align: center;
}}
</style>
</head>
<body>
<div class="header">
<h1>📋 标题文字对比报告(含格式)</h1>
<p>生成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}</p>
</div>
<div class="stats">
<div class="stat-card matched">
<h3>✅ 完全匹配</h3>
<div class="value">{len(matched)}</div>
</div>
<div class="stat-card text-diff">
<h3>⚠️ 文本差异</h3>
<div class="value">{len(text_diff)}</div>
</div>
<div class="stat-card format-diff">
<h3>🔶 格式差异</h3>
<div class="value">{len(format_diff)}</div>
</div>
<div class="stat-card only">
<h3>❌ 仅在一方</h3>
<div class="value">{len(only_in_one)}</div>
</div>
</div>
'''
# 完全匹配的标题
if matched:
html += '<div class="section"><h2>✅ 完全匹配的标题(文本+格式)</h2>'
for key, text, result in matched:
word_html = render_formatted_text_html(result.get("word_fragments", []))
html += f'''
<div class="item matched">
<div class="key">{key}</div>
<div class="rendered-text">{word_html}</div>
</div>'''
html += '</div>'
# 文本差异
if text_diff:
html += '<div class="section"><h2>⚠️ 文本差异</h2>'
for key, word_value, latex_value in text_diff:
html += f'''
<div class="item text-diff">
<div class="key">{key}</div>
<div class="diff-pair">
<div class="diff-box word">
<div class="label">Word 模板</div>
<div class="rendered-text">{word_value}</div>
</div>
<div class="diff-box latex">
<div class="label">LaTeX 文件</div>
<div class="rendered-text">{latex_value}</div>
</div>
</div>
</div>'''
html += '</div>'
# 格式差异
if format_diff:
html += '<div class="section"><h2>🔶 格式差异(加粗)</h2>'
for key, text, result in format_diff:
word_html = render_formatted_text_html(result.get("word_fragments", []))
latex_html = render_formatted_text_html(result.get("latex_fragments", []))
# 构建差异标记
diff_markers = []
for diff in result.get("differences", []):
char = diff.get("char", "")
pos = diff.get("position", 0)
word_bold = "加粗" if diff.get("word_bold") else "正常"
latex_bold = "加粗" if diff.get("latex_bold") else "正常"
diff_markers.append(f'位置 {pos}: "{char}" - Word:{word_bold}, LaTeX:{latex_bold}')
diff_marker_html = ""
if diff_markers:
diff_marker_html = '<div class="diff-marker">' + \
''.join(f'<div class="diff-marker-item">{marker}</div>' for marker in diff_markers) + \
'</div>'
html += f'''
<div class="item format-diff">
<div class="key">{key}</div>
<div class="diff-pair">
<div class="diff-box word">
<div class="label">Word 模板</div>
<div class="rendered-text">{word_html}</div>
</div>
<div class="diff-box latex">
<div class="label">LaTeX 文件</div>
<div class="rendered-text">{latex_html}</div>
</div>
</div>
{diff_marker_html}
</div>'''
html += '</div>'
# 仅在一方的标题
if only_in_one:
html += '<div class="section"><h2>❌ 仅在一方的标题</h2>'
for source, key, value in only_in_one:
source_label = 'Word 模板' if source == 'word' else 'LaTeX 文件'
html += f'''
<div class="item only">
<div class="key">仅在 {source_label}: {key}</div>
<div class="rendered-text">{value}</div>
</div>'''
html += '</div>'
html += f'''
<div class="meta">
<p>Word 文件: {word_file.name}</p>
<p>LaTeX 文件: {latex_file.name}</p>
</div>
</body>
</html>'''
return html
def generate_latex_fix_suggestions(format_diff: List) -> str:
"""
生成 LaTeX 修复建议
Args:
format_diff: 格式差异列表
Returns:
LaTeX 修复代码字符串
"""
lines = []
lines.append('% LaTeX 标题格式修复建议')
lines.append('% 自动生成于: ' + datetime.now().strftime('%Y-%m-%d %H:%M:%S'))
lines.append('% 请根据实际情况修改 main.tex 中的对应标题')
lines.append('')
lines.append('% 使用方法:')
lines.append('% 1. 将下面的 \\section{} 或 \\subsection{} 替换到 main.tex 中')
lines.append('% 2. 确保格式符合 Word 模板要求')
lines.append('')
if not format_diff:
lines.append('% ✅ 所有标题格式一致,无需修复')
return '\n'.join(lines)
lines.append('% 修复建议:')
lines.append('')
for key, text, result in format_diff:
word_fragments = result.get("word_fragments", [])
# 生成 LaTeX 代码
latex_parts = []
for frag in word_fragments:
frag_text = frag["text"]
if frag.get("bold"):
latex_parts.append(f'\\textbf{{{frag_text}}}')
else:
latex_parts.append(frag_text)
latex_code = ''.join(latex_parts)
# 判断是 section 还是 subsection
if key.startswith('section_'):
command = '\\section'
elif key.startswith('subsection_'):
command = '\\subsection'
else:
command = '\\section'
lines.append(f'% {key}: {text}')
lines.append(f'{command}{{{latex_code}}}')
lines.append('')
return '\n'.join(lines)
def main():
parser = argparse.ArgumentParser(description='对比基准模板(推荐 PDF)与 LaTeX 的标题文字')
parser.add_argument('source_file', type=Path, help='基准文件路径(推荐: .pdf;兼容: .docx)')
parser.add_argument('latex_file', type=Path, help='LaTeX 文件路径(main.tex)')
parser.add_argument('--report', type=Path, help='输出报告文件路径')
parser.add_argument('--format', choices=['auto', 'text', 'html'], default='auto',
help='报告格式(auto 根据扩展名自动判断)')
parser.add_argument('--check-format', action='store_true',
help='检查格式(加粗)是否一致(默认仅检查文本)')
parser.add_argument('--fix-file', type=Path, help='输出 LaTeX 修复建议文件路径')
args = parser.parse_args()
# 提取标题
print(f'📖 正在提取基准标题: {args.source_file}')
word_headings = extract_from_source(args.source_file, check_format=args.check_format)
print(f'📖 正在提取 LaTeX 标题: {args.latex_file}')
latex_headings = extract_from_latex(args.latex_file, check_format=args.check_format)
# 对比标题
if args.check_format:
print('🔍 正在对比标题(包含格式)...')
matched, text_diff, format_diff, only_in_one = compare_headings_with_format(
word_headings, latex_headings
)
else:
print('🔍 正在对比标题...')
matched, differences, only_in_one = compare_headings(word_headings, latex_headings)
text_diff = []
format_diff = []
# 将旧的 differences 转换为 text_diff 格式以保持一致性
text_diff = differences
# 生成报告
if args.report:
# 判断格式
if args.format == 'auto':
if args.report.suffix == '.html':
fmt = 'html'
elif args.report.suffix == '.md':
fmt = 'markdown'
else:
fmt = 'text'
else:
fmt = args.format
if args.check_format:
# 格式对比模式
if fmt == 'html':
report = generate_html_report_with_format(matched, text_diff, format_diff, only_in_one,
args.source_file, args.latex_file)
else:
report = generate_text_report_with_format(matched, text_diff, format_diff, only_in_one)
else:
# 传统模式
if fmt == 'html':
report = generate_html_report(matched, differences, only_in_one,
args.source_file, args.latex_file)
else:
report = generate_text_report(matched, differences, only_in_one)
with open(args.report, 'w', encoding='utf-8') as f:
f.write(report)
if args.check_format:
total = len(matched) + len(text_diff) + len(format_diff)
print(f'✅ 报告已生成: {args.report}')
print(f' 总计: {total} | 匹配: {len(matched)} | 文本差异: {len(text_diff)} | 格式差异: {len(format_diff)} | 仅在一方: {len(only_in_one)}')
else:
print(f'✅ 报告已生成: {args.report}')
print(f' 总计: {len(matched) + len(differences)} | 匹配: {len(matched)} | 差异: {len(differences)} | 仅在一方: {len(only_in_one)}')
else:
# 打印到控制台
if args.check_format:
report = generate_text_report_with_format(matched, text_diff, format_diff, only_in_one)
else:
report = generate_text_report(matched, differences, only_in_one)
print(report)
# 生成修复建议文件
if args.fix_file and args.check_format and format_diff:
fix_content = generate_latex_fix_suggestions(format_diff)
with open(args.fix_file, 'w', encoding='utf-8') as f:
f.write(fix_content)
print(f'🔧 LaTeX 修复建议已生成: {args.fix_file}')
elif args.fix_file and args.check_format and not format_diff:
# 无格式差异,仍然生成文件
fix_content = generate_latex_fix_suggestions(format_diff)
with open(args.fix_file, 'w', encoding='utf-8') as f:
f.write(fix_content)
print(f'✅ 所有标题格式一致,修复建议文件已生成: {args.fix_file}')
if __name__ == '__main__':
main()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
逐段像素对比工具
对比两份 PDF 的“匹配段落”像素差异,输出段落级指标与聚合指标。
"""
from __future__ import annotations
import argparse
import json
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Tuple
def _variance(vals: List[float]) -> float:
if len(vals) <= 1:
return 0.0
m = sum(vals) / len(vals)
return sum((v - m) ** 2 for v in vals) / len(vals)
def main() -> int:
parser = argparse.ArgumentParser(description="逐段像素对比(paragraph mode)")
parser.add_argument("baseline_pdf", type=Path, help="基准 PDF")
parser.add_argument("target_pdf", type=Path, help="目标 PDF")
parser.add_argument("--dpi", type=int, default=150, help="渲染 DPI")
parser.add_argument("--tolerance", type=int, default=2, help="像素容差(RGB)")
parser.add_argument("--min-similarity", type=float, default=0.85, help="段落文本匹配阈值")
parser.add_argument("--page", type=int, default=None, help="仅对比指定页(1-based)")
parser.add_argument("--output", "-o", type=Path, required=True, help="输出 JSON")
args = parser.parse_args()
if not args.baseline_pdf.exists() or not args.target_pdf.exists():
print("❌ 输入 PDF 不存在")
return 1
from core.paragraph_alignment import (
compute_internal_variance,
extract_paragraphs_from_pdf,
image_diff_ratio,
match_paragraphs,
)
baseline_paras = extract_paragraphs_from_pdf(
args.baseline_pdf, dpi=args.dpi, page_num=args.page, include_images=True
)
target_paras = extract_paragraphs_from_pdf(
args.target_pdf, dpi=args.dpi, page_num=args.page, include_images=True
)
matches = match_paragraphs(
baseline_paras, target_paras, min_similarity=float(args.min_similarity)
)
# 建立 id -> Paragraph
bmap: Dict[Tuple[int, int], Any] = {(p.page_num, p.paragraph_id): p for p in baseline_paras}
tmap: Dict[Tuple[int, int], Any] = {(p.page_num, p.paragraph_id): p for p in target_paras}
per_match: List[Dict[str, Any]] = []
x0_diffs: List[float] = []
y0_diffs: List[float] = []
gap_diffs: List[float] = []
internal_vars: List[float] = []
# 先按 baseline 段落顺序排序,方便计算 gap 差异
matches_sorted = sorted(
matches,
key=lambda m: (int(m.get("page_num") or 1), float(m["baseline"]["bbox"][1]), int(m["baseline"]["paragraph_id"])),
)
prev_b = None
prev_t = None
total_weight = 0
weighted_sum = 0.0
for m in matches_sorted:
page_num = int(m.get("page_num") or 1)
b_id = int(m["baseline"]["paragraph_id"])
t_id = int(m["target"]["paragraph_id"])
b = bmap.get((page_num, b_id))
t = tmap.get((page_num, t_id))
if b is None or t is None:
continue
ratio, diff_pixels, total_pixels = image_diff_ratio(b.image_rgb, t.image_rgb, tolerance=int(args.tolerance))
total_weight += total_pixels
weighted_sum += ratio * float(total_pixels)
pos_diff = {
"x0": float(t.bbox[0] - b.bbox[0]),
"y0": float(t.bbox[1] - b.bbox[1]),
"x1": float(t.bbox[2] - b.bbox[2]),
"y1": float(t.bbox[3] - b.bbox[3]),
}
x0_diffs.append(pos_diff["x0"])
y0_diffs.append(pos_diff["y0"])
iv_b = compute_internal_variance(b)
iv_t = compute_internal_variance(t)
internal_vars.append((float(iv_b["line_height_variance"]) + float(iv_t["line_height_variance"])) / 2.0)
if prev_b is not None and prev_t is not None and b.page_num == prev_b.page_num and t.page_num == prev_t.page_num:
b_gap = float(b.bbox[1] - prev_b.bbox[3])
t_gap = float(t.bbox[1] - prev_t.bbox[3])
gap_diffs.append(t_gap - b_gap)
prev_b, prev_t = b, t
per_match.append(
{
"page_num": page_num,
"baseline_paragraph_id": b.paragraph_id,
"target_paragraph_id": t.paragraph_id,
"text_similarity": float(m.get("text_similarity") or 0.0),
"pixel_diff_ratio": float(ratio),
"diff_pixels": int(diff_pixels),
"total_pixels": int(total_pixels),
"position_diff": pos_diff,
"internal_variance": {
"baseline": iv_b,
"target": iv_t,
},
}
)
avg_ratio = (weighted_sum / float(total_weight)) if total_weight else 1.0
payload: Dict[str, Any] = {
"meta": {
"generated_at": datetime.now().isoformat(),
"baseline_pdf": str(args.baseline_pdf),
"target_pdf": str(args.target_pdf),
"dpi": int(args.dpi),
"tolerance": int(args.tolerance),
"min_similarity": float(args.min_similarity),
"page": args.page,
},
"avg_paragraph_pixel_diff": float(avg_ratio),
"paragraph_position_variance": float(_variance(y0_diffs)),
"paragraph_spacing_variance": float(_variance(gap_diffs)),
"indent_variance": float(_variance(x0_diffs)),
"avg_internal_line_variance": float(sum(internal_vars) / len(internal_vars)) if internal_vars else 0.0,
"matches": per_match,
}
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"✅ 已写入: {args.output}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
# make_latex_model core modules
from .config_loader import ConfigLoader, load_config
from .ai_optimizer import AIOptimizer
__all__ = ["ConfigLoader", "load_config", "AIOptimizer"]
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
AI 驱动优化引擎(最小可用版)
按照 plans/v202601271348.md 的“Analyzer → Reasoner → Executor → Memory”闭环落地:
- Analyzer:DiffAnalyzer(基于像素对比特征)
- Reasoner:DecisionReasoner(启发式 / 文件交互)
- Executor:ParameterExecutor(可回滚应用)
- Memory:HistoryMemory(JSONL 记录)
说明:
- 由于“脚本内部直连宿主 AI”缺少通用标准接口,本实现默认启发式;
如需 AI 全程参与,可使用 DecisionReasoner 的 manual_file 模式。
"""
from pathlib import Path
from typing import Any, Callable, Dict, Optional
from .diff_analyzer import DiffAnalyzer
from .decision_reasoner import DecisionReasoner, ReasonerConfig
from .parameter_executor import ParameterExecutor, ExecutionResult
from .history_memory import HistoryMemory
from .workspace_manager import WorkspaceManager
class AIOptimizer:
"""AI 驱动的优化引擎"""
def __init__(
self,
skill_root: Path,
project_name: str,
mode: str = "heuristic",
evaluate_after_apply: bool = True,
):
self.skill_root = Path(skill_root)
self.project_name = project_name
skill_cfg = self._load_skill_config()
target_ratio = self._infer_target_ratio(skill_cfg)
default_steps = self._infer_default_steps(skill_cfg)
self.analyzer = DiffAnalyzer()
prompt_path = self.skill_root / "prompts" / "analysis_template.txt"
ws_manager = WorkspaceManager(self.skill_root)
ws_dir = ws_manager.get_project_workspace(project_name)
self.workspace_dir = ws_dir
self.reasoner = DecisionReasoner(
prompt_template_path=prompt_path if prompt_path.exists() else None,
workspace_dir=ws_dir,
config=ReasonerConfig(mode=mode, target_ratio=target_ratio, default_steps=default_steps),
)
self.executor = ParameterExecutor(evaluate_after_apply=evaluate_after_apply)
self.memory = HistoryMemory(ws_dir / "cache" / "ai_memory.jsonl")
def optimize_iteration(
self,
iteration: int,
current_ratio: float,
config_path: Path,
compile_func: Callable[[], bool],
compare_func: Callable[[], Optional[float]],
) -> ExecutionResult:
features_path = self.workspace_dir / "iterations" / f"iteration_{iteration:03d}" / "diff_features.json"
diff_context = self.analyzer.analyze(
diff_ratio=current_ratio,
iteration=iteration,
features_path=features_path,
)
history = self.memory.get_recent(n=5)
decision = self.reasoner.reason(diff_context=diff_context, history=history, current_config=config_path.read_text(encoding="utf-8"))
result = self.executor.execute(
decision=decision,
config_path=config_path,
compile_func=compile_func,
compare_func=compare_func,
current_ratio=current_ratio,
)
# 落盘记录(尽量可复盘)
self.memory.record(
iteration=iteration,
context={
"diff_ratio": diff_context.diff_ratio,
"root_cause": diff_context.root_cause,
"affected_regions": diff_context.affected_regions,
"evidence": diff_context.evidence,
"parameter_candidates": diff_context.parameter_candidates,
},
decision=decision,
result={
"status": result.status,
"new_ratio": result.new_ratio,
"improvement": result.improvement,
"rollback": result.rollback,
"reason": result.reason,
"applied": result.applied,
},
)
return result
def _load_skill_config(self) -> Dict[str, Any]:
cfg_path = self.skill_root / "config.yaml"
if not cfg_path.exists():
return {}
try:
import yaml
except Exception:
return {}
try:
return yaml.safe_load(cfg_path.read_text(encoding="utf-8")) or {}
except Exception:
return {}
def _infer_target_ratio(self, cfg: Dict[str, Any]) -> float:
# 优先使用“像素差异容忍度”(Single Source of Truth)
v = (
cfg.get("validation", {})
.get("tolerance", {})
.get("pixel_changed_ratio")
)
if isinstance(v, (int, float)) and v > 0:
return float(v)
# 其次用迭代收敛阈值
v = cfg.get("iteration", {}).get("convergence_threshold")
if isinstance(v, (int, float)) and v > 0:
return float(v)
return 0.01
def _infer_default_steps(self, cfg: Dict[str, Any]) -> Dict[str, float]:
gran = cfg.get("iteration", {}).get("adjustment_granularity", {}) or {}
font_pt = float(gran.get("font_size_pt", 0.1) or 0.1)
line = float(gran.get("line_spacing", 0.05) or 0.05)
margin = float(gran.get("margin_cm", 0.05) or 0.05)
# 保守策略:用配置粒度的“更小步长”作为默认
return {
"xiaosi_font_size": max(0.01, font_pt / 2.0),
"baselinestretch": max(0.001, line / 5.0),
"margin_cm": max(0.01, margin),
"parskip_em": 0.1,
"arraystretch": 0.02,
"title_indent_em": 0.1,
}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
历史记忆库(HistoryMemory)
将每轮优化的上下文/决策/结果落盘,避免重复“试错”。
实现保持极简:JSONL 追加写 + 最近 N 条读取。
"""
import json
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, List, Optional
@dataclass
class IterationRecord:
iteration: int
timestamp: str
context: Dict[str, Any]
decision: Dict[str, Any]
result: Dict[str, Any]
class HistoryMemory:
"""基于 JSONL 文件的历史记忆库"""
def __init__(self, storage_path: Path):
self.storage_path = Path(storage_path)
self.storage_path.parent.mkdir(parents=True, exist_ok=True)
def record(
self,
iteration: int,
context: Dict[str, Any],
decision: Dict[str, Any],
result: Dict[str, Any],
) -> None:
rec = IterationRecord(
iteration=iteration,
timestamp=datetime.now().isoformat(),
context=context,
decision=decision,
result=result,
)
with open(self.storage_path, "a", encoding="utf-8") as f:
f.write(json.dumps(rec.__dict__, ensure_ascii=False) + "\n")
def get_recent(self, n: int = 5) -> List[Dict[str, Any]]:
if n <= 0 or not self.storage_path.exists():
return []
# 反向读取最后 N 行(文件通常不大;这里保持简单)
try:
lines = self.storage_path.read_text(encoding="utf-8").splitlines()
except Exception:
return []
recent = []
for line in lines[-n:]:
try:
recent.append(json.loads(line))
except Exception:
continue
return recent
def clear(self) -> None:
if self.storage_path.exists():
self.storage_path.unlink()
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
内置模板目录。
目标:
1. 保留脚本需要的稳定结构化信息。
2. 不在 skill 内固化会随年份变化的官方标题文案。
3. 允许项目级 `.template.yaml` 继续做局部覆盖。
"""
from __future__ import annotations
import copy
from pathlib import Path
from typing import Any, Dict, Optional
_TEMPLATE_CATALOG: Dict[str, Dict[str, Any]] = {
"nsfc/young": {
"template": {
"name": "nsfc/young",
"display_name": "国家自然科学基金-青年科学基金项目",
"product_line": "nsfc",
"category": "research_funding",
},
"structure": {
"content_dir": "extraTex",
"config_file": "@config.tex",
"main_file": "main.tex",
"template_dir": "template",
},
},
"nsfc/general": {
"template": {
"name": "nsfc/general",
"display_name": "国家自然科学基金-面上项目",
"product_line": "nsfc",
"category": "research_funding",
},
"structure": {
"content_dir": "extraTex",
"config_file": "@config.tex",
"main_file": "main.tex",
"template_dir": "template",
},
},
"nsfc/local": {
"template": {
"name": "nsfc/local",
"display_name": "国家自然科学基金-地区科学基金项目",
"product_line": "nsfc",
"category": "research_funding",
},
"structure": {
"content_dir": "extraTex",
"config_file": "@config.tex",
"main_file": "main.tex",
"template_dir": "template",
},
},
"paper/default": {
"template": {
"name": "paper/default",
"display_name": "SCI 论文模板",
"product_line": "paper",
"category": "manuscript",
},
"structure": {
"content_dir": "extraTex",
"main_file": "main.tex",
},
},
"thesis/default": {
"template": {
"name": "thesis/default",
"display_name": "毕业论文模板",
"product_line": "thesis",
"category": "thesis",
},
"structure": {
"content_dir": "extraTex",
"main_file": "main.tex",
},
},
"cv/default": {
"template": {
"name": "cv/default",
"display_name": "中英文学术简历模板",
"product_line": "cv",
"category": "cv",
},
"structure": {
"main_file": "main-zh.tex",
},
},
}
def normalize_template_name(template_name: Optional[str]) -> Optional[str]:
"""统一模板名分隔符。"""
if not template_name:
return None
return str(template_name).strip().replace(".", "/")
def get_template_catalog() -> Dict[str, Dict[str, Any]]:
"""返回完整模板目录副本。"""
return copy.deepcopy(_TEMPLATE_CATALOG)
def get_template_defaults(template_name: Optional[str]) -> Dict[str, Any]:
"""返回指定模板的内置默认配置。"""
normalized = normalize_template_name(template_name)
if not normalized:
return {}
return copy.deepcopy(_TEMPLATE_CATALOG.get(normalized, {}))
def detect_template_name(project_path: Optional[Path]) -> Optional[str]:
"""根据项目路径推断模板名。"""
if not project_path:
return None
project_name = project_path.name.lower()
if "nsfc_young" in project_name:
return "nsfc/young"
if "nsfc_general" in project_name:
return "nsfc/general"
if "nsfc_local" in project_name:
return "nsfc/local"
if project_name.startswith("paper-"):
return "paper/default"
if project_name.startswith("thesis-"):
return "thesis/default"
if project_name.startswith("cv-"):
return "cv/default"
return None