diff --git a/.claudeignore b/.claudeignore
new file mode 100644
index 0000000..7b6ea38
--- /dev/null
+++ b/.claudeignore
@@ -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
diff --git a/.gitignore b/.gitignore
index 505a3b1..ccd5df9 100644
--- a/.gitignore
+++ b/.gitignore
@@ -8,3 +8,15 @@ wheels/
# Virtual environments
.venv
+
+.mypy_cache/
+.ruff_cache/
+
+.claude/
+
+*.md
+!README.md
+
+*.docx
+*.doc
+*.txt
diff --git a/README.md b/README.md
index e69de29..887931d 100644
--- a/README.md
+++ b/README.md
@@ -0,0 +1,4 @@
+使用方法
+```shell
+py .\test.py .\毕业论文初稿.md
+```
diff --git a/docx_thesis/__init__.py b/docx_thesis/__init__.py
deleted file mode 100644
index 46a12e3..0000000
--- a/docx_thesis/__init__.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from .config import ThesisFormat
-from .converter import ThesisConverter
-
-__all__ = ["ThesisFormat", "ThesisConverter"]
diff --git a/docx_thesis/cli.py b/docx_thesis/cli.py
deleted file mode 100644
index 3c09539..0000000
--- a/docx_thesis/cli.py
+++ /dev/null
@@ -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()
diff --git a/docx_thesis/config.py b/docx_thesis/config.py
deleted file mode 100644
index 82c465b..0000000
--- a/docx_thesis/config.py
+++ /dev/null
@@ -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:"
diff --git a/docx_thesis/converter.py b/docx_thesis/converter.py
deleted file mode 100644
index 0d8f014..0000000
--- a/docx_thesis/converter.py
+++ /dev/null
@@ -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'')
- 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'')
- 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''))
- r2 = p.add_run()
- r2._element.append(parse_xml(
- f' PAGE '))
- r3 = p.add_run()
- r3._element.append(parse_xml(
- f''))
-
- _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'')
- 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'')
- 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'')
- 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'')
- 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'')
- 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'')
- 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'')
- 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'')
- 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''))
- r2 = p2.add_run()
- r2._element.append(parse_xml(
- f''
- ' TOC \\o "1-3" \\h \\z \\u '))
- r3 = p2.add_run()
- r3._element.append(parse_xml(
- f''))
- 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''))
-
- # ── 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'')
- 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")
diff --git a/main.py b/main.py
index 4a9a956..af21892 100644
--- a/main.py
+++ b/main.py
@@ -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()
diff --git a/pyproject.toml b/pyproject.toml
index a12b263..8309df6 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -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",
]
diff --git a/transit/__init__.py b/transit/__init__.py
new file mode 100644
index 0000000..0568cee
--- /dev/null
+++ b/transit/__init__.py
@@ -0,0 +1,15 @@
+"""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
+
+__all__ = [
+ "parse_markdown",
+ "body_to_paragraphs",
+ "replace_placeholder",
+ "generate_thesis",
+ "load_config",
+ "ThesisConfig",
+]
diff --git a/transit/__main__.py b/transit/__main__.py
new file mode 100644
index 0000000..c45b2e6
--- /dev/null
+++ b/transit/__main__.py
@@ -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()
diff --git a/transit/body.py b/transit/body.py
new file mode 100644
index 0000000..8d7caeb
--- /dev/null
+++ b/transit/body.py
@@ -0,0 +1,87 @@
+"""
+Markdown 正文 → Word 段落转换。
+
+将正文 Markdown 按标题层级拆分为带样式的段落序列,
+再注入到渲染后 docx 文档的占位符位置。
+"""
+
+import re
+from docx import Document
+
+_PAT_HEADING = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
+
+
+def body_to_paragraphs(md_text: str) -> list[dict]:
+ """将 Markdown 正文按标题和段落拆分为结构化列表。
+
+ 返回的每个元素::
+ {"text": str, "level": int, "style": str}
+ 其中 ``style`` 为 ``Heading N`` 或 ``Normal``。
+ """
+ paragraphs: list[dict] = []
+ last_end = 0
+
+ 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):
+ block = block.strip()
+ if block:
+ paragraphs.append(
+ {"text": block, "level": 0, "style": "Normal"}
+ )
+
+ level = len(m.group(1))
+ 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):
+ block = block.strip()
+ if block:
+ paragraphs.append(
+ {"text": block, "level": 0, "style": "Normal"}
+ )
+
+ return paragraphs
+
+
+def replace_placeholder(doc: Document, placeholder: str, paragraphs: list[dict]):
+ """在 *doc* 中找到包含 *placeholder* 的段落,替换为 *paragraphs* 列表。
+
+ 每个段落的 ``style`` 字段会从文档样式中查找并应用。
+ """
+ placeholder_found = False
+ for para in doc.paragraphs:
+ if placeholder in para.text:
+ placeholder_found = True
+ parent = para._element.getparent()
+ idx = list(parent).index(para._element)
+ parent.remove(para._element)
+
+ for pd_data in reversed(paragraphs):
+ new_p = doc.add_paragraph(pd_data["text"])
+ style_name = pd_data["style"]
+ try:
+ new_p.style = doc.styles[style_name]
+ except KeyError:
+ matched = False
+ for s in doc.styles:
+ if s.name.lower() == style_name.lower():
+ new_p.style = s
+ matched = True
+ break
+ if not matched:
+ new_p.style = doc.styles["Normal"]
+ parent.insert(idx, new_p._element)
+ break
+
+ if not placeholder_found:
+ print(f"警告:未找到占位符 '{placeholder}',正文段落未注入。")
diff --git a/transit/config.py b/transit/config.py
new file mode 100644
index 0000000..aa04052
--- /dev/null
+++ b/transit/config.py
@@ -0,0 +1,63 @@
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Optional
+import tomllib
+
+
+@dataclass
+class ThesisConfig:
+ """论文配置数据(学生信息、元数据等,不包含正文内容)。"""
+
+ student_name: str = ""
+ student_id: str = ""
+ college: str = ""
+ major: str = ""
+ class_: str = ""
+ advisor: str = ""
+ advisor_title: str = ""
+ title: str = ""
+
+ title_from_md: bool = True
+ body_start_keywords: list[str] = field(default_factory=lambda: ["绪论", "引言"])
+ body_end_keywords: list[str] = field(
+ default_factory=lambda: ["致谢", "参考文献", "附录"]
+ )
+
+ def to_dict(self) -> dict:
+ """转成模板渲染用的扁平字典,排除 options 命名空间。"""
+ return {
+ "student_name": self.student_name,
+ "student_id": self.student_id,
+ "college": self.college,
+ "major": self.major,
+ "class": self.class_,
+ "advisor": self.advisor,
+ "advisor_title": self.advisor_title,
+ "title": self.title,
+ }
+
+
+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(
+ student_name=meta.get("student_name", ""),
+ student_id=meta.get("student_id", ""),
+ college=meta.get("college", ""),
+ major=meta.get("major", ""),
+ class_=meta.get("class", ""),
+ advisor=meta.get("advisor", ""),
+ advisor_title=meta.get("advisor_title", ""),
+ title=meta.get("title", ""),
+ 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", ["致谢", "参考文献", "附录"]
+ ),
+ )
diff --git a/transit/parser.py b/transit/parser.py
new file mode 100644
index 0000000..94a9531
--- /dev/null
+++ b/transit/parser.py
@@ -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"] = content[ack[0] : ack[1]].strip()
+
+ # ── 参考文献 ──
+ ref = _find_section(content, ["参考文献"])
+ if ref:
+ data["reference"] = content[ref[0] : ref[1]].strip()
+
+ # ── 附录 ──
+ app = _find_section(content, ["附录"])
+ if app:
+ data["appendix"] = content[app[0] : app[1]].strip()
+
+ return data
diff --git a/transit/renderer.py b/transit/renderer.py
new file mode 100644
index 0000000..156a0c3
--- /dev/null
+++ b/transit/renderer.py
@@ -0,0 +1,132 @@
+"""
+论文生成编排器。
+
+组装 配置 + 解析 + 模板渲染 + 正文注入 的完整流水线。
+"""
+
+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
+
+
+_TEXT_FIELDS = [
+ "title",
+ "abstact_cn_context",
+ "abstract_cn_keywords",
+ "abstract_en_context",
+ "abstract_en_keywords",
+ "acknowledgement",
+ "reference",
+ "appendix",
+ "student_name",
+ "student_id",
+ "college",
+ "major",
+ "class",
+ "advisor",
+ "advisor_title",
+]
+
+
+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 标题为准
+ if v != "":
+ context[k] = v
+
+ # 4. 用 defaultdict 兜底缺失键
+ ctx = defaultdict(lambda: "", context)
+
+ # 5. 解析正文为段落列表
+ body_md = ctx.get("body_md", "")
+ body_paragraphs = body_to_paragraphs(body_md) if body_md else []
+
+ # 6. 占位符
+ ctx["body_placeholder"] = "__CONTEXT_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)
+ final_doc.save(str(output_path))
+ temp_path.unlink(missing_ok=True)
+
+ print(f"[完成] 论文生成完成: {output_path}")
+
+ # 10. 字段填充报告
+ print("\n--- 字段填充情况 ---")
+ for key in _TEXT_FIELDS:
+ val = ctx[key]
+ if val == "":
+ print(f" [缺失] {key}")
+ else:
+ preview = str(val)[:60].replace("\n", " ")
+ print(f" [OK] {key}: {preview}...")
+
+ missing = [k for k in _TEXT_FIELDS if ctx[k] == ""]
+ if missing:
+ print("\n[警告] 以下字段缺失,已填充 '':")
+ for f in missing:
+ print(f" - {f}")
+
+ return dict(ctx)
diff --git a/uv.lock b/uv.lock
index 555326a..501ee8f 100644
--- a/uv.lock
+++ b/uv.lock
@@ -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" },
]