
Make Latex Model
- 43 installs
- 2.6k repo stars
- Updated July 20, 2026
- huangwb8/chineseresearchlatex
Optimizes ChineseResearchLaTeX projects into high-quality templates across NSFC, paper, thesis, and CV lines with pixel-level style alignment.
About
Refines a ChineseResearchLaTeX project into a polished template, deciding whether to edit the project layer or shared packages and verifying via each product line's official build entry. Developers use it for LaTeX template optimization and style parameter alignment across the four supported document lines.
- Supports NSFC, paper, thesis, and CV product lines
- Requires regression planning before editing shared packages
Make Latex Model by the numbers
- 43 all-time installs (skills.sh)
- +1 installs in the week ending Aug 2, 2026 (Skillselion tracking)
- Ranked #385 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 | 43 |
|---|---|
| repo stars | ★ 2.6k |
| Last updated | July 20, 2026 |
| Repository | huangwb8/chineseresearchlatex ↗ |
What it does
Optimizes ChineseResearchLaTeX projects into high-quality templates across NSFC, paper, thesis, and CV lines with pixel-level style 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