Compare commits

...

6 Commits

Author SHA1 Message Date
zzy
4e39a4f2ac feat(transit): 添加正文引用标记到书签超链接功能
- 新增 CITE_PATTERN 正则表达式匹配 [N] 引用格式
- 添加 base_dir 参数支持相对图片路径解析
- 实现书签创建和超链接替换功能
- 添加 link_body_citations 函数处理正文引用链接
- 在参考文献段落中添加书签标识
- 支持将 [N] 引用替换为指向参考文献的超链接
2026-05-10 15:07:20 +08:00
zzy
0b10e97e0c feat(transit): 支持Markdown图片转Word图片段落
- 新增 transit/images.py 模块处理图片解析和插入逻辑
- 实现 `<img>` 标签解析为独立图片段落
- 添加图片尺寸检测和自适应缩放功能(超出页面宽度时自动缩放)
- 支持图标题居中显示和图片居中对齐
- 优化 body_to_paragraphs 函数,添加图片处理逻辑
- 更新 README.md 使用说明,添加模板文件要求说明

BREAKING CHANGE: 图片处理方式变更,需要包含 sample.docx 模板文件
2026-05-09 16:17:51 +08:00
zzy
fc6afdea9d refactor(config): 重构配置类以支持动态元数据字段
配置类 ThesisConfig 现在使用 metadata 字典直接透传 TOML 配置,
无需为每个变量单独声明字段。新增模板变量只需修改 TOML 文件,
无需修改 Python 代码。

BREAKING CHANGE: 配置文件结构发生改变,从单独字段改为统一的
metadata 节点。
2026-05-08 23:07:23 +08:00
zzy
74d28ea2d8 feat(transit): 添加参考文献解析功能并修复段落编号继承问题
新增了参考文献处理模块,支持按照 GB 7714 《文后参考文献著录规则》顺序编码制
解析和格式化参考文献。同时修复了段落替换过程中自动编号丢失的问题。

- 新增 transit/references.py 模块,提供参考文献解析和格式化功能
- 在 body.py 的 replace_placeholder 函数中实现段落编号属性的正确继承
- 修改 transit/__init__.py 导入新的参考文献处理函数
- 更新 transit/config.py 添加参考文献样式配置项
- 修改 transit/renderer.py 集成参考文献处理流程
2026-05-08 22:14:51 +08:00
zzy
c29a3e6af0 feat(transit): 改进正文段落到Word文档的转换功能
支持自定义标题级别偏移量和正文样式,增强样式应用的灵活性。
- 新增 level_offset 参数用于调整标题级别
- 新增 body_style 参数用于设置正文段落样式
- 改进样式应用逻辑,支持多种样式的降级机制
- 更新配置文件以支持新的样式配置选项
- 修改解析器使致谢、参考文献和附录部分只提取正文内容
2026-05-08 21:44:09 +08:00
zzy
ae70d05672 refactor: 重构项目结构并更新依赖配置
- 移除原有的 docx_thesis 模块及其相关文件 (cli.py, config.py, converter.py)
- 新增 .claudeignore 文件以忽略 Python 生成文件和缓存
- 更新 .gitignore 文件添加更多忽略规则包括 .mypy_cache/, .ruff_cache/,
  .claude/, *.md 等
- 添加 README.md 使用说明文档
- 修改 pyproject.toml 依赖配置,新增 docxtpl、pyyaml,
  移除原 thesis 命令入口点并更新为 transit.__main__
- 新增 transit 模块及相应初始化文件
- 重命名 main.py 为快速入口脚本
2026-05-08 21:06:01 +08:00
18 changed files with 1183 additions and 941 deletions

17
.claudeignore Normal file
View File

@@ -0,0 +1,17 @@
# Python-generated files
__pycache__/
*.py[oc]
build/
dist/
wheels/
*.egg-info
# Virtual environments
.venv
.mypy_cache/
.ruff_cache/
*.docx
*.doc
*.txt

16
.gitignore vendored
View File

@@ -8,3 +8,19 @@ wheels/
# Virtual environments
.venv
.mypy_cache/
.ruff_cache/
.claude/
*.md
!README.md
*.docx
*.doc
*.txt
.vscode/
.tmp/
*.toml

View File

@@ -0,0 +1,5 @@
使用方法
```shell
# 需要 sample.docx 文件 且 该文件有 {{xxx}} 模板引擎的内容
python .\test.py .\毕业论文初稿.md
```

View File

@@ -1,4 +0,0 @@
from .config import ThesisFormat
from .converter import ThesisConverter
__all__ = ["ThesisFormat", "ThesisConverter"]

View File

@@ -1,43 +0,0 @@
"""CLI entry point for the thesis converter (used by `uv run thesis`)."""
import argparse
import sys
from pathlib import Path
from .config import ThesisFormat
from .converter import ThesisConverter
def main():
parser = argparse.ArgumentParser(
description="将 Markdown 毕业论文转换为格式化的 Word 文档",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=(
"示例:\n"
" uv run thesis 论文.md # 输出 论文.docx\n"
" uv run thesis 论文.md 毕业设计.docx # 指定输出文件名\n"
),
)
parser.add_argument("input", help="输入的 .md 文件路径")
parser.add_argument("output", nargs="?", default=None,
help="输出的 .docx 路径(默认自动替换扩展名)")
args = parser.parse_args()
inp = Path(args.input)
if not inp.exists():
print(f"错误:找不到文件 {inp}")
sys.exit(1)
out = Path(args.output) if args.output else inp.with_suffix(".docx")
config = ThesisFormat()
converter = ThesisConverter(config)
converter.convert(str(inp), str(out))
print(f"转换完成:{out}")
print("提示:在 Word 中打开后,按 Ctrl+A → F9 更新目录和页码。")
if __name__ == "__main__":
main()

View File

@@ -1,90 +0,0 @@
from dataclasses import dataclass
@dataclass
class ThesisFormat:
"""毕业论文格式配置(桂林理工大学理工类标准)。
依据《要求.txt》中第2、3节的规定。
所有尺寸单位:厘米(cm) 或 磅(pt),按字段说明为准。
"""
# ════════════════════════════════════════════
# 页面设置 (§2.2)
# ════════════════════════════════════════════
page_width: float = 21.0 # A4
page_height: float = 29.7 # A4
margin_top: float = 3.0 # 上3.0cm
margin_bottom: float = 2.5 # 下2.5cm
margin_left: float = 3.0 # 左3.0cm
margin_right: float = 2.0 # 右2.0cm
footer_distance: float = 1.5 # 页脚1.5cm
# ════════════════════════════════════════════
# 字体 (§3.1) — 理工类全文宋体
# ════════════════════════════════════════════
font_cn: str = "宋体"
font_cn_heading: str = "宋体" # 标题也用宋体(加粗区分)
font_en: str = "Times New Roman"
font_code: str = "Courier New"
font_heading_en: str = "Times New Roman"
# ════════════════════════════════════════════
# 字号(磅)(§3.2)
# ════════════════════════════════════════════
# 中文:三号=16pt 小三=15pt 四号=14pt 小四=12pt 五号=10.5pt 小五=9pt
size_abstract_title: float = 16 # 三号 — 摘要题头
size_chapter: float = 16 # 三号 — 第一层次(章)
size_section: float = 15 # 小三 — 第二层次(节)
size_subsection: float = 14 # 四号 — 第三层次(条)
size_subsubsection: float = 12 # 小四 — 第四层次(款)
size_body: float = 12 # 小四 — 正文
size_keyword_label: float = 12 # 小四 — 关键词题头
size_reference: float = 10.5 # 五号 — 参考文献条目
size_code: float = 9 # 小五 — 代码块
size_footer: float = 9 # 小五 — 页脚页码
# ════════════════════════════════════════════
# 段落间距(磅)
# ════════════════════════════════════════════
spacing_before_chapter: int = 0
spacing_after_chapter: int = 0
spacing_before_section: int = 0
spacing_after_section: int = 0
spacing_before_subsection: int = 0
spacing_after_subsection: int = 0
# ════════════════════════════════════════════
# 行距 (§2.2) — 单倍行距
# ════════════════════════════════════════════
line_spacing_body: float = 1.0
line_spacing_heading: float = 1.0
line_spacing_reference: float = 1.0
line_spacing_code: float = 1.0
# ════════════════════════════════════════════
# 缩进
# ════════════════════════════════════════════
first_line_indent_chars: int = 2 # 正文首行缩进2字符
# ════════════════════════════════════════════
# 文档网格 (§2.2) — 每页32行×每行42字
# ════════════════════════════════════════════
grid_chars_per_line: int = 42
grid_lines_per_page: int = 32
# ════════════════════════════════════════════
# 章节标题文字识别
# ════════════════════════════════════════════
abstract_title_cn: str = "摘 要"
abstract_title_en: str = "Abstract"
toc_title: str = "目 次"
acknowledgement_title: str = "致谢"
references_title: str = "参考文献"
appendix_title: str = "附录"
# ════════════════════════════════════════════
# 摘要关键词标记
# ════════════════════════════════════════════
keywords_label_cn: str = "关键词:"
keywords_label_en: str = "Key words:"

View File

