From ae70d056727072d218d82c8f5ef570cba7eb1adc Mon Sep 17 00:00:00 2001 From: zzy <2450266535@qq.com> Date: Fri, 8 May 2026 21:06:01 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E9=87=8D=E6=9E=84=E9=A1=B9?= =?UTF-8?q?=E7=9B=AE=E7=BB=93=E6=9E=84=E5=B9=B6=E6=9B=B4=E6=96=B0=E4=BE=9D?= =?UTF-8?q?=E8=B5=96=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 移除原有的 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 为快速入口脚本 --- .claudeignore | 17 + .gitignore | 12 + README.md | 4 + docx_thesis/__init__.py | 4 - docx_thesis/cli.py | 43 --- docx_thesis/config.py | 90 ----- docx_thesis/converter.py | 795 --------------------------------------- main.py | 13 +- pyproject.toml | 7 +- transit/__init__.py | 15 + transit/__main__.py | 34 ++ transit/body.py | 87 +++++ transit/config.py | 63 ++++ transit/parser.py | 131 +++++++ transit/renderer.py | 132 +++++++ uv.lock | 191 ++++++++++ 16 files changed, 697 insertions(+), 941 deletions(-) create mode 100644 .claudeignore delete mode 100644 docx_thesis/__init__.py delete mode 100644 docx_thesis/cli.py delete mode 100644 docx_thesis/config.py delete mode 100644 docx_thesis/converter.py create mode 100644 transit/__init__.py create mode 100644 transit/__main__.py create mode 100644 transit/body.py create mode 100644 transit/config.py create mode 100644 transit/parser.py create mode 100644 transit/renderer.py 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" }, ]