@@ -1,795 +0,0 @@
"""Convert Markdown graduation thesis → formatted Word .docx.
Parses markdown line-by-line and writes a python-docx document that
complies with 桂林理工大学 理工类毕业设计(论文)格式要求.
"""
from __future__ import annotations
import re
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
from docx.oxml import parse_xml
from docx.oxml.ns import nsdecls, qn
from docx.shared import Cm, Pt, RGBColor
from docx.text.paragraph import Paragraph
from docx.text.run import Run
from .config import ThesisFormat
# ── font helpers ─────────────────────────────────────────────────────────
def _set_font(
run: Run,
cn_font: str,
en_font: str | None = None,
size: float | None = None,
bold: bool | None = None,
italic: bool | None = None,
):
if en_font:
run.font.name = en_font
if cn_font:
rpr = run._element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
rpr.insert(0, rfonts)
rfonts.set(qn("w:eastAsia"), cn_font)
if size is not None:
run.font.size = Pt(size)
if bold is not None:
run.font.bold = bold
if italic is not None:
run.font.italic = italic
def _set_spacing(p: Paragraph, before: int = 0, after: int = 0,
line_spacing: float = 1.0):
pf = p.paragraph_format
pf.space_before = Pt(before)
pf.space_after = Pt(after)
pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
pf.line_spacing = line_spacing
def _set_indent(p: Paragraph, chars: int = 2):
if chars > 0:
p.paragraph_format.first_line_indent = Cm(chars * 0.37)
def _set_page_number_fmt(section, fmt: str):
sect_pr = section._sectPr
el = sect_pr.find(qn("w:pgNumType"))
if el is None:
el = parse_xml(f'<w:pgNumType {nsdecls("w")}/>')
sect_pr.append(el)
el.set(qn("w:fmt"), fmt)
def _setup_footer(section, roman: bool):
footer = section.footer
footer.is_linked_to_previous = False
# clear default empty paragraph runs to avoid extra blank line
for p in footer.paragraphs:
for r in p.runs:
r.text = ""
p = footer.paragraphs[0]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
p.paragraph_format.space_before = Pt(0)
p.paragraph_format.space_after = Pt(0)
r = p.add_run()
_set_font(r, "宋体", "Times New Roman", size=9)
r._element.append(parse_xml(
f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>'))
r2 = p.add_run()
r2._element.append(parse_xml(
f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>'))
r3 = p.add_run()
r3._element.append(parse_xml(
f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'))
_set_page_number_fmt(section, "lowerRoman" if roman else "decimal")
# ── inline markdown parser ───────────────────────────────────────────────
def _parse_inline(text: str):
"""Tokenise line → list of (text, attrs) tuples."""
tokens: list[tuple[str, dict]] = []
buf = ""
i = 0
n = len(text)
def flush():
nonlocal buf
if buf:
tokens.append((buf, {}))
buf = ""
while i < n:
ch = text[i]
# `code`
if ch == "`":
flush()
j = text.find("`", i + 1)
if j == -1:
buf += ch
i += 1
continue
tokens.append((text[i + 1:j], {"code": True}))
i = j + 1
continue
# **bold**
if text[i:i + 2] == "**":
flush()
j = text.find("**", i + 2)
if j == -1:
buf += ch
i += 1
continue
inner = text[i + 2:j]
sub = _parse_inline(inner)
for t, a in sub:
a["bold"] = True
tokens.append((t, a))
i = j + 2
continue
# *italic* (single star, not **)
if ch == "*" and i + 1 < n and text[i + 1] != "*":
flush()
j = text.find("*", i + 1)
if j == -1:
buf += ch
i += 1
continue
tokens.append((text[i + 1:j], {"italic": True}))
i = j + 1
continue
buf += ch
i += 1
flush()
return tokens
def _add_inline(p: Paragraph, tokens: list, cfg: ThesisFormat,
size: float | None = None, bold: bool = False):
for text, attrs in tokens:
run = p.add_run(text)
b = bold or attrs.get("bold", False)
it = attrs.get("italic", False)
code = attrs.get("code", False)
cn = cfg.font_code if code else cfg.font_cn
en = cfg.font_code if code else cfg.font_en
_set_font(run, cn, en, size=size or cfg.size_body,
bold=b, italic=it if not b else None)
# ── block-level parser ───────────────────────────────────────────────────
def _parse_blocks(text: str):
lines = text.split("\n")
blocks: list[dict] = []
i, n = 0, len(lines)
while i < n:
line = lines[i]
# thematic break
if line.strip() == "---":
blocks.append({"type": "thematic_break"})
i += 1
continue
# fenced code block
if line.strip().startswith("```") or line.strip().startswith("~~~"):
fence = line.strip()[:3]
info = line.strip()[3:].strip()
code_lines: list[str] = []
i += 1
while i < n and not lines[i].strip().startswith(fence):
code_lines.append(lines[i])
i += 1
i += 1
blocks.append({"type": "block_code", "info": info,
"raw": "\n".join(code_lines)})
continue
# heading
m = re.match(r"^(#{1,6})\s+(.+)$", line)
if m:
blocks.append({"type": "heading",
"level": len(m.group(1)),
"text": m.group(2).strip()})
i += 1
continue
# blockquote
if line.strip().startswith(">"):
ql: list[str] = []
while i < n and (lines[i].strip().startswith(">")
or lines[i].strip() == ""):
ql.append(re.sub(r"^>\s?", "", lines[i]))
i += 1
blocks.append({"type": "block_quote",
"text": "\n".join(ql).strip()})
continue
# list
if re.match(r"^(\s*)([-*+]\s|\d+\.\s)", line):
items: list[str] = []
while i < n:
if re.match(r"^(\s*)([-*+]\s|\d+\.\s)", lines[i]):
t = re.sub(r"^(\s*)[-*+]\s|\d+\.\s", "", lines[i], 1)
items.append(t)
i += 1
while i < n and lines[i].strip() \
and not re.match(r"^(\s*)([-*+]\s|\d+\.\s)",
lines[i]):
if lines[i][0] in " \t":
items[-1] += " " + lines[i].strip()
i += 1
else:
break
elif lines[i].strip() == "":
i += 1
else:
break
blocks.append({"type": "list", "items": items})
continue
# blank
if line.strip() == "":
i += 1
continue
# paragraph (accumulate)
para: list[str] = []
while i < n and lines[i].strip():
para.append(lines[i])
i += 1
t = "\n".join(para).strip()
if t:
blocks.append({"type": "paragraph", "text": t})
return blocks
# ── converter ────────────────────────────────────────────────────────────
class ThesisConverter:
"""Markdown → 理工类毕业论文 Word 文档。
处理流程:
1. 解析 MD → blocks
2. 扫描 blocks 提取论文题目H1
3. 按章节类别写入带正确格式的 Word
4. 每章自动分页、页面网格、字体字号严格按学校要求
"""
def __init__(self, config: ThesisFormat | None = None):
self.config = config or ThesisFormat()
self.doc = Document()
self._thesis_title: str = "" # 论文题目(来自 H1
self._has_title = False # 是否已保存论文题目
self._section_break_added = False # 是否插入过正文分节符
# ── public API ──────────────────────────────────────────────────
def convert(self, md_path: str | Path, docx_path: str | Path):
text = Path(md_path).read_text(encoding="utf-8")
text = self._strip_manual_toc(text)
blocks = _parse_blocks(text)
# extract H1 thesis title
for blk in blocks:
if blk["type"] == "heading" and blk["level"] == 1:
self._thesis_title = blk["text"]
break
self._setup_document()
self._process_blocks(blocks)
self.doc.save(str(docx_path))
# ── strip manual TOC ────────────────────────────────────────────
@staticmethod
def _strip_manual_toc(text: str) -> str:
lines = text.split("\n")
toc_start = -1
sep_end = -1
for i, line in enumerate(lines):
if re.search(r"[目目]\s*[次次]", line) and line.startswith("#"):
toc_start = i
if toc_start >= 0 and line.strip() == "---" and i > toc_start:
sep_end = i
break
if toc_start >= 0 and sep_end > toc_start:
kept = lines[:toc_start + 1]
kept.append("")
kept.extend(lines[sep_end:])
return "\n".join(kept)
return text
# ── page setup ──────────────────────────────────────────────────
def _setup_document(self):
cfg = self.config
sec = self.doc.sections[0]
self._apply_page_setup(sec, roman=True)
# default font
styles = self.doc.styles
normal = styles["Normal"]
rpr = normal.element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
rpr.insert(0, rfonts)
rfonts.set(qn("w:ascii"), cfg.font_en)
rfonts.set(qn("w:hAnsi"), cfg.font_en)
rfonts.set(qn("w:eastAsia"), cfg.font_cn)
rfonts.set(qn("w:cs"), cfg.font_en)
sz = rpr.find(qn("w:sz"))
if sz is None:
sz = parse_xml(
f'<w:sz {nsdecls("w")} w:val="{int(cfg.size_body * 2)}"/>')
rpr.append(sz)
pf = normal.paragraph_format
pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
pf.line_spacing = cfg.line_spacing_body
self._config_heading_styles()
_setup_footer(sec, roman=True)
def _config_heading_styles(self):
"""Configure Heading 1/2/3 built-in styles to match thesis formatting.
This ensures Word's TOC field can detect headings and auto-generate
the table of contents correctly.
"""
cfg = self.config
styles = self.doc.styles
# ── Heading 1 = 章 (三号宋体加粗左) ──────────────────────────
h1 = styles["Heading 1"]
h1.font.name = cfg.font_heading_en
rpr = h1.element.get_or_add_rPr()
rfonts = rpr.find(qn("w:rFonts"))
if rfonts is None:
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
rpr.insert(0, rfonts)
rfonts.set(qn("w:eastAsia"), cfg.font_cn_heading)
h1.font.size = Pt(cfg.size_chapter)
h1.font.bold = True
h1.font.color.rgb = RGBColor(0, 0, 0)
h1.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
h1.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
h1.paragraph_format.line_spacing = cfg.line_spacing_heading
h1.paragraph_format.space_before = Pt(0)
h1.paragraph_format.space_after = Pt(0)
# Keep with next + page break before
pPr = h1.element.get_or_add_pPr()
keep_next = parse_xml(f'<w:keepNext {nsdecls("w")}/>')
pPr.append(keep_next)
# ── Heading 2 = 节 (小三号宋体加粗左) ────────────────────────
h2 = styles["Heading 2"]
h2.font.name = cfg.font_heading_en
rpr2 = h2.element.get_or_add_rPr()
rfonts2 = rpr2.find(qn("w:rFonts"))
if rfonts2 is None:
rfonts2 = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
rpr2.insert(0, rfonts2)
rfonts2.set(qn("w:eastAsia"), cfg.font_cn_heading)
h2.font.size = Pt(cfg.size_section)
h2.font.bold = True
h2.font.color.rgb = RGBColor(0, 0, 0)
h2.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
h2.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
h2.paragraph_format.line_spacing = cfg.line_spacing_heading
h2.paragraph_format.space_before = Pt(0)
h2.paragraph_format.space_after = Pt(0)
# ── Heading 3 = 条 (四号宋体加粗左) ──────────────────────────
h3 = styles["Heading 3"]
h3.font.name = cfg.font_heading_en
rpr3 = h3.element.get_or_add_rPr()
rfonts3 = rpr3.find(qn("w:rFonts"))
if rfonts3 is None:
rfonts3 = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
rpr3.insert(0, rfonts3)
rfonts3.set(qn("w:eastAsia"), cfg.font_cn_heading)
h3.font.size = Pt(cfg.size_subsection)
h3.font.bold = True
h3.font.color.rgb = RGBColor(0, 0, 0)
h3.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
h3.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
h3.paragraph_format.line_spacing = cfg.line_spacing_heading
h3.paragraph_format.space_before = Pt(0)
h3.paragraph_format.space_after = Pt(0)
def _add_section_break_main(self):
sec = self.doc.add_section()
self._apply_page_setup(sec, roman=False)
self._section_break_added = True
def _apply_page_setup(self, sec, roman: bool = True):
"""Apply margins, grid, and footer to a section."""
cfg = self.config
sec.page_width = Cm(cfg.page_width)
sec.page_height = Cm(cfg.page_height)
sect_pr = sec._sectPr
for el in list(sect_pr):
if el.tag in (qn("w:pgMar"), qn("w:docGrid")):
sect_pr.remove(el)
pgMar = parse_xml(
f'<w:pgMar {nsdecls("w")} '
f'w:top="{int(cfg.margin_top * 567)}" '
f'w:bottom="{int(cfg.margin_bottom * 567)}" '
f'w:left="{int(cfg.margin_left * 567)}" '
f'w:right="{int(cfg.margin_right * 567)}" '
f'w:header="0" '
f'w:footer="{int(cfg.footer_distance * 567)}"/>')
sect_pr.append(pgMar)
text_height_mm = (cfg.page_height - cfg.margin_top
- cfg.margin_bottom) * 10
line_pitch = int(text_height_mm / cfg.grid_lines_per_page * 56.7)
text_width_mm = (cfg.page_width - cfg.margin_left
- cfg.margin_right) * 10
char_pitch = int(text_width_mm / cfg.grid_chars_per_line * 56.7)
dg = parse_xml(
f'<w:docGrid {nsdecls("w")} '
f'w:type="linesAndChars" '
f'w:linePitch="{line_pitch}" '
f'w:charSpace="{char_pitch}"/>')
sect_pr.append(dg)
_setup_footer(sec, roman=roman)
# ── block processing ────────────────────────────────────────────
def _process_blocks(self, blocks):
# State machine:
# before_abstract → abstract_cn → abstract_en → toc → main
state = "before_abstract"
self._seen_first_chapter = False
for blk in blocks:
t = blk["type"]
if t == "heading" and blk["level"] == 1:
# Skip H1 (thesis title) — not rendered on Chinese abstract
continue
if t == "heading" and blk["level"] == 2:
txt = blk["text"].strip()
if txt.replace(" ", "") == "摘 要".replace(" ", ""):
state = "abstract_cn"
self._add_abstract_title("摘 要")
continue
if txt == "Abstract":
self._add_abstract_title_en()
state = "abstract_en"
continue
if "" in txt and "" in txt:
state = "toc"
self._add_toc("目 次")
continue
# Normal chapter
if state in ("before_abstract", "abstract_cn", "abstract_en", "toc"):
self._add_section_break_main()
state = "main"
self._add_page_break_if_not_first()
self._add_chapter(txt)
continue
if t == "heading" and blk["level"] == 3:
self._ensure_main_section(state)
state = "main"
txt = blk["text"].strip()
if re.match(r"^\d+\.\d+\.\d+\s", txt):
self._add_subsection(txt)
else:
self._add_section(txt)
continue
if t == "heading" and blk["level"] >= 4:
self._ensure_main_section(state)
state = "main"
# headings below 3 → body-style bold
self._add_body_para(blk["text"], bold=True, indent=False)
continue
# paragraphs / code / blockquote / list / thematic_break
if t == "paragraph":
txt = blk["text"]
if not txt.strip():
continue
if state == "abstract_cn":
if txt.startswith("关键词:"):
self._add_keywords(txt, cn=True)
else:
self._add_abstract_body(txt)
continue
if state == "abstract_en":
if txt.startswith("Key words:"):
self._add_keywords(txt, cn=False)
else:
self._add_abstract_body(txt) # 英文摘要正文
continue
# Normal body
self._ensure_main_section(state)
state = "main"
if txt.startswith("关键词:"):
self._add_keywords(txt, cn=True)
elif txt.startswith("Key words:"):
self._add_keywords(txt, cn=False)
else:
self._add_body_para(txt)
continue
if t == "block_code":
# code can appear in abstract or main — skip abstract code
if state in ("abstract_cn", "abstract_en", "toc"):
continue
self._ensure_main_section(state)
state = "main"
self._process_code(blk)
continue
if t == "block_quote":
txt = blk.get("text", "").strip()
if not txt:
continue
self._ensure_main_section(state)
state = "main"
self._add_body_para(txt)
continue
if t == "list":
self._ensure_main_section(state)
state = "main"
for item in blk.get("items", []):
self._add_body_para("" + item)
continue
if t == "thematic_break":
# In front matter or already processed — handled by state
continue
def _ensure_main_section(self, state: str):
if state in ("before_abstract", "abstract_cn", "abstract_en", "toc"):
if not self._section_break_added:
self._add_section_break_main()
def _add_page_break_if_not_first(self):
if self._seen_first_chapter:
self.doc.add_page_break()
else:
self._seen_first_chapter = True
# ══════════════════════════════════════════════════════════════
# rendering methods
# ══════════════════════════════════════════════════════════════
# ── abstract ──────────────────────────────────────────────────
def _add_abstract_title(self, text: str):
"""摘要题头:三号宋体加粗居中 (3.3节)"""
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 1"]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
run = p.add_run(text)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_abstract_title, bold=True)
# blank line after title (§3.3)
self.doc.add_paragraph()
def _add_abstract_body(self, text: str):
"""摘要正文小四宋体首行缩进2字符"""
cfg = self.config
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_body)
_set_indent(p, cfg.first_line_indent_chars)
tokens = _parse_inline(text)
_add_inline(p, tokens, cfg)
def _add_abstract_title_en(self):
"""英文摘要页:标题+论文题目+作者署名 (2.3节)"""
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 1"]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
run = p.add_run("Abstract")
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_abstract_title, bold=True)
self.doc.add_paragraph()
# Thesis title in English (centered)
if self._thesis_title:
# crude English translation placeholder — user should replace
p2 = self.doc.add_paragraph()
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
_set_spacing(p2, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
r = p2.add_run(self._thesis_title)
_set_font(r, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_section, bold=True)
# Author & teacher line
p3 = self.doc.add_paragraph()
p3.alignment = WD_ALIGN_PARAGRAPH.CENTER
_set_spacing(p3, before=6, after=6,
line_spacing=cfg.line_spacing_heading)
r = p3.add_run("Student: \tTeacher: ")
_set_font(r, cfg.font_cn, cfg.font_en, size=cfg.size_body)
# ── keywords ──────────────────────────────────────────────────
def _add_keywords(self, text: str, cn: bool):
"""关键词:小四宋体加粗顶格 (3.3节)"""
cfg = self.config
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_body)
label = cfg.keywords_label_cn if cn else cfg.keywords_label_en
m = re.match(r"\*\*" + re.escape(label) + r"\*\*(.*)", text)
if m:
run = p.add_run(label)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_keyword_label, bold=True)
rest = m.group(1).strip()
tokens = _parse_inline(rest)
_add_inline(p, tokens, cfg, size=cfg.size_keyword_label)
else:
tokens = _parse_inline(text)
_add_inline(p, tokens, cfg, size=cfg.size_keyword_label)
# ── TOC ────────────────────────────────────────────────────────
def _add_toc(self, title: str):
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 1"]
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
run = p.add_run(title)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_abstract_title, bold=True)
self.doc.add_paragraph() # blank line
# Word TOC field
p2 = self.doc.add_paragraph()
_set_spacing(p2, before=0, after=0,
line_spacing=cfg.line_spacing_body)
r = p2.add_run()
r._element.append(parse_xml(
f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>'))
r2 = p2.add_run()
r2._element.append(parse_xml(
f'<w:instrText {nsdecls("w")} xml:space="preserve">'
' TOC \\o "1-3" \\h \\z \\u </w:instrText>'))
r3 = p2.add_run()
r3._element.append(parse_xml(
f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>'))
r4 = p2.add_run("(请右键此处 > 更新域)")
_set_font(r4, cfg.font_cn, cfg.font_en, size=cfg.size_body)
r5 = p2.add_run()
r5._element.append(parse_xml(
f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'))
# ── chapter headings (第一层次) ──────────────────────────────
def _add_chapter(self, text: str):
"""章标题:三号宋体加粗,顶格 (§3.2 表3)"""
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 1"]
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
# Ensure double space between number and title (§2.5.2 表1)
formatted = re.sub(r"^(\d+)\s+", r"\1 ", text)
run = p.add_run(formatted)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_chapter, bold=True)
# ── section heading (第二层次) ───────────────────────────────
def _add_section(self, text: str):
"""节标题:小三号宋体加粗,顶格 (§3.2 表3)"""
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 2"]
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
# Single space between number and title (§2.5.2 表1)
formatted = re.sub(r"^(\d+\.\d+)\s+", r"\1 ", text)
run = p.add_run(formatted)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_section, bold=True)
# ── subsection heading (第三层次) ──────────────────────────
def _add_subsection(self, text: str):
"""条标题:四号宋体加粗,顶格 (§3.2 表3)"""
cfg = self.config
p = self.doc.add_paragraph()
p.style = self.doc.styles["Heading 3"]
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_heading)
run = p.add_run(text)
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
size=cfg.size_subsection, bold=True)
# ── body paragraph ──────────────────────────────────────────
def _add_body_para(self, text: str, bold: bool = False,
indent: bool = True):
"""正文小四宋体首行缩进2字符 (§3.2)"""
cfg = self.config
p = self.doc.add_paragraph()
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_body)
if indent:
_set_indent(p, cfg.first_line_indent_chars)
tokens = _parse_inline(text)
_add_inline(p, tokens, cfg, bold=bold)
# ── code block ──────────────────────────────────────────────
def _process_code(self, blk: dict):
code = blk.get("raw", "")
if not code.strip():
return
cfg = self.config
p = self.doc.add_paragraph()
_set_spacing(p, before=0, after=0,
line_spacing=cfg.line_spacing_code)
pf = p.paragraph_format
pf.left_indent = Cm(0.75)
pPr = p._element.get_or_add_pPr()
shd = parse_xml(
f'<w:shd {nsdecls("w")} w:fill="F2F2F2" w:val="clear"/>')
pPr.append(shd)
for line in code.split("\n"):
if line:
run = p.add_run(line)
_set_font(run, cfg.font_code, cfg.font_code,
size=cfg.size_code)
p.add_run("\n")

13
main.py
View File

@@ -1,10 +1,7 @@
"""CLI entry for thesis Markdown → Word conversion.
#!/usr/bin/env python3
"""快速入口兼容旧用法python main.py"""
Usage:
uv run python main.py 毕业论文初稿.md # → 毕业论文初稿.docx
uv run python main.py 毕业论文初稿.md 论文.docx
"""
from transit.__main__ import main
from docx_thesis.cli import main
main()
if __name__ == "__main__":
main()

View File

@@ -5,15 +5,20 @@ description = "毕业论文 Markdown → Word 格式转换工具"
readme = "README.md"
requires-python = ">=3.14"
dependencies = [
"docxtpl>=0.20.2",
"mistune>=3.2.1",
"python-docx>=1.2.0",
"pyyaml>=6.0.3",
]
[project.scripts]
thesis = "docx_thesis.cli:main"
thesis = "transit.__main__:main"
[dependency-groups]
dev = [
"black>=26.3.1",
"mypy>=2.0.0",
"olefile>=0.47",
"pywin32>=311",
"ruff>=0.15.12",
]

18
transit/__init__.py Normal file
View File

@@ -0,0 +1,18 @@
"""transit —— 毕业论文 Markdown → Word 格式转换工具。"""
from .parser import parse_markdown
from .body import body_to_paragraphs, replace_placeholder
from .renderer import generate_thesis
from .config import load_config, ThesisConfig
from .references import references_to_paragraphs, format_gb7714
__all__ = [
"parse_markdown",
"body_to_paragraphs",
"replace_placeholder",
"generate_thesis",
"load_config",
"ThesisConfig",
"references_to_paragraphs",
"format_gb7714",
]

34
transit/__main__.py Normal file
View File

@@ -0,0 +1,34 @@
"""CLI 入口python -m transit"""
import argparse
from pathlib import Path
from .renderer import generate_thesis
def main():
parser = argparse.ArgumentParser(
description="毕业论文 Markdown → Word 格式转换工具"
)
parser.add_argument("data", type=str, help="Markdown 正文文件路径(.md")
parser.add_argument(
"-t", "--template", default="sample.docx", help="docx 模板文件路径(默认: sample.docx"
)
parser.add_argument(
"-o", "--output", default="output.docx", help="输出 Word 文件路径(默认: output.docx"
)
parser.add_argument(
"-c", "--config", default=None, help="TOML 配置文件路径(可选)"
)
args = parser.parse_args()
generate_thesis(
template_path=args.template,
data_path=args.data,
config_path=args.config,
output_path=args.output,
)
if __name__ == "__main__":
main()

276
transit/body.py Normal file
View File

@@ -0,0 +1,276 @@
"""
Markdown 正文 → Word 段落转换。
将正文 Markdown 按标题层级拆分为带样式的段落序列,
再注入到渲染后 docx 文档的占位符位置。
"""
import re
from copy import deepcopy
from pathlib import Path
from docx import Document
from docx.oxml.ns import qn
from .images import make_image_paragraph, is_figure_caption, insert_image_paragraphs
_PAT_HEADING = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
# 匹配正文中的引用标记 [1] / [1,2,3]
_CITE_PATTERN = re.compile(r"\[(\d+(?:[,\s]*\d+)*)\]")
def body_to_paragraphs(
md_text: str,
*,
level_offset: int = 0,
body_style: str = "Body Text Indent",
base_dir: str | Path | None = None,
) -> list[dict]:
"""将 Markdown 正文按标题和段落拆分为结构化列表。
Parameters
----------
md_text : str
正文 Markdown。
level_offset : int
标题级别偏移量(正文从 ``##`` 开始时传 ``-1``,使其输出为 ``Heading 1``)。
body_style : str
正文段落的 Word 样式名。
base_dir : str | Path | None
Markdown 文件所在目录,用于解析图片相对路径。
"""
paragraphs: list[dict] = []
last_end = 0
def _add_block(block: str) -> None:
block = block.strip()
if not block:
return
# 图片段落
img = make_image_paragraph(block, base_dir)
if img:
paragraphs.append(img)
return
# 跳过紧跟在图片后的重复图标题
if paragraphs and paragraphs[-1].get("type") == "image" and is_figure_caption(block):
return
# 普通正文段落
paragraphs.append({"text": block, "level": 0, "style": body_style})
for m in _PAT_HEADING.finditer(md_text):
# 标题前的普通文本
if m.start() > last_end:
pre = md_text[last_end : m.start()].strip()
if pre:
for block in re.split(r"\n\s*\n", pre):
_add_block(block)
level = len(m.group(1)) + level_offset
heading_text = m.group(2).strip()
paragraphs.append(
{"text": heading_text, "level": level, "style": f"Heading {level}"}
)
last_end = m.end()
# 最后一段 / 尾部文本
tail = md_text[last_end:].strip()
if tail:
for block in re.split(r"\n\s*\n", tail):
_add_block(block)
return paragraphs
def replace_placeholder(
doc: Document,
placeholder: str,
paragraphs: list[dict],
*,
default_body_style: str | None = None,
):
"""在 *doc* 中找到包含 *placeholder* 的段落,替换为 *paragraphs* 列表。
正文段落的样式优先级:
1. ``style`` 字段指定的样式名(来自 ``body_to_paragraphs`` 的 ``body_style``
2. 占位符段落自身的样式(模板中已设好的样式)
3. ``Normal``
"""
placeholder_found = False
for para in doc.paragraphs:
if placeholder in para.text:
placeholder_found = True
placeholder_style = para.style.name if para.style else None
parent = para._element.getparent()
idx = list(parent).index(para._element)
# 保存原段落的编号属性numPr用于继承自动编号
orig_pPr = para._element.find(qn("w:pPr"))
numPr = orig_pPr.find(qn("w:numPr")) if orig_pPr is not None else None
parent.remove(para._element)
# 为参考文献段落准备书签 ID
bm_id = _max_bookmark_id(doc) + 1
for pd_data in reversed(paragraphs):
if pd_data.get("type") == "image":
insert_image_paragraphs(
doc, [pd_data], idx=idx, parent=parent
)
else:
new_p = doc.add_paragraph(pd_data["text"])
style_name = pd_data["style"]
# 尝试应用样式,逐步降级
applied = _apply_style(new_p, doc, style_name)
if not applied and style_name.startswith("Heading"):
new_p.style = doc.styles["Normal"]
elif not applied:
if placeholder_style:
_apply_style(new_p, doc, placeholder_style)
if new_p.style.name == "Normal" and placeholder_style:
new_p.style = doc.styles[placeholder_style]
# 继承原段落的编号属性(自动编号)
if numPr is not None:
new_pPr = new_p._element.find(qn("w:pPr"))
if new_pPr is None:
new_pPr = new_p._element.makeelement(qn("w:pPr"), {})
new_p._element.insert(0, new_pPr)
existing = new_pPr.find(qn("w:numPr"))
if existing is not None:
new_pPr.remove(existing)
new_pPr.append(deepcopy(numPr))
parent.insert(idx, new_p._element)
# 为参考文献条目添加书签
ref_id = pd_data.get("ref_id")
if ref_id is not None:
_add_bookmark(new_p, f"ref-{ref_id}", bm_id)
bm_id += 1
break
if not placeholder_found:
print(f"警告:未找到占位符 '{placeholder}',正文段落未注入。")
def link_body_citations(doc: Document):
"""将文档中正文段落的 ``[N]`` 引用替换为指向对应书签的超链接。"""
for para in doc.paragraphs:
# 跳过已有超链接的段落(如目录页)
if para._element.findall(qn("w:hyperlink")):
continue
_link_paragraph(para)
def _max_bookmark_id(doc: Document) -> int:
"""扫描文档返回最大书签 ID。"""
max_id = 0
for para in doc.paragraphs:
for bm in para._element.iter(qn("w:bookmarkStart")):
try:
max_id = max(max_id, int(bm.get(qn("w:id"))))
except (ValueError, TypeError):
pass
return max_id
def _add_bookmark(paragraph, name: str, bm_id: int):
"""为段落添加书签。"""
bm_start = paragraph._element.makeelement(qn("w:bookmarkStart"), {})
bm_start.set(qn("w:id"), str(bm_id))
bm_start.set(qn("w:name"), name)
bm_end = paragraph._element.makeelement(qn("w:bookmarkEnd"), {})
bm_end.set(qn("w:id"), str(bm_id))
pPr = paragraph._element.find(qn("w:pPr"))
if pPr is not None:
paragraph._element.insert(1, bm_start)
else:
paragraph._element.insert(0, bm_start)
paragraph._element.append(bm_end)
def _link_paragraph(para):
"""将单个段落中的 ``[N]`` 替换为 HYPERLINK 域。"""
runs = list(para._element.findall(qn("w:r")))
if not runs:
return
full_text = ""
for r in runs:
t = r.find(qn("w:t"))
if t is not None and t.text:
full_text += t.text
matches = list(_CITE_PATTERN.finditer(full_text))
if not matches:
return
first_rPr = runs[0].find(qn("w:rPr"))
for r in runs:
para._element.remove(r)
pos = 0
for m in matches:
before = full_text[pos : m.start()]
if before:
_add_run(para._element, before, first_rPr)
nums = re.findall(r"\d+", m.group(1))
_add_hlink(para._element, f"ref-{nums[0]}", m.group())
pos = m.end()
after = full_text[pos:]
if after:
_add_run(para._element, after, first_rPr)
def _add_run(parent, text: str, rPr):
r = parent.makeelement(qn("w:r"), {})
if rPr is not None:
r.append(deepcopy(rPr))
t = r.makeelement(qn("w:t"), {})
t.text = text
r.append(t)
parent.append(r)
def _add_hlink(parent, anchor: str, text: str):
hl = parent.makeelement(qn("w:hyperlink"), {})
hl.set(qn("w:anchor"), anchor)
r = parent.makeelement(qn("w:r"), {})
rPr = r.makeelement(qn("w:rPr"), {})
rStyle = rPr.makeelement(qn("w:rStyle"), {})
rStyle.set(qn("w:val"), "Hyperlink")
rPr.append(rStyle)
vertAlign = rPr.makeelement(qn("w:vertAlign"), {})
vertAlign.set(qn("w:val"), "superscript")
rPr.append(vertAlign)
r.append(rPr)
t = r.makeelement(qn("w:t"), {})
t.text = text
r.append(t)
hl.append(r)
parent.append(hl)
def _apply_style(paragraph, doc, style_name: str) -> bool:
"""尝试给段落应用样式,成功返回 ``True``。"""
try:
paragraph.style = doc.styles[style_name]
return True
except KeyError:
pass
# 大小写不敏感匹配
for s in doc.styles:
if s.name.lower() == style_name.lower():
paragraph.style = s
return True
return False

51
transit/config.py Normal file
View File

@@ -0,0 +1,51 @@
from dataclasses import dataclass, field
from pathlib import Path
from typing import Optional
import tomllib
@dataclass
class ThesisConfig:
"""论文配置数据。
``metadata`` 直接透传 TOML 的 ``[metadata]`` 节,不再为每个变量声明字段。
新增模板变量只需改 TOML无需修改 Python。
"""
metadata: dict = field(default_factory=dict)
# 以下字段仍有业务逻辑,保留为显式属性
title_from_md: bool = True
body_start_keywords: list[str] = field(default_factory=lambda: ["绪论", "引言"])
body_end_keywords: list[str] = field(
default_factory=lambda: ["致谢", "参考文献", "附录"]
)
body_style: str = "Body Text Indent"
level_offset: int = -1
reference_style: str = "列出段落1"
def to_dict(self) -> dict:
"""透传 metadata模板变量来源"""
return self.metadata
def load_config(path: str | Path) -> ThesisConfig:
"""从 TOML 文件加载论文配置。"""
path = Path(path)
with open(path, "rb") as f:
raw = tomllib.load(f)
meta = raw.get("metadata", {})
opts = raw.get("options", {})
return ThesisConfig(
metadata=meta,
title_from_md=opts.get("title_from_md", True),
body_start_keywords=opts.get("body_start_keywords", ["绪论", "引言"]),
body_end_keywords=opts.get(
"body_end_keywords", ["致谢", "参考文献", "附录"]
),
body_style=opts.get("body_style", "Body Text Indent"),
level_offset=opts.get("level_offset", -1),
reference_style=opts.get("reference_style", "列出段落1"),
)

163
transit/images.py Normal file
View File

@@ -0,0 +1,163 @@
"""
图片处理模块。
将 Markdown 中的 ``<img>`` 标签解析为独立段落,
并在 Word 文档中插入图片及居中图标题。
"""
import struct
import re
from pathlib import Path
from docx import Document
from docx.enum.text import WD_ALIGN_PARAGRAPH
# 匹配 <img src="..." alt="...">
_IMG_TAG = re.compile(
r'<img\s+([^>]*?)src=["\']([^"\']+)["\']([^>]*?)>', re.IGNORECASE
)
# 从标签属性块中提取名值对
_ATTR = re.compile(r'(\w+)\s*=\s*["\']([^"\']*)["\']')
# 匹配 **图X ...** 重复图标题(注入时跳过)
_FIG_CAPTION = re.compile(
r'^\*\*(?:图|Table|Figure|Fig\.?)\s*\d+.*\*\*$', re.IGNORECASE
)
def make_image_paragraph(block: str, base_dir: str | Path | None = None) -> dict | None:
"""若 *block* 包含 ``<img>`` 标签,返回图片段落字典;否则返回 ``None``。
Parameters
----------
block : str
文本块。
base_dir : str | Path | None
Markdown 文件所在目录,用于解析图片相对路径。
"""
m = _IMG_TAG.search(block)
if not m:
return None
attrs = dict(_ATTR.findall(block))
src = attrs.get("src", m.group(2))
if base_dir and not Path(src).is_absolute():
src = str(Path(base_dir) / src)
return {
"type": "image",
"src": src,
"alt": attrs.get("alt", ""),
}
def is_figure_caption(block: str) -> bool:
"""检查 *block* 是否为 ``**图X ...**`` 格式的重复图标题。"""
return bool(_FIG_CAPTION.match(block.strip()))
def _get_image_dimensions(image_path: str) -> tuple[int, int] | None:
"""读取图片文件头返回 ``(width_px, height_px)``,不支持格式返回 ``None``。
仅读取文件头,不依赖第三方库。支持 PNG / JPEG / GIF / BMP。
"""
try:
with open(image_path, "rb") as f:
header = f.read(32)
except Exception:
return None
# PNG: 8-byte signature, then IHDR chunk
if header[:8] == b"\x89PNG\r\n\x1a\n":
w, h = struct.unpack_from(">II", header, 16)
return w, h
# JPEG: starts with FF D8, scan for SOF marker
if header[:2] == b"\xff\xd8":
pos = 2
while pos < len(header):
if header[pos] != 0xFF:
return None
marker = header[pos + 1]
if marker in (0xC0, 0xC1, 0xC2):
h, w = struct.unpack_from(">HH", header, pos + 5)
return w, h
seg_len = struct.unpack_from(">H", header, pos + 2)[0]
pos += 2 + seg_len
return None
# GIF: "GIF87a" or "GIF89a"
if header[:6] in (b"GIF87a", b"GIF89a"):
w, h = struct.unpack_from("<HH", header, 6)
return w, h
# BMP: "BM" signature
if header[:2] == b"BM":
w, h = struct.unpack_from("<ii", header, 18)
return w, abs(h)
return None
def _get_native_emu(image_path: str) -> int | None:
"""读取图片的原生宽度EMU失败返回 ``None``。
Word 默认以 72 DPI 渲染图片1 px = 914400 / 72 = 12700 EMU。
"""
dims = _get_image_dimensions(image_path)
if dims is None:
return None
w_px, _ = dims
return w_px * 12700
def _constrain_width(image_path: str, page_text_width: int) -> int | None:
"""返回图片宽度EMU超出页宽时缩至页宽。
Parameters
----------
image_path : str
图片路径。
page_text_width : int
页面正文区宽度EMU来自 ``section.page_width - margins``。
"""
native = _get_native_emu(image_path)
if native is None:
return None
return min(native, page_text_width)
def insert_image_paragraphs(
doc: Document,
paragraphs: list[dict],
*,
idx: int,
parent,
):
"""在 *doc* 的指定位置插入图片段落序列。
每条图片段落生成两个 Word 段落:
1. 居中图片(超出页宽时自动缩放至页宽,否则保持原尺寸)
2. 居中图标题(从 ``alt`` 提取)
插入顺序保持 ``paragraphs`` 的原有顺序。
"""
section = doc.sections[0]
page_text_width = section.page_width - section.left_margin - section.right_margin
for pd_data in reversed(paragraphs):
# 图标题(在 reversed 中先插入,最终位于图片下方)
cap_p = doc.add_paragraph(pd_data.get("alt", ""))
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
parent.insert(idx, cap_p._element)
# 图片段落max-width 行为:超出页宽时压缩,否则原尺寸)
img_p = doc.add_paragraph()
img_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
img_run = img_p.add_run()
try:
img_width = _constrain_width(pd_data["src"], page_text_width)
img_run.add_picture(pd_data["src"], width=img_width)
except Exception as exc:
print(f"警告:图片加载失败 {pd_data['src']}{exc}")
img_run.add_text(f"[图片加载失败: {pd_data['src']}]")
parent.insert(idx, img_p._element)

131
transit/parser.py Normal file
View File

@@ -0,0 +1,131 @@
"""
Markdown 论文解析器。
将结构化 Markdown摘要、正文、致谢、参考文献、附录解析为字典
供 docx 模板渲染使用。
"""
import re
from typing import Optional
# 匹配任意级数的标题:^## 或 ^### 等,支持可选的数字编号
_RE_HEADING = re.compile(r"^(#{1,6})\s*(?:\d+(?:\.\d+)*\s*)?(.+)$", re.MULTILINE)
def _strip_front_matter(content: str) -> str:
"""移除 YAML front matter``---`` 包裹的头部块)。"""
if content.startswith("---"):
end = content.find("---", 3)
if end != -1:
return content[end + 3 :]
return content
def _find_section(
content: str, titles: list[str], after: int = 0
) -> Optional[tuple[int, int, str]]:
"""查找第一个匹配的章节,返回 ``(section_start, section_end, matched_title)``。
章节范围从标题行开始,到下一个同级/更高级标题结束(或内容结尾)。
"""
for m in _RE_HEADING.finditer(content, after):
raw_text = m.group(2).strip()
for t in titles:
if raw_text == t or raw_text.endswith(t):
rest = content[m.end() :]
next_m = _RE_HEADING.search(rest)
section_end = m.end() + (next_m.start() if next_m else len(rest))
return (m.start(), section_end, t)
return None
def _get_section_body(content: str, section_info: tuple) -> str:
"""从 section_info 中提取标题行之后的纯章节正文。"""
hdr_m = _RE_HEADING.match(content, section_info[0])
if not hdr_m:
return ""
return content[hdr_m.end() : section_info[1]].strip()
def parse_markdown(
md_text: str,
body_start_kw: list[str] | None = None,
body_end_kw: list[str] | None = None,
) -> dict:
"""解析 Markdown 格式的论文文本,返回模板变量字典。
Parameters
----------
md_text : str
完整的 Markdown 文本。
body_start_kw : list[str] | None
标识正文开始的章节名列表,默认 [``绪论``, ``引言``]。
body_end_kw : list[str] | None
标识正文结束的章节名列表,默认 [``致谢``, ``参考文献``, ``附录``]。
"""
if body_start_kw is None:
body_start_kw = ["绪论", "引言"]
if body_end_kw is None:
body_end_kw = ["致谢", "参考文献", "附录"]
content = _strip_front_matter(md_text.strip())
data: dict = {}
# ── 标题(第一个 # 标题) ──
title_m = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
if title_m:
data["title"] = title_m.group(1).strip()
# ── 中文摘要 ──
abs_cn = _find_section(content, ["摘 要", "摘要"])
if abs_cn:
sec_body = _get_section_body(content, abs_cn)
kw_m = re.search(
r"\*\*关键词[:]?\s*\*\*\s*(.*?)$", sec_body, re.MULTILINE
)
if kw_m:
data["abstact_cn_context"] = sec_body[: kw_m.start()].strip()
data["abstract_cn_keywords"] = kw_m.group(1).strip()
else:
data["abstact_cn_context"] = sec_body
# ── 英文摘要 ──
abs_en = _find_section(content, ["Abstract"])
if abs_en:
sec_body = _get_section_body(content, abs_en)
kw_m = re.search(
r"\*\*Key words[:]?\s*\*\*\s*(.*?)$", sec_body, re.MULTILINE
)
if kw_m:
data["abstract_en_context"] = sec_body[: kw_m.start()].strip()
data["abstract_en_keywords"] = kw_m.group(1).strip()
else:
data["abstract_en_context"] = sec_body
# ── 正文(从绪论/引言到致谢/参考文献/附录) ──
body_start = _find_section(content, body_start_kw)
if body_start:
body_content = content[body_start[0] :]
body_end = _find_section(body_content, body_end_kw)
if body_end:
body_content = body_content[: body_end[0]]
data["body_md"] = body_content.strip()
else:
data["body_md"] = ""
# ── 致谢(仅正文,不含标题行) ──
ack = _find_section(content, ["致谢"])
if ack:
data["acknowledgement"] = _get_section_body(content, ack)
# ── 参考文献(仅正文,不含标题行) ──
ref = _find_section(content, ["参考文献"])
if ref:
data["reference"] = _get_section_body(content, ref)
# ── 附录(仅正文,不含标题行) ──
app = _find_section(content, ["附录"])
if app:
data["appendix"] = _get_section_body(content, app)
return data

118
transit/references.py Normal file
View File

@@ -0,0 +1,118 @@
"""
GB 7714 《文后参考文献著录规则》顺序编码制 — 参考文献解析与格式化。
将 Markdown 中 ``[N] ... [TYPE] ...`` 格式的参考文献逐条解析,
按文献类型(书 M、期刊 J、会议 C、学位论文 D、标准 S、电子资源 EB/OL 等)
重新编排为符合 GB 7714 规范的格式,并输出为独立段落。
"""
import re
from typing import Optional
# 单行匹配: [N] 开头
_RE_LINE = re.compile(r"^\[(\d+)\]\s*(.*)$")
# 文献类型标记: [M] [J] [C] [D] [S] [EB/OL] [P] [N]
_RE_TYPE = re.compile(r"\[(\w+(?:/\w+)?)\]")
# GB 7714 中各文献类型的标准格式模板(仅用于说明,实际格式化直接拼接)
TYPE_LABELS: dict[str, str] = {
"M": "专著",
"J": "期刊文章",
"C": "会议论文",
"D": "学位论文",
"S": "标准",
"EB/OL": "电子资源",
"P": "专利",
"N": "报纸文章",
}
def parse_reference_line(line: str) -> Optional[dict]:
"""解析单行参考文献,提取序号、作者+标题、文献类型、来源信息。
期望格式::
[N] Authors. Title[TYPE]. Source info.
"""
line = line.strip()
m = _RE_LINE.match(line)
if not m:
return None
number = int(m.group(1))
rest = m.group(2).strip()
# 定位文献类型标记 [TYPE]
tm = _RE_TYPE.search(rest)
if not tm:
return None
before_type = rest[: tm.start()].strip().rstrip(".")
doc_type = tm.group(1)
after_type = rest[tm.end() :].strip().lstrip(".").strip()
return {
"number": number,
"before_type": before_type, # "Authors. Title"
"doc_type": doc_type, # "M", "J", "EB/OL", …
"after_type": after_type, # 来源信息
}
def _normalize_period(text: str) -> str:
"""确保文本以英文句点结尾GB 7714 要求)。"""
text = text.rstrip()
if text and not text.endswith("."):
text += "."
return text
def format_gb7714(ref: dict) -> str:
"""按 GB 7714 重新编排一条参考文献(不含序号前缀,由 Word 样式自动编号)。
格式::
Authors. Title[TYPE]. Source.
"""
bt = ref["before_type"]
dt = ref["doc_type"]
at = ref["after_type"]
formatted = f"{bt}[{dt}]. {at}"
return _normalize_period(formatted)
def references_to_paragraphs(
ref_text: str,
ref_style: str = "列出段落1",
) -> list[dict]:
"""将参考文献原始文本转换为格式化段落列表。
返回的每个元素::
{"text": str, "level": 0, "style": ref_style}
每条参考文献为一个独立段落。
"""
if not ref_text or ref_text == "<None>":
return [{"text": "<None>", "level": 0, "style": ref_style}]
lines = [l.strip() for l in ref_text.strip().split("\n") if l.strip()]
paragraphs: list[dict] = []
for line in lines:
ref = parse_reference_line(line)
if ref:
formatted = format_gb7714(ref)
ref_id = ref["number"]
else:
# 无法解析时,至少去掉 [N] 前缀
fallback = re.sub(r"^\[\d+\]\s*", "", line)
formatted = _normalize_period(fallback)
ref_id = None
paragraphs.append(
{"text": formatted, "level": 0, "style": ref_style, "ref_id": ref_id}
)
return paragraphs

152
transit/renderer.py Normal file
View File

@@ -0,0 +1,152 @@
"""
论文生成编排器。
组装 配置 + 解析 + 模板渲染 + 正文注入 的完整流水线。
"""
from collections import defaultdict
from pathlib import Path
from docxtpl import DocxTemplate
from docx import Document
from .config import load_config, ThesisConfig
from .parser import parse_markdown
from .body import body_to_paragraphs, replace_placeholder, link_body_citations
from .references import references_to_paragraphs
# 解析器可能产生的字段(用于填充报告)
_PARSER_FIELDS = [
"title",
"abstact_cn_context",
"abstract_cn_keywords",
"abstract_en_context",
"abstract_en_keywords",
"acknowledgement",
"reference",
"appendix",
"body_md",
]
def generate_thesis(
template_path: str | Path,
data_path: str | Path,
config_path: str | Path | None = None,
output_path: str | Path = "output.docx",
) -> dict:
"""执行从数据到 Word 的完整论文生成流程。
Parameters
----------
template_path : str | Path
docxtpl 模板文件路径(.docx
data_path : str | Path
Markdown 论文正文文件路径(.md
config_path : str | Path | None
TOML 配置文件路径。为 ``None`` 时尝试自动查找。
output_path : str | Path
输出 Word 文件路径。
"""
data_path = Path(data_path)
# 1. 加载配置
if config_path is None:
candidates = [
Path("thesis_config.toml"),
data_path.with_suffix(".toml"),
]
config_path = next((p for p in candidates if p.exists()), None)
config: ThesisConfig | None = None
if config_path and Path(config_path).exists():
config = load_config(config_path)
print(f"[配置] 配置文件: {config_path}")
else:
config = ThesisConfig()
print("[配置] 未找到配置文件,使用默认值。")
# 2. 解析 Markdown
with open(data_path, "r", encoding="utf-8") as f:
md_text = f.read()
context = parse_markdown(
md_text,
body_start_kw=config.body_start_keywords,
body_end_kw=config.body_end_keywords,
)
# 3. 合并配置 → 上下文(配置填充解析器未产生的空白)
for k, v in config.to_dict().items():
if k == "title" and config.title_from_md and context.get("title"):
continue # 以 markdown 标题为准
context.setdefault(k, v)
# 4. 用 defaultdict 兜底缺失键
ctx = defaultdict(lambda: "<None>", context)
# 5. 解析正文为段落列表
body_md = ctx.get("body_md", "")
body_paragraphs = (
body_to_paragraphs(
body_md,
level_offset=config.level_offset,
body_style=config.body_style,
base_dir=data_path.parent,
)
if body_md else []
)
# 6. 解析参考文献为段落列表
ref_text = ctx.get("reference", "")
ref_paragraphs = references_to_paragraphs(ref_text, ref_style=config.reference_style)
# 7. 占位符(替代模板变量,后处理时替换)
ctx["body_placeholder"] = "__CONTEXT_PLACEHOLDER__"
ctx["reference"] = "__REFERENCE_PLACEHOLDER__"
# 7. 渲染模板
doc = DocxTemplate(str(template_path))
doc.render(ctx)
# 8. 保存临时文件,再做后处理
temp_path = Path(output_path).with_suffix(".tmp")
doc.save(str(temp_path))
# 9. 正文注入+参考文献注入
final_doc = Document(str(temp_path))
replace_placeholder(
final_doc, "__CONTEXT_PLACEHOLDER__", body_paragraphs,
default_body_style=config.body_style,
)
# 将正文中的 [N] 引用替换为超链接
link_body_citations(final_doc)
replace_placeholder(
final_doc, "__REFERENCE_PLACEHOLDER__", ref_paragraphs,
default_body_style=config.reference_style,
)
final_doc.save(str(output_path))
temp_path.unlink(missing_ok=True)
print(f"[完成] 论文生成完成: {output_path}")
# 10. 字段填充报告(动态收集所有模板与解析字段)
report_fields = list(dict.fromkeys([*config.metadata.keys(), *_PARSER_FIELDS]))
print("\n--- 字段填充情况 ---")
for key in report_fields:
val = ctx.get(key, "<None>")
if val == "<None>":
print(f" [缺失] {key}")
else:
preview = str(val)[:60].replace("\n", " ")
print(f" [OK] {key}: {preview}...")
missing = [k for k in report_fields if ctx.get(k, "<None>") == "<None>"]
if missing:
print("\n[警告] 以下字段缺失,已填充 '<None>'")
for f in missing:
print(f" - {f}")
return dict(ctx)

191
uv.lock generated
View File

@@ -44,6 +44,75 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/bd/46/d3ec57ad500f598d1554bd14ce4df615960549ab2844961bc4e1f5fbd174/ast_serialize-0.3.0-cp39-abi3-win_arm64.whl", hash = "sha256:0dd00da29985f15f50dc35728b7e1e7c84507bccfea1d9914738530f1c72238a", size = 1077165, upload-time = "2026-04-30T23:24:46.377Z" },
]
[[package]]
name = "black"
version = "26.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "click" },
{ name = "mypy-extensions" },
{ name = "packaging" },
{ name = "pathspec" },
{ name = "platformdirs" },
{ name = "pytokens" },
]
sdist = { url = "https://files.pythonhosted.org/packages/e1/c5/61175d618685d42b005847464b8fb4743a67b1b8fdb75e50e5a96c31a27a/black-26.3.1.tar.gz", hash = "sha256:2c50f5063a9641c7eed7795014ba37b0f5fa227f3d408b968936e24bc0566b07", size = 666155, upload-time = "2026-03-12T03:36:03.593Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d5/da/e36e27c9cebc1311b7579210df6f1c86e50f2d7143ae4fcf8a5017dc8809/black-26.3.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:2d6bfaf7fd0993b420bed691f20f9492d53ce9a2bcccea4b797d34e947318a78", size = 1889234, upload-time = "2026-03-12T03:40:30.964Z" },
{ url = "https://files.pythonhosted.org/packages/0e/7b/9871acf393f64a5fa33668c19350ca87177b181f44bb3d0c33b2d534f22c/black-26.3.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:f89f2ab047c76a9c03f78d0d66ca519e389519902fa27e7a91117ef7611c0568", size = 1720522, upload-time = "2026-03-12T03:40:32.346Z" },
{ url = "https://files.pythonhosted.org/packages/03/87/e766c7f2e90c07fb7586cc787c9ae6462b1eedab390191f2b7fc7f6170a9/black-26.3.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b07fc0dab849d24a80a29cfab8d8a19187d1c4685d8a5e6385a5ce323c1f015f", size = 1787824, upload-time = "2026-03-12T03:40:33.636Z" },
{ url = "https://files.pythonhosted.org/packages/ac/94/2424338fb2d1875e9e83eed4c8e9c67f6905ec25afd826a911aea2b02535/black-26.3.1-cp314-cp314-win_amd64.whl", hash = "sha256:0126ae5b7c09957da2bdbd91a9ba1207453feada9e9fe51992848658c6c8e01c", size = 1445855, upload-time = "2026-03-12T03:40:35.442Z" },
{ url = "https://files.pythonhosted.org/packages/86/43/0c3338bd928afb8ee7471f1a4eec3bdbe2245ccb4a646092a222e8669840/black-26.3.1-cp314-cp314-win_arm64.whl", hash = "sha256:92c0ec1f2cc149551a2b7b47efc32c866406b6891b0ee4625e95967c8f4acfb1", size = 1258109, upload-time = "2026-03-12T03:40:36.832Z" },
{ url = "https://files.pythonhosted.org/packages/8e/0d/52d98722666d6fc6c3dd4c76df339501d6efd40e0ff95e6186a7b7f0befd/black-26.3.1-py3-none-any.whl", hash = "sha256:2bd5aa94fc267d38bb21a70d7410a89f1a1d318841855f698746f8e7f51acd1b", size = 207542, upload-time = "2026-03-12T03:36:01.668Z" },
]
[[package]]
name = "click"
version = "8.3.3"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "colorama", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/bb/63/f9e1ea081ce35720d8b92acde70daaedace594dc93b693c869e0d5910718/click-8.3.3.tar.gz", hash = "sha256:398329ad4837b2ff7cbe1dd166a4c0f8900c3ca3a218de04466f38f6497f18a2", size = 328061, upload-time = "2026-04-22T15:11:27.506Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ae/44/c1221527f6a71a01ec6fbad7fa78f1d50dfa02217385cf0fa3eec7087d59/click-8.3.3-py3-none-any.whl", hash = "sha256:a2bf429bb3033c89fa4936ffb35d5cb471e3719e1f3c8a7c3fff0b8314305613", size = 110502, upload-time = "2026-04-22T15:11:25.044Z" },
]
[[package]]
name = "colorama"
version = "0.4.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" },
]
[[package]]
name = "docxtpl"
version = "0.20.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "jinja2" },
{ name = "lxml" },
{ name = "python-docx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/b2/b4/4435f3fcb1357ec441079c4af1dda3ea926fad6dcead4aed2d93b369944e/docxtpl-0.20.2.tar.gz", hash = "sha256:eddf3350d70b4d123208e801d585bcb313d21044a377a14f75a66d0965841de1", size = 17890, upload-time = "2025-11-13T12:47:15.943Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/a4/ad/e07939d8e020e513d3860400413ba1e0e06102c469639b440d921337efef/docxtpl-0.20.2-py3-none-any.whl", hash = "sha256:626d5c570a46a62b2ca73b4d08f1c240fa031a5bc45371e1466a4fe184923d10", size = 17881, upload-time = "2025-11-13T12:47:13.704Z" },
]
[[package]]
name = "jinja2"
version = "3.1.6"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markupsafe" },
]
sdist = { url = "https://files.pythonhosted.org/packages/df/bf/f7da0350254c0ed7c72f3e33cef02e048281fec7ecec5f032d4aac52226b/jinja2-3.1.6.tar.gz", hash = "sha256:0137fb05990d35f1275a587e9aee6d56da821fc83491a0fb838183be43f66d6d", size = 245115, upload-time = "2025-03-05T20:05:02.478Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/62/a1/3d680cbfd5f4b8f15abc1d571870c5fc3e594bb582bc3b64ea099db13e56/jinja2-3.1.6-py3-none-any.whl", hash = "sha256:85ece4451f492d0c13c5dd7c13a64681a86afae63a5f347908daf103ce6d2f67", size = 134899, upload-time = "2025-03-05T20:05:00.369Z" },
]
[[package]]
name = "librt"
version = "0.10.0"
@@ -122,6 +191,36 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/2b/a0/9b916c68c0e57752c07f8f64b30138d9d4059dbeb27b90274dedbea128ff/lxml-6.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:26dd9f57ee3bd41e7d35b4c98a2ffd89ed11591649f421f0ec19f67d50ec67ac", size = 3817120, upload-time = "2026-04-18T04:32:15.803Z" },
]
[[package]]
name = "markupsafe"
version = "3.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/99/7690b6d4034fffd95959cbe0c02de8deb3098cc577c67bb6a24fe5d7caa7/markupsafe-3.0.3.tar.gz", hash = "sha256:722695808f4b6457b320fdc131280796bdceb04ab50fe1795cd540799ebe1698", size = 80313, upload-time = "2025-09-27T18:37:40.426Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/8a/8e42d4838cd89b7dde187011e97fe6c3af66d8c044997d2183fbd6d31352/markupsafe-3.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:eaa9599de571d72e2daf60164784109f19978b327a3910d3e9de8c97b5b70cfe", size = 11619, upload-time = "2025-09-27T18:37:06.342Z" },
{ url = "https://files.pythonhosted.org/packages/b5/64/7660f8a4a8e53c924d0fa05dc3a55c9cee10bbd82b11c5afb27d44b096ce/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c47a551199eb8eb2121d4f0f15ae0f923d31350ab9280078d1e5f12b249e0026", size = 12029, upload-time = "2025-09-27T18:37:07.213Z" },
{ url = "https://files.pythonhosted.org/packages/da/ef/e648bfd021127bef5fa12e1720ffed0c6cbb8310c8d9bea7266337ff06de/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f34c41761022dd093b4b6896d4810782ffbabe30f2d443ff5f083e0cbbb8c737", size = 24408, upload-time = "2025-09-27T18:37:09.572Z" },
{ url = "https://files.pythonhosted.org/packages/41/3c/a36c2450754618e62008bf7435ccb0f88053e07592e6028a34776213d877/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:457a69a9577064c05a97c41f4e65148652db078a3a509039e64d3467b9e7ef97", size = 23005, upload-time = "2025-09-27T18:37:10.58Z" },
{ url = "https://files.pythonhosted.org/packages/bc/20/b7fdf89a8456b099837cd1dc21974632a02a999ec9bf7ca3e490aacd98e7/markupsafe-3.0.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e8afc3f2ccfa24215f8cb28dcf43f0113ac3c37c2f0f0806d8c70e4228c5cf4d", size = 22048, upload-time = "2025-09-27T18:37:11.547Z" },
{ url = "https://files.pythonhosted.org/packages/9a/a7/591f592afdc734f47db08a75793a55d7fbcc6902a723ae4cfbab61010cc5/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec15a59cf5af7be74194f7ab02d0f59a62bdcf1a537677ce67a2537c9b87fcda", size = 23821, upload-time = "2025-09-27T18:37:12.48Z" },
{ url = "https://files.pythonhosted.org/packages/7d/33/45b24e4f44195b26521bc6f1a82197118f74df348556594bd2262bda1038/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:0eb9ff8191e8498cca014656ae6b8d61f39da5f95b488805da4bb029cccbfbaf", size = 21606, upload-time = "2025-09-27T18:37:13.485Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0e/53dfaca23a69fbfbbf17a4b64072090e70717344c52eaaaa9c5ddff1e5f0/markupsafe-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2713baf880df847f2bece4230d4d094280f4e67b1e813eec43b4c0e144a34ffe", size = 23043, upload-time = "2025-09-27T18:37:14.408Z" },
{ url = "https://files.pythonhosted.org/packages/46/11/f333a06fc16236d5238bfe74daccbca41459dcd8d1fa952e8fbd5dccfb70/markupsafe-3.0.3-cp314-cp314-win32.whl", hash = "sha256:729586769a26dbceff69f7a7dbbf59ab6572b99d94576a5592625d5b411576b9", size = 14747, upload-time = "2025-09-27T18:37:15.36Z" },
{ url = "https://files.pythonhosted.org/packages/28/52/182836104b33b444e400b14f797212f720cbc9ed6ba34c800639d154e821/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:bdc919ead48f234740ad807933cdf545180bfbe9342c2bb451556db2ed958581", size = 15341, upload-time = "2025-09-27T18:37:16.496Z" },
{ url = "https://files.pythonhosted.org/packages/6f/18/acf23e91bd94fd7b3031558b1f013adfa21a8e407a3fdb32745538730382/markupsafe-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:5a7d5dc5140555cf21a6fefbdbf8723f06fcd2f63ef108f2854de715e4422cb4", size = 14073, upload-time = "2025-09-27T18:37:17.476Z" },
{ url = "https://files.pythonhosted.org/packages/3c/f0/57689aa4076e1b43b15fdfa646b04653969d50cf30c32a102762be2485da/markupsafe-3.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:1353ef0c1b138e1907ae78e2f6c63ff67501122006b0f9abad68fda5f4ffc6ab", size = 11661, upload-time = "2025-09-27T18:37:18.453Z" },
{ url = "https://files.pythonhosted.org/packages/89/c3/2e67a7ca217c6912985ec766c6393b636fb0c2344443ff9d91404dc4c79f/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1085e7fbddd3be5f89cc898938f42c0b3c711fdcb37d75221de2666af647c175", size = 12069, upload-time = "2025-09-27T18:37:19.332Z" },
{ url = "https://files.pythonhosted.org/packages/f0/00/be561dce4e6ca66b15276e184ce4b8aec61fe83662cce2f7d72bd3249d28/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1b52b4fb9df4eb9ae465f8d0c228a00624de2334f216f178a995ccdcf82c4634", size = 25670, upload-time = "2025-09-27T18:37:20.245Z" },
{ url = "https://files.pythonhosted.org/packages/50/09/c419f6f5a92e5fadde27efd190eca90f05e1261b10dbd8cbcb39cd8ea1dc/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:fed51ac40f757d41b7c48425901843666a6677e3e8eb0abcff09e4ba6e664f50", size = 23598, upload-time = "2025-09-27T18:37:21.177Z" },
{ url = "https://files.pythonhosted.org/packages/22/44/a0681611106e0b2921b3033fc19bc53323e0b50bc70cffdd19f7d679bb66/markupsafe-3.0.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f190daf01f13c72eac4efd5c430a8de82489d9cff23c364c3ea822545032993e", size = 23261, upload-time = "2025-09-27T18:37:22.167Z" },
{ url = "https://files.pythonhosted.org/packages/5f/57/1b0b3f100259dc9fffe780cfb60d4be71375510e435efec3d116b6436d43/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e56b7d45a839a697b5eb268c82a71bd8c7f6c94d6fd50c3d577fa39a9f1409f5", size = 24835, upload-time = "2025-09-27T18:37:23.296Z" },
{ url = "https://files.pythonhosted.org/packages/26/6a/4bf6d0c97c4920f1597cc14dd720705eca0bf7c787aebc6bb4d1bead5388/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:f3e98bb3798ead92273dc0e5fd0f31ade220f59a266ffd8a4f6065e0a3ce0523", size = 22733, upload-time = "2025-09-27T18:37:24.237Z" },
{ url = "https://files.pythonhosted.org/packages/14/c7/ca723101509b518797fedc2fdf79ba57f886b4aca8a7d31857ba3ee8281f/markupsafe-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5678211cb9333a6468fb8d8be0305520aa073f50d17f089b5b4b477ea6e67fdc", size = 23672, upload-time = "2025-09-27T18:37:25.271Z" },
{ url = "https://files.pythonhosted.org/packages/fb/df/5bd7a48c256faecd1d36edc13133e51397e41b73bb77e1a69deab746ebac/markupsafe-3.0.3-cp314-cp314t-win32.whl", hash = "sha256:915c04ba3851909ce68ccc2b8e2cd691618c4dc4c4232fb7982bca3f41fd8c3d", size = 14819, upload-time = "2025-09-27T18:37:26.285Z" },
{ url = "https://files.pythonhosted.org/packages/1a/8a/0402ba61a2f16038b48b39bccca271134be00c5c9f0f623208399333c448/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4faffd047e07c38848ce017e8725090413cd80cbc23d86e55c587bf979e579c9", size = 15426, upload-time = "2025-09-27T18:37:27.316Z" },
{ url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" },
]
[[package]]
name = "mistune"
version = "3.2.1"
@@ -170,6 +269,24 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
]
[[package]]
name = "olefile"
version = "0.47"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/69/1b/077b508e3e500e1629d366249c3ccb32f95e50258b231705c09e3c7a4366/olefile-0.47.zip", hash = "sha256:599383381a0bf3dfbd932ca0ca6515acd174ed48870cbf7fee123d698c192c1c", size = 112240, upload-time = "2023-12-01T16:22:53.025Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/17/d3/b64c356a907242d719fc668b71befd73324e47ab46c8ebbbede252c154b2/olefile-0.47-py2.py3-none-any.whl", hash = "sha256:543c7da2a7adadf21214938bb79c83ea12b473a4b6ee4ad4bf854e7715e13d1f", size = 114565, upload-time = "2023-12-01T16:22:51.518Z" },
]
[[package]]
name = "packaging"
version = "26.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" },
]
[[package]]
name = "pathspec"
version = "1.1.1"
@@ -179,6 +296,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" },
]
[[package]]
name = "platformdirs"
version = "4.9.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/9f/4a/0883b8e3802965322523f0b200ecf33d31f10991d0401162f4b23c698b42/platformdirs-4.9.6.tar.gz", hash = "sha256:3bfa75b0ad0db84096ae777218481852c0ebc6c727b3168c1b9e0118e458cf0a", size = 29400, upload-time = "2026-04-09T00:04:10.812Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/75/a6/a0a304dc33b49145b21f4808d763822111e67d1c3a32b524a1baf947b6e1/platformdirs-4.9.6-py3-none-any.whl", hash = "sha256:e61adb1d5e5cb3441b4b7710bea7e4c12250ca49439228cc1021c00dcfac0917", size = 21348, upload-time = "2026-04-09T00:04:09.463Z" },
]
[[package]]
name = "python-docx"
version = "1.2.0"
@@ -192,6 +318,61 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/d0/00/1e03a4989fa5795da308cd774f05b704ace555a70f9bf9d3be057b680bcf/python_docx-1.2.0-py3-none-any.whl", hash = "sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7", size = 252987, upload-time = "2025-06-16T20:46:22.506Z" },
]
[[package]]
name = "pytokens"
version = "0.4.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/b6/34/b4e015b99031667a7b960f888889c5bd34ef585c85e1cb56a594b92836ac/pytokens-0.4.1.tar.gz", hash = "sha256:292052fe80923aae2260c073f822ceba21f3872ced9a68bb7953b348e561179a", size = 23015, upload-time = "2026-01-30T01:03:45.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/8f/a7/b470f672e6fc5fee0a01d9e75005a0e617e162381974213a945fcd274843/pytokens-0.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4a14d5f5fc78ce85e426aa159489e2d5961acf0e47575e08f35584009178e321", size = 160821, upload-time = "2026-01-30T01:03:19.684Z" },
{ url = "https://files.pythonhosted.org/packages/80/98/e83a36fe8d170c911f864bfded690d2542bfcfacb9c649d11a9e6eb9dc41/pytokens-0.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:97f50fd18543be72da51dd505e2ed20d2228c74e0464e4262e4899797803d7fa", size = 254263, upload-time = "2026-01-30T01:03:20.834Z" },
{ url = "https://files.pythonhosted.org/packages/0f/95/70d7041273890f9f97a24234c00b746e8da86df462620194cef1d411ddeb/pytokens-0.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:dc74c035f9bfca0255c1af77ddd2d6ae8419012805453e4b0e7513e17904545d", size = 268071, upload-time = "2026-01-30T01:03:21.888Z" },
{ url = "https://files.pythonhosted.org/packages/da/79/76e6d09ae19c99404656d7db9c35dfd20f2086f3eb6ecb496b5b31163bad/pytokens-0.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:f66a6bbe741bd431f6d741e617e0f39ec7257ca1f89089593479347cc4d13324", size = 271716, upload-time = "2026-01-30T01:03:23.633Z" },
{ url = "https://files.pythonhosted.org/packages/79/37/482e55fa1602e0a7ff012661d8c946bafdc05e480ea5a32f4f7e336d4aa9/pytokens-0.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:b35d7e5ad269804f6697727702da3c517bb8a5228afa450ab0fa787732055fc9", size = 104539, upload-time = "2026-01-30T01:03:24.788Z" },
{ url = "https://files.pythonhosted.org/packages/30/e8/20e7db907c23f3d63b0be3b8a4fd1927f6da2395f5bcc7f72242bb963dfe/pytokens-0.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:8fcb9ba3709ff77e77f1c7022ff11d13553f3c30299a9fe246a166903e9091eb", size = 168474, upload-time = "2026-01-30T01:03:26.428Z" },
{ url = "https://files.pythonhosted.org/packages/d6/81/88a95ee9fafdd8f5f3452107748fd04c24930d500b9aba9738f3ade642cc/pytokens-0.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79fc6b8699564e1f9b521582c35435f1bd32dd06822322ec44afdeba666d8cb3", size = 290473, upload-time = "2026-01-30T01:03:27.415Z" },
{ url = "https://files.pythonhosted.org/packages/cf/35/3aa899645e29b6375b4aed9f8d21df219e7c958c4c186b465e42ee0a06bf/pytokens-0.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d31b97b3de0f61571a124a00ffe9a81fb9939146c122c11060725bd5aea79975", size = 303485, upload-time = "2026-01-30T01:03:28.558Z" },
{ url = "https://files.pythonhosted.org/packages/52/a0/07907b6ff512674d9b201859f7d212298c44933633c946703a20c25e9d81/pytokens-0.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:967cf6e3fd4adf7de8fc73cd3043754ae79c36475c1c11d514fc72cf5490094a", size = 306698, upload-time = "2026-01-30T01:03:29.653Z" },
{ url = "https://files.pythonhosted.org/packages/39/2a/cbbf9250020a4a8dd53ba83a46c097b69e5eb49dd14e708f496f548c6612/pytokens-0.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:584c80c24b078eec1e227079d56dc22ff755e0ba8654d8383b2c549107528918", size = 116287, upload-time = "2026-01-30T01:03:30.912Z" },
{ url = "https://files.pythonhosted.org/packages/c6/78/397db326746f0a342855b81216ae1f0a32965deccfd7c830a2dbc66d2483/pytokens-0.4.1-py3-none-any.whl", hash = "sha256:26cef14744a8385f35d0e095dc8b3a7583f6c953c2e3d269c7f82484bf5ad2de", size = 13729, upload-time = "2026-01-30T01:03:45.029Z" },
]
[[package]]
name = "pywin32"
version = "311"
source = { registry = "https://pypi.org/simple" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c9/31/097f2e132c4f16d99a22bfb777e0fd88bd8e1c634304e102f313af69ace5/pywin32-311-cp314-cp314-win32.whl", hash = "sha256:b7a2c10b93f8986666d0c803ee19b5990885872a7de910fc460f9b0c2fbf92ee", size = 8840714, upload-time = "2025-07-14T20:13:32.449Z" },
{ url = "https://files.pythonhosted.org/packages/90/4b/07c77d8ba0e01349358082713400435347df8426208171ce297da32c313d/pywin32-311-cp314-cp314-win_amd64.whl", hash = "sha256:3aca44c046bd2ed8c90de9cb8427f581c479e594e99b5c0bb19b29c10fd6cb87", size = 9656800, upload-time = "2025-07-14T20:13:34.312Z" },
{ url = "https://files.pythonhosted.org/packages/c0/d2/21af5c535501a7233e734b8af901574572da66fcc254cb35d0609c9080dd/pywin32-311-cp314-cp314-win_arm64.whl", hash = "sha256:a508e2d9025764a8270f93111a970e1d0fbfc33f4153b388bb649b7eec4f9b42", size = 8932540, upload-time = "2025-07-14T20:13:36.379Z" },
]
[[package]]
name = "pyyaml"
version = "6.0.3"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
]
[[package]]
name = "ruff"
version = "0.15.12"
@@ -222,25 +403,35 @@ name = "transit"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "docxtpl" },
{ name = "mistune" },
{ name = "python-docx" },
{ name = "pyyaml" },
]
[package.dev-dependencies]
dev = [
{ name = "black" },
{ name = "mypy" },
{ name = "olefile" },
{ name = "pywin32" },
{ name = "ruff" },
]
[package.metadata]
requires-dist = [
{ name = "docxtpl", specifier = ">=0.20.2" },
{ name = "mistune", specifier = ">=3.2.1" },
{ name = "python-docx", specifier = ">=1.2.0" },
{ name = "pyyaml", specifier = ">=6.0.3" },
]
[package.metadata.requires-dev]
dev = [
{ name = "black", specifier = ">=26.3.1" },
{ name = "mypy", specifier = ">=2.0.0" },
{ name = "olefile", specifier = ">=0.47" },
{ name = "pywin32", specifier = ">=311" },
{ name = "ruff", specifier = ">=0.15.12" },
]