Compare commits
6 Commits
5cbc1d9b76
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 4e39a4f2ac | |||
| 0b10e97e0c | |||
| fc6afdea9d | |||
| 74d28ea2d8 | |||
| c29a3e6af0 | |||
| ae70d05672 |
17
.claudeignore
Normal file
17
.claudeignore
Normal file
@@ -0,0 +1,17 @@
|
||||
# Python-generated files
|
||||
__pycache__/
|
||||
*.py[oc]
|
||||
build/
|
||||
dist/
|
||||
wheels/
|
||||
*.egg-info
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
*.docx
|
||||
*.doc
|
||||
*.txt
|
||||
16
.gitignore
vendored
16
.gitignore
vendored
@@ -8,3 +8,19 @@ wheels/
|
||||
|
||||
# Virtual environments
|
||||
.venv
|
||||
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
|
||||
.claude/
|
||||
|
||||
*.md
|
||||
!README.md
|
||||
|
||||
*.docx
|
||||
*.doc
|
||||
*.txt
|
||||
|
||||
.vscode/
|
||||
.tmp/
|
||||
*.toml
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
使用方法
|
||||
```shell
|
||||
# 需要 sample.docx 文件 且 该文件有 {{xxx}} 模板引擎的内容
|
||||
python .\test.py .\毕业论文初稿.md
|
||||
```
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
from .config import ThesisFormat
|
||||
from .converter import ThesisConverter
|
||||
|
||||
__all__ = ["ThesisFormat", "ThesisConverter"]
|
||||
@@ -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()
|
||||
@@ -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:"
|
||||
@@ -1,795 +0,0 @@
|
||||
"""Convert Markdown graduation thesis → formatted Word .docx.
|
||||
|
||||
Parses markdown line-by-line and writes a python-docx document that
|
||||
complies with 桂林理工大学 理工类毕业设计(论文)格式要求.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
|
||||
from docx.oxml import parse_xml
|
||||
from docx.oxml.ns import nsdecls, qn
|
||||
from docx.shared import Cm, Pt, RGBColor
|
||||
from docx.text.paragraph import Paragraph
|
||||
from docx.text.run import Run
|
||||
|
||||
from .config import ThesisFormat
|
||||
|
||||
|
||||
# ── font helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _set_font(
|
||||
run: Run,
|
||||
cn_font: str,
|
||||
en_font: str | None = None,
|
||||
size: float | None = None,
|
||||
bold: bool | None = None,
|
||||
italic: bool | None = None,
|
||||
):
|
||||
if en_font:
|
||||
run.font.name = en_font
|
||||
if cn_font:
|
||||
rpr = run._element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
|
||||
rpr.insert(0, rfonts)
|
||||
rfonts.set(qn("w:eastAsia"), cn_font)
|
||||
if size is not None:
|
||||
run.font.size = Pt(size)
|
||||
if bold is not None:
|
||||
run.font.bold = bold
|
||||
if italic is not None:
|
||||
run.font.italic = italic
|
||||
|
||||
|
||||
def _set_spacing(p: Paragraph, before: int = 0, after: int = 0,
|
||||
line_spacing: float = 1.0):
|
||||
pf = p.paragraph_format
|
||||
pf.space_before = Pt(before)
|
||||
pf.space_after = Pt(after)
|
||||
pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
||||
pf.line_spacing = line_spacing
|
||||
|
||||
|
||||
def _set_indent(p: Paragraph, chars: int = 2):
|
||||
if chars > 0:
|
||||
p.paragraph_format.first_line_indent = Cm(chars * 0.37)
|
||||
|
||||
|
||||
def _set_page_number_fmt(section, fmt: str):
|
||||
sect_pr = section._sectPr
|
||||
el = sect_pr.find(qn("w:pgNumType"))
|
||||
if el is None:
|
||||
el = parse_xml(f'<w:pgNumType {nsdecls("w")}/>')
|
||||
sect_pr.append(el)
|
||||
el.set(qn("w:fmt"), fmt)
|
||||
|
||||
|
||||
def _setup_footer(section, roman: bool):
|
||||
footer = section.footer
|
||||
footer.is_linked_to_previous = False
|
||||
|
||||
# clear default empty paragraph runs to avoid extra blank line
|
||||
for p in footer.paragraphs:
|
||||
for r in p.runs:
|
||||
r.text = ""
|
||||
|
||||
p = footer.paragraphs[0]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
p.paragraph_format.space_before = Pt(0)
|
||||
p.paragraph_format.space_after = Pt(0)
|
||||
|
||||
r = p.add_run()
|
||||
_set_font(r, "宋体", "Times New Roman", size=9)
|
||||
r._element.append(parse_xml(
|
||||
f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>'))
|
||||
r2 = p.add_run()
|
||||
r2._element.append(parse_xml(
|
||||
f'<w:instrText {nsdecls("w")} xml:space="preserve"> PAGE </w:instrText>'))
|
||||
r3 = p.add_run()
|
||||
r3._element.append(parse_xml(
|
||||
f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'))
|
||||
|
||||
_set_page_number_fmt(section, "lowerRoman" if roman else "decimal")
|
||||
|
||||
|
||||
# ── inline markdown parser ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_inline(text: str):
|
||||
"""Tokenise line → list of (text, attrs) tuples."""
|
||||
tokens: list[tuple[str, dict]] = []
|
||||
buf = ""
|
||||
i = 0
|
||||
n = len(text)
|
||||
|
||||
def flush():
|
||||
nonlocal buf
|
||||
if buf:
|
||||
tokens.append((buf, {}))
|
||||
buf = ""
|
||||
|
||||
while i < n:
|
||||
ch = text[i]
|
||||
# `code`
|
||||
if ch == "`":
|
||||
flush()
|
||||
j = text.find("`", i + 1)
|
||||
if j == -1:
|
||||
buf += ch
|
||||
i += 1
|
||||
continue
|
||||
tokens.append((text[i + 1:j], {"code": True}))
|
||||
i = j + 1
|
||||
continue
|
||||
# **bold**
|
||||
if text[i:i + 2] == "**":
|
||||
flush()
|
||||
j = text.find("**", i + 2)
|
||||
if j == -1:
|
||||
buf += ch
|
||||
i += 1
|
||||
continue
|
||||
inner = text[i + 2:j]
|
||||
sub = _parse_inline(inner)
|
||||
for t, a in sub:
|
||||
a["bold"] = True
|
||||
tokens.append((t, a))
|
||||
i = j + 2
|
||||
continue
|
||||
# *italic* (single star, not **)
|
||||
if ch == "*" and i + 1 < n and text[i + 1] != "*":
|
||||
flush()
|
||||
j = text.find("*", i + 1)
|
||||
if j == -1:
|
||||
buf += ch
|
||||
i += 1
|
||||
continue
|
||||
tokens.append((text[i + 1:j], {"italic": True}))
|
||||
i = j + 1
|
||||
continue
|
||||
buf += ch
|
||||
i += 1
|
||||
flush()
|
||||
return tokens
|
||||
|
||||
|
||||
def _add_inline(p: Paragraph, tokens: list, cfg: ThesisFormat,
|
||||
size: float | None = None, bold: bool = False):
|
||||
for text, attrs in tokens:
|
||||
run = p.add_run(text)
|
||||
b = bold or attrs.get("bold", False)
|
||||
it = attrs.get("italic", False)
|
||||
code = attrs.get("code", False)
|
||||
cn = cfg.font_code if code else cfg.font_cn
|
||||
en = cfg.font_code if code else cfg.font_en
|
||||
_set_font(run, cn, en, size=size or cfg.size_body,
|
||||
bold=b, italic=it if not b else None)
|
||||
|
||||
|
||||
# ── block-level parser ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_blocks(text: str):
|
||||
lines = text.split("\n")
|
||||
blocks: list[dict] = []
|
||||
i, n = 0, len(lines)
|
||||
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
|
||||
# thematic break
|
||||
if line.strip() == "---":
|
||||
blocks.append({"type": "thematic_break"})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# fenced code block
|
||||
if line.strip().startswith("```") or line.strip().startswith("~~~"):
|
||||
fence = line.strip()[:3]
|
||||
info = line.strip()[3:].strip()
|
||||
code_lines: list[str] = []
|
||||
i += 1
|
||||
while i < n and not lines[i].strip().startswith(fence):
|
||||
code_lines.append(lines[i])
|
||||
i += 1
|
||||
i += 1
|
||||
blocks.append({"type": "block_code", "info": info,
|
||||
"raw": "\n".join(code_lines)})
|
||||
continue
|
||||
|
||||
# heading
|
||||
m = re.match(r"^(#{1,6})\s+(.+)$", line)
|
||||
if m:
|
||||
blocks.append({"type": "heading",
|
||||
"level": len(m.group(1)),
|
||||
"text": m.group(2).strip()})
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# blockquote
|
||||
if line.strip().startswith(">"):
|
||||
ql: list[str] = []
|
||||
while i < n and (lines[i].strip().startswith(">")
|
||||
or lines[i].strip() == ""):
|
||||
ql.append(re.sub(r"^>\s?", "", lines[i]))
|
||||
i += 1
|
||||
blocks.append({"type": "block_quote",
|
||||
"text": "\n".join(ql).strip()})
|
||||
continue
|
||||
|
||||
# list
|
||||
if re.match(r"^(\s*)([-*+]\s|\d+\.\s)", line):
|
||||
items: list[str] = []
|
||||
while i < n:
|
||||
if re.match(r"^(\s*)([-*+]\s|\d+\.\s)", lines[i]):
|
||||
t = re.sub(r"^(\s*)[-*+]\s|\d+\.\s", "", lines[i], 1)
|
||||
items.append(t)
|
||||
i += 1
|
||||
while i < n and lines[i].strip() \
|
||||
and not re.match(r"^(\s*)([-*+]\s|\d+\.\s)",
|
||||
lines[i]):
|
||||
if lines[i][0] in " \t":
|
||||
items[-1] += " " + lines[i].strip()
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
elif lines[i].strip() == "":
|
||||
i += 1
|
||||
else:
|
||||
break
|
||||
blocks.append({"type": "list", "items": items})
|
||||
continue
|
||||
|
||||
# blank
|
||||
if line.strip() == "":
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# paragraph (accumulate)
|
||||
para: list[str] = []
|
||||
while i < n and lines[i].strip():
|
||||
para.append(lines[i])
|
||||
i += 1
|
||||
t = "\n".join(para).strip()
|
||||
if t:
|
||||
blocks.append({"type": "paragraph", "text": t})
|
||||
|
||||
return blocks
|
||||
|
||||
|
||||
# ── converter ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class ThesisConverter:
|
||||
"""Markdown → 理工类毕业论文 Word 文档。
|
||||
|
||||
处理流程:
|
||||
1. 解析 MD → blocks
|
||||
2. 扫描 blocks 提取论文题目(H1)
|
||||
3. 按章节类别写入带正确格式的 Word
|
||||
4. 每章自动分页、页面网格、字体字号严格按学校要求
|
||||
"""
|
||||
|
||||
def __init__(self, config: ThesisFormat | None = None):
|
||||
self.config = config or ThesisFormat()
|
||||
self.doc = Document()
|
||||
self._thesis_title: str = "" # 论文题目(来自 H1)
|
||||
self._has_title = False # 是否已保存论文题目
|
||||
self._section_break_added = False # 是否插入过正文分节符
|
||||
|
||||
# ── public API ──────────────────────────────────────────────────
|
||||
|
||||
def convert(self, md_path: str | Path, docx_path: str | Path):
|
||||
text = Path(md_path).read_text(encoding="utf-8")
|
||||
text = self._strip_manual_toc(text)
|
||||
blocks = _parse_blocks(text)
|
||||
|
||||
# extract H1 thesis title
|
||||
for blk in blocks:
|
||||
if blk["type"] == "heading" and blk["level"] == 1:
|
||||
self._thesis_title = blk["text"]
|
||||
break
|
||||
|
||||
self._setup_document()
|
||||
self._process_blocks(blocks)
|
||||
self.doc.save(str(docx_path))
|
||||
|
||||
# ── strip manual TOC ────────────────────────────────────────────
|
||||
|
||||
@staticmethod
|
||||
def _strip_manual_toc(text: str) -> str:
|
||||
lines = text.split("\n")
|
||||
toc_start = -1
|
||||
sep_end = -1
|
||||
for i, line in enumerate(lines):
|
||||
if re.search(r"[目目]\s*[次次]", line) and line.startswith("#"):
|
||||
toc_start = i
|
||||
if toc_start >= 0 and line.strip() == "---" and i > toc_start:
|
||||
sep_end = i
|
||||
break
|
||||
if toc_start >= 0 and sep_end > toc_start:
|
||||
kept = lines[:toc_start + 1]
|
||||
kept.append("")
|
||||
kept.extend(lines[sep_end:])
|
||||
return "\n".join(kept)
|
||||
return text
|
||||
|
||||
# ── page setup ──────────────────────────────────────────────────
|
||||
|
||||
def _setup_document(self):
|
||||
cfg = self.config
|
||||
sec = self.doc.sections[0]
|
||||
self._apply_page_setup(sec, roman=True)
|
||||
|
||||
# default font
|
||||
styles = self.doc.styles
|
||||
normal = styles["Normal"]
|
||||
rpr = normal.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
|
||||
rpr.insert(0, rfonts)
|
||||
rfonts.set(qn("w:ascii"), cfg.font_en)
|
||||
rfonts.set(qn("w:hAnsi"), cfg.font_en)
|
||||
rfonts.set(qn("w:eastAsia"), cfg.font_cn)
|
||||
rfonts.set(qn("w:cs"), cfg.font_en)
|
||||
|
||||
sz = rpr.find(qn("w:sz"))
|
||||
if sz is None:
|
||||
sz = parse_xml(
|
||||
f'<w:sz {nsdecls("w")} w:val="{int(cfg.size_body * 2)}"/>')
|
||||
rpr.append(sz)
|
||||
pf = normal.paragraph_format
|
||||
pf.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
||||
pf.line_spacing = cfg.line_spacing_body
|
||||
|
||||
self._config_heading_styles()
|
||||
|
||||
_setup_footer(sec, roman=True)
|
||||
|
||||
def _config_heading_styles(self):
|
||||
"""Configure Heading 1/2/3 built-in styles to match thesis formatting.
|
||||
|
||||
This ensures Word's TOC field can detect headings and auto-generate
|
||||
the table of contents correctly.
|
||||
"""
|
||||
cfg = self.config
|
||||
styles = self.doc.styles
|
||||
|
||||
# ── Heading 1 = 章 (三号宋体加粗左) ──────────────────────────
|
||||
h1 = styles["Heading 1"]
|
||||
h1.font.name = cfg.font_heading_en
|
||||
rpr = h1.element.get_or_add_rPr()
|
||||
rfonts = rpr.find(qn("w:rFonts"))
|
||||
if rfonts is None:
|
||||
rfonts = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
|
||||
rpr.insert(0, rfonts)
|
||||
rfonts.set(qn("w:eastAsia"), cfg.font_cn_heading)
|
||||
h1.font.size = Pt(cfg.size_chapter)
|
||||
h1.font.bold = True
|
||||
h1.font.color.rgb = RGBColor(0, 0, 0)
|
||||
h1.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
h1.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
||||
h1.paragraph_format.line_spacing = cfg.line_spacing_heading
|
||||
h1.paragraph_format.space_before = Pt(0)
|
||||
h1.paragraph_format.space_after = Pt(0)
|
||||
# Keep with next + page break before
|
||||
pPr = h1.element.get_or_add_pPr()
|
||||
keep_next = parse_xml(f'<w:keepNext {nsdecls("w")}/>')
|
||||
pPr.append(keep_next)
|
||||
|
||||
# ── Heading 2 = 节 (小三号宋体加粗左) ────────────────────────
|
||||
h2 = styles["Heading 2"]
|
||||
h2.font.name = cfg.font_heading_en
|
||||
rpr2 = h2.element.get_or_add_rPr()
|
||||
rfonts2 = rpr2.find(qn("w:rFonts"))
|
||||
if rfonts2 is None:
|
||||
rfonts2 = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
|
||||
rpr2.insert(0, rfonts2)
|
||||
rfonts2.set(qn("w:eastAsia"), cfg.font_cn_heading)
|
||||
h2.font.size = Pt(cfg.size_section)
|
||||
h2.font.bold = True
|
||||
h2.font.color.rgb = RGBColor(0, 0, 0)
|
||||
h2.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
h2.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
||||
h2.paragraph_format.line_spacing = cfg.line_spacing_heading
|
||||
h2.paragraph_format.space_before = Pt(0)
|
||||
h2.paragraph_format.space_after = Pt(0)
|
||||
|
||||
# ── Heading 3 = 条 (四号宋体加粗左) ──────────────────────────
|
||||
h3 = styles["Heading 3"]
|
||||
h3.font.name = cfg.font_heading_en
|
||||
rpr3 = h3.element.get_or_add_rPr()
|
||||
rfonts3 = rpr3.find(qn("w:rFonts"))
|
||||
if rfonts3 is None:
|
||||
rfonts3 = parse_xml(f'<w:rFonts {nsdecls("w")}/>')
|
||||
rpr3.insert(0, rfonts3)
|
||||
rfonts3.set(qn("w:eastAsia"), cfg.font_cn_heading)
|
||||
h3.font.size = Pt(cfg.size_subsection)
|
||||
h3.font.bold = True
|
||||
h3.font.color.rgb = RGBColor(0, 0, 0)
|
||||
h3.paragraph_format.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
h3.paragraph_format.line_spacing_rule = WD_LINE_SPACING.MULTIPLE
|
||||
h3.paragraph_format.line_spacing = cfg.line_spacing_heading
|
||||
h3.paragraph_format.space_before = Pt(0)
|
||||
h3.paragraph_format.space_after = Pt(0)
|
||||
|
||||
def _add_section_break_main(self):
|
||||
sec = self.doc.add_section()
|
||||
self._apply_page_setup(sec, roman=False)
|
||||
self._section_break_added = True
|
||||
|
||||
def _apply_page_setup(self, sec, roman: bool = True):
|
||||
"""Apply margins, grid, and footer to a section."""
|
||||
cfg = self.config
|
||||
sec.page_width = Cm(cfg.page_width)
|
||||
sec.page_height = Cm(cfg.page_height)
|
||||
|
||||
sect_pr = sec._sectPr
|
||||
for el in list(sect_pr):
|
||||
if el.tag in (qn("w:pgMar"), qn("w:docGrid")):
|
||||
sect_pr.remove(el)
|
||||
|
||||
pgMar = parse_xml(
|
||||
f'<w:pgMar {nsdecls("w")} '
|
||||
f'w:top="{int(cfg.margin_top * 567)}" '
|
||||
f'w:bottom="{int(cfg.margin_bottom * 567)}" '
|
||||
f'w:left="{int(cfg.margin_left * 567)}" '
|
||||
f'w:right="{int(cfg.margin_right * 567)}" '
|
||||
f'w:header="0" '
|
||||
f'w:footer="{int(cfg.footer_distance * 567)}"/>')
|
||||
sect_pr.append(pgMar)
|
||||
|
||||
text_height_mm = (cfg.page_height - cfg.margin_top
|
||||
- cfg.margin_bottom) * 10
|
||||
line_pitch = int(text_height_mm / cfg.grid_lines_per_page * 56.7)
|
||||
text_width_mm = (cfg.page_width - cfg.margin_left
|
||||
- cfg.margin_right) * 10
|
||||
char_pitch = int(text_width_mm / cfg.grid_chars_per_line * 56.7)
|
||||
dg = parse_xml(
|
||||
f'<w:docGrid {nsdecls("w")} '
|
||||
f'w:type="linesAndChars" '
|
||||
f'w:linePitch="{line_pitch}" '
|
||||
f'w:charSpace="{char_pitch}"/>')
|
||||
sect_pr.append(dg)
|
||||
|
||||
_setup_footer(sec, roman=roman)
|
||||
|
||||
# ── block processing ────────────────────────────────────────────
|
||||
|
||||
def _process_blocks(self, blocks):
|
||||
# State machine:
|
||||
# before_abstract → abstract_cn → abstract_en → toc → main
|
||||
state = "before_abstract"
|
||||
self._seen_first_chapter = False
|
||||
|
||||
for blk in blocks:
|
||||
t = blk["type"]
|
||||
|
||||
if t == "heading" and blk["level"] == 1:
|
||||
# Skip H1 (thesis title) — not rendered on Chinese abstract
|
||||
continue
|
||||
|
||||
if t == "heading" and blk["level"] == 2:
|
||||
txt = blk["text"].strip()
|
||||
if txt.replace(" ", "") == "摘 要".replace(" ", ""):
|
||||
state = "abstract_cn"
|
||||
self._add_abstract_title("摘 要")
|
||||
continue
|
||||
if txt == "Abstract":
|
||||
self._add_abstract_title_en()
|
||||
state = "abstract_en"
|
||||
continue
|
||||
if "目" in txt and "次" in txt:
|
||||
state = "toc"
|
||||
self._add_toc("目 次")
|
||||
continue
|
||||
# Normal chapter
|
||||
if state in ("before_abstract", "abstract_cn", "abstract_en", "toc"):
|
||||
self._add_section_break_main()
|
||||
state = "main"
|
||||
self._add_page_break_if_not_first()
|
||||
self._add_chapter(txt)
|
||||
continue
|
||||
|
||||
if t == "heading" and blk["level"] == 3:
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
txt = blk["text"].strip()
|
||||
if re.match(r"^\d+\.\d+\.\d+\s", txt):
|
||||
self._add_subsection(txt)
|
||||
else:
|
||||
self._add_section(txt)
|
||||
continue
|
||||
|
||||
if t == "heading" and blk["level"] >= 4:
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
# headings below 3 → body-style bold
|
||||
self._add_body_para(blk["text"], bold=True, indent=False)
|
||||
continue
|
||||
|
||||
# paragraphs / code / blockquote / list / thematic_break
|
||||
|
||||
if t == "paragraph":
|
||||
txt = blk["text"]
|
||||
if not txt.strip():
|
||||
continue
|
||||
if state == "abstract_cn":
|
||||
if txt.startswith("关键词:"):
|
||||
self._add_keywords(txt, cn=True)
|
||||
else:
|
||||
self._add_abstract_body(txt)
|
||||
continue
|
||||
if state == "abstract_en":
|
||||
if txt.startswith("Key words:"):
|
||||
self._add_keywords(txt, cn=False)
|
||||
else:
|
||||
self._add_abstract_body(txt) # 英文摘要正文
|
||||
continue
|
||||
# Normal body
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
if txt.startswith("关键词:"):
|
||||
self._add_keywords(txt, cn=True)
|
||||
elif txt.startswith("Key words:"):
|
||||
self._add_keywords(txt, cn=False)
|
||||
else:
|
||||
self._add_body_para(txt)
|
||||
continue
|
||||
|
||||
if t == "block_code":
|
||||
# code can appear in abstract or main — skip abstract code
|
||||
if state in ("abstract_cn", "abstract_en", "toc"):
|
||||
continue
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
self._process_code(blk)
|
||||
continue
|
||||
|
||||
if t == "block_quote":
|
||||
txt = blk.get("text", "").strip()
|
||||
if not txt:
|
||||
continue
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
self._add_body_para(txt)
|
||||
continue
|
||||
|
||||
if t == "list":
|
||||
self._ensure_main_section(state)
|
||||
state = "main"
|
||||
for item in blk.get("items", []):
|
||||
self._add_body_para("• " + item)
|
||||
continue
|
||||
|
||||
if t == "thematic_break":
|
||||
# In front matter or already processed — handled by state
|
||||
continue
|
||||
|
||||
def _ensure_main_section(self, state: str):
|
||||
if state in ("before_abstract", "abstract_cn", "abstract_en", "toc"):
|
||||
if not self._section_break_added:
|
||||
self._add_section_break_main()
|
||||
|
||||
def _add_page_break_if_not_first(self):
|
||||
if self._seen_first_chapter:
|
||||
self.doc.add_page_break()
|
||||
else:
|
||||
self._seen_first_chapter = True
|
||||
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
# rendering methods
|
||||
# ══════════════════════════════════════════════════════════════
|
||||
|
||||
# ── abstract ──────────────────────────────────────────────────
|
||||
|
||||
def _add_abstract_title(self, text: str):
|
||||
"""摘要题头:三号宋体加粗居中 (3.3节)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 1"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
run = p.add_run(text)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_abstract_title, bold=True)
|
||||
# blank line after title (§3.3)
|
||||
self.doc.add_paragraph()
|
||||
|
||||
def _add_abstract_body(self, text: str):
|
||||
"""摘要正文:小四宋体,首行缩进2字符"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_body)
|
||||
_set_indent(p, cfg.first_line_indent_chars)
|
||||
tokens = _parse_inline(text)
|
||||
_add_inline(p, tokens, cfg)
|
||||
|
||||
def _add_abstract_title_en(self):
|
||||
"""英文摘要页:标题+论文题目+作者署名 (2.3节)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 1"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
run = p.add_run("Abstract")
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_abstract_title, bold=True)
|
||||
self.doc.add_paragraph()
|
||||
|
||||
# Thesis title in English (centered)
|
||||
if self._thesis_title:
|
||||
# crude English translation placeholder — user should replace
|
||||
p2 = self.doc.add_paragraph()
|
||||
p2.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
_set_spacing(p2, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
r = p2.add_run(self._thesis_title)
|
||||
_set_font(r, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_section, bold=True)
|
||||
|
||||
# Author & teacher line
|
||||
p3 = self.doc.add_paragraph()
|
||||
p3.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
_set_spacing(p3, before=6, after=6,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
r = p3.add_run("Student: \tTeacher: ")
|
||||
_set_font(r, cfg.font_cn, cfg.font_en, size=cfg.size_body)
|
||||
|
||||
# ── keywords ──────────────────────────────────────────────────
|
||||
|
||||
def _add_keywords(self, text: str, cn: bool):
|
||||
"""关键词:小四宋体加粗顶格 (3.3节)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_body)
|
||||
|
||||
label = cfg.keywords_label_cn if cn else cfg.keywords_label_en
|
||||
m = re.match(r"\*\*" + re.escape(label) + r"\*\*(.*)", text)
|
||||
if m:
|
||||
run = p.add_run(label)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_keyword_label, bold=True)
|
||||
rest = m.group(1).strip()
|
||||
tokens = _parse_inline(rest)
|
||||
_add_inline(p, tokens, cfg, size=cfg.size_keyword_label)
|
||||
else:
|
||||
tokens = _parse_inline(text)
|
||||
_add_inline(p, tokens, cfg, size=cfg.size_keyword_label)
|
||||
|
||||
# ── TOC ────────────────────────────────────────────────────────
|
||||
|
||||
def _add_toc(self, title: str):
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 1"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
run = p.add_run(title)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_abstract_title, bold=True)
|
||||
|
||||
self.doc.add_paragraph() # blank line
|
||||
|
||||
# Word TOC field
|
||||
p2 = self.doc.add_paragraph()
|
||||
_set_spacing(p2, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_body)
|
||||
r = p2.add_run()
|
||||
r._element.append(parse_xml(
|
||||
f'<w:fldChar {nsdecls("w")} w:fldCharType="begin"/>'))
|
||||
r2 = p2.add_run()
|
||||
r2._element.append(parse_xml(
|
||||
f'<w:instrText {nsdecls("w")} xml:space="preserve">'
|
||||
' TOC \\o "1-3" \\h \\z \\u </w:instrText>'))
|
||||
r3 = p2.add_run()
|
||||
r3._element.append(parse_xml(
|
||||
f'<w:fldChar {nsdecls("w")} w:fldCharType="separate"/>'))
|
||||
r4 = p2.add_run("(请右键此处 > 更新域)")
|
||||
_set_font(r4, cfg.font_cn, cfg.font_en, size=cfg.size_body)
|
||||
r5 = p2.add_run()
|
||||
r5._element.append(parse_xml(
|
||||
f'<w:fldChar {nsdecls("w")} w:fldCharType="end"/>'))
|
||||
|
||||
# ── chapter headings (第一层次) ──────────────────────────────
|
||||
|
||||
def _add_chapter(self, text: str):
|
||||
"""章标题:三号宋体加粗,顶格 (§3.2 表3)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 1"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
|
||||
# Ensure double space between number and title (§2.5.2 表1)
|
||||
formatted = re.sub(r"^(\d+)\s+", r"\1 ", text)
|
||||
run = p.add_run(formatted)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_chapter, bold=True)
|
||||
|
||||
# ── section heading (第二层次) ───────────────────────────────
|
||||
|
||||
def _add_section(self, text: str):
|
||||
"""节标题:小三号宋体加粗,顶格 (§3.2 表3)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 2"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
|
||||
# Single space between number and title (§2.5.2 表1)
|
||||
formatted = re.sub(r"^(\d+\.\d+)\s+", r"\1 ", text)
|
||||
run = p.add_run(formatted)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_section, bold=True)
|
||||
|
||||
# ── subsection heading (第三层次) ──────────────────────────
|
||||
|
||||
def _add_subsection(self, text: str):
|
||||
"""条标题:四号宋体加粗,顶格 (§3.2 表3)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.style = self.doc.styles["Heading 3"]
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.LEFT
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_heading)
|
||||
run = p.add_run(text)
|
||||
_set_font(run, cfg.font_cn_heading, cfg.font_heading_en,
|
||||
size=cfg.size_subsection, bold=True)
|
||||
|
||||
# ── body paragraph ──────────────────────────────────────────
|
||||
|
||||
def _add_body_para(self, text: str, bold: bool = False,
|
||||
indent: bool = True):
|
||||
"""正文:小四宋体,首行缩进2字符 (§3.2)"""
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
p.alignment = WD_ALIGN_PARAGRAPH.JUSTIFY
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_body)
|
||||
if indent:
|
||||
_set_indent(p, cfg.first_line_indent_chars)
|
||||
tokens = _parse_inline(text)
|
||||
_add_inline(p, tokens, cfg, bold=bold)
|
||||
|
||||
# ── code block ──────────────────────────────────────────────
|
||||
|
||||
def _process_code(self, blk: dict):
|
||||
code = blk.get("raw", "")
|
||||
if not code.strip():
|
||||
return
|
||||
cfg = self.config
|
||||
p = self.doc.add_paragraph()
|
||||
_set_spacing(p, before=0, after=0,
|
||||
line_spacing=cfg.line_spacing_code)
|
||||
pf = p.paragraph_format
|
||||
pf.left_indent = Cm(0.75)
|
||||
|
||||
pPr = p._element.get_or_add_pPr()
|
||||
shd = parse_xml(
|
||||
f'<w:shd {nsdecls("w")} w:fill="F2F2F2" w:val="clear"/>')
|
||||
pPr.append(shd)
|
||||
|
||||
for line in code.split("\n"):
|
||||
if line:
|
||||
run = p.add_run(line)
|
||||
_set_font(run, cfg.font_code, cfg.font_code,
|
||||
size=cfg.size_code)
|
||||
p.add_run("\n")
|
||||
13
main.py
13
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()
|
||||
|
||||
@@ -5,15 +5,20 @@ description = "毕业论文 Markdown → Word 格式转换工具"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.14"
|
||||
dependencies = [
|
||||
"docxtpl>=0.20.2",
|
||||
"mistune>=3.2.1",
|
||||
"python-docx>=1.2.0",
|
||||
"pyyaml>=6.0.3",
|
||||
]
|
||||
|
||||
[project.scripts]
|
||||
thesis = "docx_thesis.cli:main"
|
||||
thesis = "transit.__main__:main"
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"black>=26.3.1",
|
||||
"mypy>=2.0.0",
|
||||
"olefile>=0.47",
|
||||
"pywin32>=311",
|
||||
"ruff>=0.15.12",
|
||||
]
|
||||
|
||||
18
transit/__init__.py
Normal file
18
transit/__init__.py
Normal file
@@ -0,0 +1,18 @@
|
||||
"""transit —— 毕业论文 Markdown → Word 格式转换工具。"""
|
||||
|
||||
from .parser import parse_markdown
|
||||
from .body import body_to_paragraphs, replace_placeholder
|
||||
from .renderer import generate_thesis
|
||||
from .config import load_config, ThesisConfig
|
||||
from .references import references_to_paragraphs, format_gb7714
|
||||
|
||||
__all__ = [
|
||||
"parse_markdown",
|
||||
"body_to_paragraphs",
|
||||
"replace_placeholder",
|
||||
"generate_thesis",
|
||||
"load_config",
|
||||
"ThesisConfig",
|
||||
"references_to_paragraphs",
|
||||
"format_gb7714",
|
||||
]
|
||||
34
transit/__main__.py
Normal file
34
transit/__main__.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""CLI 入口:python -m transit"""
|
||||
|
||||
import argparse
|
||||
from pathlib import Path
|
||||
from .renderer import generate_thesis
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="毕业论文 Markdown → Word 格式转换工具"
|
||||
)
|
||||
parser.add_argument("data", type=str, help="Markdown 正文文件路径(.md)")
|
||||
parser.add_argument(
|
||||
"-t", "--template", default="sample.docx", help="docx 模板文件路径(默认: sample.docx)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-o", "--output", default="output.docx", help="输出 Word 文件路径(默认: output.docx)"
|
||||
)
|
||||
parser.add_argument(
|
||||
"-c", "--config", default=None, help="TOML 配置文件路径(可选)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
generate_thesis(
|
||||
template_path=args.template,
|
||||
data_path=args.data,
|
||||
config_path=args.config,
|
||||
output_path=args.output,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
276
transit/body.py
Normal file
276
transit/body.py
Normal file
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Markdown 正文 → Word 段落转换。
|
||||
|
||||
将正文 Markdown 按标题层级拆分为带样式的段落序列,
|
||||
再注入到渲染后 docx 文档的占位符位置。
|
||||
"""
|
||||
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.oxml.ns import qn
|
||||
|
||||
from .images import make_image_paragraph, is_figure_caption, insert_image_paragraphs
|
||||
|
||||
_PAT_HEADING = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
|
||||
|
||||
# 匹配正文中的引用标记 [1] / [1,2,3]
|
||||
_CITE_PATTERN = re.compile(r"\[(\d+(?:[,,\s]*\d+)*)\]")
|
||||
|
||||
|
||||
def body_to_paragraphs(
|
||||
md_text: str,
|
||||
*,
|
||||
level_offset: int = 0,
|
||||
body_style: str = "Body Text Indent",
|
||||
base_dir: str | Path | None = None,
|
||||
) -> list[dict]:
|
||||
"""将 Markdown 正文按标题和段落拆分为结构化列表。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
md_text : str
|
||||
正文 Markdown。
|
||||
level_offset : int
|
||||
标题级别偏移量(正文从 ``##`` 开始时传 ``-1``,使其输出为 ``Heading 1``)。
|
||||
body_style : str
|
||||
正文段落的 Word 样式名。
|
||||
base_dir : str | Path | None
|
||||
Markdown 文件所在目录,用于解析图片相对路径。
|
||||
"""
|
||||
paragraphs: list[dict] = []
|
||||
last_end = 0
|
||||
|
||||
def _add_block(block: str) -> None:
|
||||
block = block.strip()
|
||||
if not block:
|
||||
return
|
||||
# 图片段落
|
||||
img = make_image_paragraph(block, base_dir)
|
||||
if img:
|
||||
paragraphs.append(img)
|
||||
return
|
||||
# 跳过紧跟在图片后的重复图标题
|
||||
if paragraphs and paragraphs[-1].get("type") == "image" and is_figure_caption(block):
|
||||
return
|
||||
# 普通正文段落
|
||||
paragraphs.append({"text": block, "level": 0, "style": body_style})
|
||||
|
||||
for m in _PAT_HEADING.finditer(md_text):
|
||||
# 标题前的普通文本
|
||||
if m.start() > last_end:
|
||||
pre = md_text[last_end : m.start()].strip()
|
||||
if pre:
|
||||
for block in re.split(r"\n\s*\n", pre):
|
||||
_add_block(block)
|
||||
|
||||
level = len(m.group(1)) + level_offset
|
||||
heading_text = m.group(2).strip()
|
||||
paragraphs.append(
|
||||
{"text": heading_text, "level": level, "style": f"Heading {level}"}
|
||||
)
|
||||
last_end = m.end()
|
||||
|
||||
# 最后一段 / 尾部文本
|
||||
tail = md_text[last_end:].strip()
|
||||
if tail:
|
||||
for block in re.split(r"\n\s*\n", tail):
|
||||
_add_block(block)
|
||||
|
||||
return paragraphs
|
||||
|
||||
|
||||
def replace_placeholder(
|
||||
doc: Document,
|
||||
placeholder: str,
|
||||
paragraphs: list[dict],
|
||||
*,
|
||||
default_body_style: str | None = None,
|
||||
):
|
||||
"""在 *doc* 中找到包含 *placeholder* 的段落,替换为 *paragraphs* 列表。
|
||||
|
||||
正文段落的样式优先级:
|
||||
1. ``style`` 字段指定的样式名(来自 ``body_to_paragraphs`` 的 ``body_style``)
|
||||
2. 占位符段落自身的样式(模板中已设好的样式)
|
||||
3. ``Normal``
|
||||
"""
|
||||
placeholder_found = False
|
||||
for para in doc.paragraphs:
|
||||
if placeholder in para.text:
|
||||
placeholder_found = True
|
||||
placeholder_style = para.style.name if para.style else None
|
||||
parent = para._element.getparent()
|
||||
idx = list(parent).index(para._element)
|
||||
|
||||
# 保存原段落的编号属性(numPr),用于继承自动编号
|
||||
orig_pPr = para._element.find(qn("w:pPr"))
|
||||
numPr = orig_pPr.find(qn("w:numPr")) if orig_pPr is not None else None
|
||||
|
||||
parent.remove(para._element)
|
||||
|
||||
# 为参考文献段落准备书签 ID
|
||||
bm_id = _max_bookmark_id(doc) + 1
|
||||
|
||||
for pd_data in reversed(paragraphs):
|
||||
if pd_data.get("type") == "image":
|
||||
insert_image_paragraphs(
|
||||
doc, [pd_data], idx=idx, parent=parent
|
||||
)
|
||||
else:
|
||||
new_p = doc.add_paragraph(pd_data["text"])
|
||||
style_name = pd_data["style"]
|
||||
|
||||
# 尝试应用样式,逐步降级
|
||||
applied = _apply_style(new_p, doc, style_name)
|
||||
if not applied and style_name.startswith("Heading"):
|
||||
new_p.style = doc.styles["Normal"]
|
||||
elif not applied:
|
||||
if placeholder_style:
|
||||
_apply_style(new_p, doc, placeholder_style)
|
||||
if new_p.style.name == "Normal" and placeholder_style:
|
||||
new_p.style = doc.styles[placeholder_style]
|
||||
|
||||
# 继承原段落的编号属性(自动编号)
|
||||
if numPr is not None:
|
||||
new_pPr = new_p._element.find(qn("w:pPr"))
|
||||
if new_pPr is None:
|
||||
new_pPr = new_p._element.makeelement(qn("w:pPr"), {})
|
||||
new_p._element.insert(0, new_pPr)
|
||||
existing = new_pPr.find(qn("w:numPr"))
|
||||
if existing is not None:
|
||||
new_pPr.remove(existing)
|
||||
new_pPr.append(deepcopy(numPr))
|
||||
|
||||
parent.insert(idx, new_p._element)
|
||||
|
||||
# 为参考文献条目添加书签
|
||||
ref_id = pd_data.get("ref_id")
|
||||
if ref_id is not None:
|
||||
_add_bookmark(new_p, f"ref-{ref_id}", bm_id)
|
||||
bm_id += 1
|
||||
break
|
||||
|
||||
if not placeholder_found:
|
||||
print(f"警告:未找到占位符 '{placeholder}',正文段落未注入。")
|
||||
|
||||
|
||||
def link_body_citations(doc: Document):
|
||||
"""将文档中正文段落的 ``[N]`` 引用替换为指向对应书签的超链接。"""
|
||||
for para in doc.paragraphs:
|
||||
# 跳过已有超链接的段落(如目录页)
|
||||
if para._element.findall(qn("w:hyperlink")):
|
||||
continue
|
||||
_link_paragraph(para)
|
||||
|
||||
|
||||
def _max_bookmark_id(doc: Document) -> int:
|
||||
"""扫描文档返回最大书签 ID。"""
|
||||
max_id = 0
|
||||
for para in doc.paragraphs:
|
||||
for bm in para._element.iter(qn("w:bookmarkStart")):
|
||||
try:
|
||||
max_id = max(max_id, int(bm.get(qn("w:id"))))
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return max_id
|
||||
|
||||
|
||||
def _add_bookmark(paragraph, name: str, bm_id: int):
|
||||
"""为段落添加书签。"""
|
||||
bm_start = paragraph._element.makeelement(qn("w:bookmarkStart"), {})
|
||||
bm_start.set(qn("w:id"), str(bm_id))
|
||||
bm_start.set(qn("w:name"), name)
|
||||
|
||||
bm_end = paragraph._element.makeelement(qn("w:bookmarkEnd"), {})
|
||||
bm_end.set(qn("w:id"), str(bm_id))
|
||||
|
||||
pPr = paragraph._element.find(qn("w:pPr"))
|
||||
if pPr is not None:
|
||||
paragraph._element.insert(1, bm_start)
|
||||
else:
|
||||
paragraph._element.insert(0, bm_start)
|
||||
paragraph._element.append(bm_end)
|
||||
|
||||
|
||||
def _link_paragraph(para):
|
||||
"""将单个段落中的 ``[N]`` 替换为 HYPERLINK 域。"""
|
||||
runs = list(para._element.findall(qn("w:r")))
|
||||
if not runs:
|
||||
return
|
||||
|
||||
full_text = ""
|
||||
for r in runs:
|
||||
t = r.find(qn("w:t"))
|
||||
if t is not None and t.text:
|
||||
full_text += t.text
|
||||
|
||||
matches = list(_CITE_PATTERN.finditer(full_text))
|
||||
if not matches:
|
||||
return
|
||||
|
||||
first_rPr = runs[0].find(qn("w:rPr"))
|
||||
for r in runs:
|
||||
para._element.remove(r)
|
||||
|
||||
pos = 0
|
||||
for m in matches:
|
||||
before = full_text[pos : m.start()]
|
||||
if before:
|
||||
_add_run(para._element, before, first_rPr)
|
||||
|
||||
nums = re.findall(r"\d+", m.group(1))
|
||||
_add_hlink(para._element, f"ref-{nums[0]}", m.group())
|
||||
|
||||
pos = m.end()
|
||||
|
||||
after = full_text[pos:]
|
||||
if after:
|
||||
_add_run(para._element, after, first_rPr)
|
||||
|
||||
|
||||
def _add_run(parent, text: str, rPr):
|
||||
r = parent.makeelement(qn("w:r"), {})
|
||||
if rPr is not None:
|
||||
r.append(deepcopy(rPr))
|
||||
t = r.makeelement(qn("w:t"), {})
|
||||
t.text = text
|
||||
r.append(t)
|
||||
parent.append(r)
|
||||
|
||||
|
||||
def _add_hlink(parent, anchor: str, text: str):
|
||||
hl = parent.makeelement(qn("w:hyperlink"), {})
|
||||
hl.set(qn("w:anchor"), anchor)
|
||||
|
||||
r = parent.makeelement(qn("w:r"), {})
|
||||
rPr = r.makeelement(qn("w:rPr"), {})
|
||||
rStyle = rPr.makeelement(qn("w:rStyle"), {})
|
||||
rStyle.set(qn("w:val"), "Hyperlink")
|
||||
rPr.append(rStyle)
|
||||
vertAlign = rPr.makeelement(qn("w:vertAlign"), {})
|
||||
vertAlign.set(qn("w:val"), "superscript")
|
||||
rPr.append(vertAlign)
|
||||
r.append(rPr)
|
||||
|
||||
t = r.makeelement(qn("w:t"), {})
|
||||
t.text = text
|
||||
r.append(t)
|
||||
hl.append(r)
|
||||
parent.append(hl)
|
||||
|
||||
|
||||
def _apply_style(paragraph, doc, style_name: str) -> bool:
|
||||
"""尝试给段落应用样式,成功返回 ``True``。"""
|
||||
try:
|
||||
paragraph.style = doc.styles[style_name]
|
||||
return True
|
||||
except KeyError:
|
||||
pass
|
||||
# 大小写不敏感匹配
|
||||
for s in doc.styles:
|
||||
if s.name.lower() == style_name.lower():
|
||||
paragraph.style = s
|
||||
return True
|
||||
return False
|
||||
51
transit/config.py
Normal file
51
transit/config.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
import tomllib
|
||||
|
||||
|
||||
@dataclass
|
||||
class ThesisConfig:
|
||||
"""论文配置数据。
|
||||
|
||||
``metadata`` 直接透传 TOML 的 ``[metadata]`` 节,不再为每个变量声明字段。
|
||||
新增模板变量只需改 TOML,无需修改 Python。
|
||||
"""
|
||||
|
||||
metadata: dict = field(default_factory=dict)
|
||||
|
||||
# 以下字段仍有业务逻辑,保留为显式属性
|
||||
title_from_md: bool = True
|
||||
body_start_keywords: list[str] = field(default_factory=lambda: ["绪论", "引言"])
|
||||
body_end_keywords: list[str] = field(
|
||||
default_factory=lambda: ["致谢", "参考文献", "附录"]
|
||||
)
|
||||
body_style: str = "Body Text Indent"
|
||||
level_offset: int = -1
|
||||
reference_style: str = "列出段落1"
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""透传 metadata(模板变量来源)。"""
|
||||
return self.metadata
|
||||
|
||||
|
||||
def load_config(path: str | Path) -> ThesisConfig:
|
||||
"""从 TOML 文件加载论文配置。"""
|
||||
path = Path(path)
|
||||
with open(path, "rb") as f:
|
||||
raw = tomllib.load(f)
|
||||
|
||||
meta = raw.get("metadata", {})
|
||||
opts = raw.get("options", {})
|
||||
|
||||
return ThesisConfig(
|
||||
metadata=meta,
|
||||
title_from_md=opts.get("title_from_md", True),
|
||||
body_start_keywords=opts.get("body_start_keywords", ["绪论", "引言"]),
|
||||
body_end_keywords=opts.get(
|
||||
"body_end_keywords", ["致谢", "参考文献", "附录"]
|
||||
),
|
||||
body_style=opts.get("body_style", "Body Text Indent"),
|
||||
level_offset=opts.get("level_offset", -1),
|
||||
reference_style=opts.get("reference_style", "列出段落1"),
|
||||
)
|
||||
163
transit/images.py
Normal file
163
transit/images.py
Normal file
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
图片处理模块。
|
||||
|
||||
将 Markdown 中的 ``<img>`` 标签解析为独立段落,
|
||||
并在 Word 文档中插入图片及居中图标题。
|
||||
"""
|
||||
|
||||
import struct
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
from docx import Document
|
||||
from docx.enum.text import WD_ALIGN_PARAGRAPH
|
||||
|
||||
# 匹配 <img src="..." alt="...">
|
||||
_IMG_TAG = re.compile(
|
||||
r'<img\s+([^>]*?)src=["\']([^"\']+)["\']([^>]*?)>', re.IGNORECASE
|
||||
)
|
||||
|
||||
# 从标签属性块中提取名值对
|
||||
_ATTR = re.compile(r'(\w+)\s*=\s*["\']([^"\']*)["\']')
|
||||
|
||||
# 匹配 **图X ...** 重复图标题(注入时跳过)
|
||||
_FIG_CAPTION = re.compile(
|
||||
r'^\*\*(?:图|Table|Figure|Fig\.?)\s*\d+.*\*\*$', re.IGNORECASE
|
||||
)
|
||||
|
||||
|
||||
def make_image_paragraph(block: str, base_dir: str | Path | None = None) -> dict | None:
|
||||
"""若 *block* 包含 ``<img>`` 标签,返回图片段落字典;否则返回 ``None``。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
block : str
|
||||
文本块。
|
||||
base_dir : str | Path | None
|
||||
Markdown 文件所在目录,用于解析图片相对路径。
|
||||
"""
|
||||
m = _IMG_TAG.search(block)
|
||||
if not m:
|
||||
return None
|
||||
attrs = dict(_ATTR.findall(block))
|
||||
src = attrs.get("src", m.group(2))
|
||||
if base_dir and not Path(src).is_absolute():
|
||||
src = str(Path(base_dir) / src)
|
||||
return {
|
||||
"type": "image",
|
||||
"src": src,
|
||||
"alt": attrs.get("alt", ""),
|
||||
}
|
||||
|
||||
|
||||
def is_figure_caption(block: str) -> bool:
|
||||
"""检查 *block* 是否为 ``**图X ...**`` 格式的重复图标题。"""
|
||||
return bool(_FIG_CAPTION.match(block.strip()))
|
||||
|
||||
|
||||
def _get_image_dimensions(image_path: str) -> tuple[int, int] | None:
|
||||
"""读取图片文件头返回 ``(width_px, height_px)``,不支持格式返回 ``None``。
|
||||
|
||||
仅读取文件头,不依赖第三方库。支持 PNG / JPEG / GIF / BMP。
|
||||
"""
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
header = f.read(32)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
# PNG: 8-byte signature, then IHDR chunk
|
||||
if header[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
w, h = struct.unpack_from(">II", header, 16)
|
||||
return w, h
|
||||
|
||||
# JPEG: starts with FF D8, scan for SOF marker
|
||||
if header[:2] == b"\xff\xd8":
|
||||
pos = 2
|
||||
while pos < len(header):
|
||||
if header[pos] != 0xFF:
|
||||
return None
|
||||
marker = header[pos + 1]
|
||||
if marker in (0xC0, 0xC1, 0xC2):
|
||||
h, w = struct.unpack_from(">HH", header, pos + 5)
|
||||
return w, h
|
||||
seg_len = struct.unpack_from(">H", header, pos + 2)[0]
|
||||
pos += 2 + seg_len
|
||||
return None
|
||||
|
||||
# GIF: "GIF87a" or "GIF89a"
|
||||
if header[:6] in (b"GIF87a", b"GIF89a"):
|
||||
w, h = struct.unpack_from("<HH", header, 6)
|
||||
return w, h
|
||||
|
||||
# BMP: "BM" signature
|
||||
if header[:2] == b"BM":
|
||||
w, h = struct.unpack_from("<ii", header, 18)
|
||||
return w, abs(h)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _get_native_emu(image_path: str) -> int | None:
|
||||
"""读取图片的原生宽度(EMU),失败返回 ``None``。
|
||||
|
||||
Word 默认以 72 DPI 渲染图片,1 px = 914400 / 72 = 12700 EMU。
|
||||
"""
|
||||
dims = _get_image_dimensions(image_path)
|
||||
if dims is None:
|
||||
return None
|
||||
w_px, _ = dims
|
||||
return w_px * 12700
|
||||
|
||||
|
||||
def _constrain_width(image_path: str, page_text_width: int) -> int | None:
|
||||
"""返回图片宽度(EMU),超出页宽时缩至页宽。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
image_path : str
|
||||
图片路径。
|
||||
page_text_width : int
|
||||
页面正文区宽度(EMU),来自 ``section.page_width - margins``。
|
||||
"""
|
||||
native = _get_native_emu(image_path)
|
||||
if native is None:
|
||||
return None
|
||||
return min(native, page_text_width)
|
||||
|
||||
|
||||
def insert_image_paragraphs(
|
||||
doc: Document,
|
||||
paragraphs: list[dict],
|
||||
*,
|
||||
idx: int,
|
||||
parent,
|
||||
):
|
||||
"""在 *doc* 的指定位置插入图片段落序列。
|
||||
|
||||
每条图片段落生成两个 Word 段落:
|
||||
1. 居中图片(超出页宽时自动缩放至页宽,否则保持原尺寸)
|
||||
2. 居中图标题(从 ``alt`` 提取)
|
||||
|
||||
插入顺序保持 ``paragraphs`` 的原有顺序。
|
||||
"""
|
||||
section = doc.sections[0]
|
||||
page_text_width = section.page_width - section.left_margin - section.right_margin
|
||||
|
||||
for pd_data in reversed(paragraphs):
|
||||
# 图标题(在 reversed 中先插入,最终位于图片下方)
|
||||
cap_p = doc.add_paragraph(pd_data.get("alt", ""))
|
||||
cap_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
parent.insert(idx, cap_p._element)
|
||||
|
||||
# 图片段落(max-width 行为:超出页宽时压缩,否则原尺寸)
|
||||
img_p = doc.add_paragraph()
|
||||
img_p.alignment = WD_ALIGN_PARAGRAPH.CENTER
|
||||
img_run = img_p.add_run()
|
||||
try:
|
||||
img_width = _constrain_width(pd_data["src"], page_text_width)
|
||||
img_run.add_picture(pd_data["src"], width=img_width)
|
||||
except Exception as exc:
|
||||
print(f"警告:图片加载失败 {pd_data['src']} — {exc}")
|
||||
img_run.add_text(f"[图片加载失败: {pd_data['src']}]")
|
||||
parent.insert(idx, img_p._element)
|
||||
131
transit/parser.py
Normal file
131
transit/parser.py
Normal file
@@ -0,0 +1,131 @@
|
||||
"""
|
||||
Markdown 论文解析器。
|
||||
|
||||
将结构化 Markdown(摘要、正文、致谢、参考文献、附录)解析为字典,
|
||||
供 docx 模板渲染使用。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# 匹配任意级数的标题:^## 或 ^### 等,支持可选的数字编号
|
||||
_RE_HEADING = re.compile(r"^(#{1,6})\s*(?:\d+(?:\.\d+)*\s*)?(.+)$", re.MULTILINE)
|
||||
|
||||
|
||||
def _strip_front_matter(content: str) -> str:
|
||||
"""移除 YAML front matter(``---`` 包裹的头部块)。"""
|
||||
if content.startswith("---"):
|
||||
end = content.find("---", 3)
|
||||
if end != -1:
|
||||
return content[end + 3 :]
|
||||
return content
|
||||
|
||||
|
||||
def _find_section(
|
||||
content: str, titles: list[str], after: int = 0
|
||||
) -> Optional[tuple[int, int, str]]:
|
||||
"""查找第一个匹配的章节,返回 ``(section_start, section_end, matched_title)``。
|
||||
|
||||
章节范围从标题行开始,到下一个同级/更高级标题结束(或内容结尾)。
|
||||
"""
|
||||
for m in _RE_HEADING.finditer(content, after):
|
||||
raw_text = m.group(2).strip()
|
||||
for t in titles:
|
||||
if raw_text == t or raw_text.endswith(t):
|
||||
rest = content[m.end() :]
|
||||
next_m = _RE_HEADING.search(rest)
|
||||
section_end = m.end() + (next_m.start() if next_m else len(rest))
|
||||
return (m.start(), section_end, t)
|
||||
return None
|
||||
|
||||
|
||||
def _get_section_body(content: str, section_info: tuple) -> str:
|
||||
"""从 section_info 中提取标题行之后的纯章节正文。"""
|
||||
hdr_m = _RE_HEADING.match(content, section_info[0])
|
||||
if not hdr_m:
|
||||
return ""
|
||||
return content[hdr_m.end() : section_info[1]].strip()
|
||||
|
||||
|
||||
def parse_markdown(
|
||||
md_text: str,
|
||||
body_start_kw: list[str] | None = None,
|
||||
body_end_kw: list[str] | None = None,
|
||||
) -> dict:
|
||||
"""解析 Markdown 格式的论文文本,返回模板变量字典。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
md_text : str
|
||||
完整的 Markdown 文本。
|
||||
body_start_kw : list[str] | None
|
||||
标识正文开始的章节名列表,默认 [``绪论``, ``引言``]。
|
||||
body_end_kw : list[str] | None
|
||||
标识正文结束的章节名列表,默认 [``致谢``, ``参考文献``, ``附录``]。
|
||||
"""
|
||||
if body_start_kw is None:
|
||||
body_start_kw = ["绪论", "引言"]
|
||||
if body_end_kw is None:
|
||||
body_end_kw = ["致谢", "参考文献", "附录"]
|
||||
|
||||
content = _strip_front_matter(md_text.strip())
|
||||
data: dict = {}
|
||||
|
||||
# ── 标题(第一个 # 标题) ──
|
||||
title_m = re.search(r"^#\s+(.+)$", content, re.MULTILINE)
|
||||
if title_m:
|
||||
data["title"] = title_m.group(1).strip()
|
||||
|
||||
# ── 中文摘要 ──
|
||||
abs_cn = _find_section(content, ["摘 要", "摘要"])
|
||||
if abs_cn:
|
||||
sec_body = _get_section_body(content, abs_cn)
|
||||
kw_m = re.search(
|
||||
r"\*\*关键词[::]?\s*\*\*\s*(.*?)$", sec_body, re.MULTILINE
|
||||
)
|
||||
if kw_m:
|
||||
data["abstact_cn_context"] = sec_body[: kw_m.start()].strip()
|
||||
data["abstract_cn_keywords"] = kw_m.group(1).strip()
|
||||
else:
|
||||
data["abstact_cn_context"] = sec_body
|
||||
|
||||
# ── 英文摘要 ──
|
||||
abs_en = _find_section(content, ["Abstract"])
|
||||
if abs_en:
|
||||
sec_body = _get_section_body(content, abs_en)
|
||||
kw_m = re.search(
|
||||
r"\*\*Key words[::]?\s*\*\*\s*(.*?)$", sec_body, re.MULTILINE
|
||||
)
|
||||
if kw_m:
|
||||
data["abstract_en_context"] = sec_body[: kw_m.start()].strip()
|
||||
data["abstract_en_keywords"] = kw_m.group(1).strip()
|
||||
else:
|
||||
data["abstract_en_context"] = sec_body
|
||||
|
||||
# ── 正文(从绪论/引言到致谢/参考文献/附录) ──
|
||||
body_start = _find_section(content, body_start_kw)
|
||||
if body_start:
|
||||
body_content = content[body_start[0] :]
|
||||
body_end = _find_section(body_content, body_end_kw)
|
||||
if body_end:
|
||||
body_content = body_content[: body_end[0]]
|
||||
data["body_md"] = body_content.strip()
|
||||
else:
|
||||
data["body_md"] = ""
|
||||
|
||||
# ── 致谢(仅正文,不含标题行) ──
|
||||
ack = _find_section(content, ["致谢"])
|
||||
if ack:
|
||||
data["acknowledgement"] = _get_section_body(content, ack)
|
||||
|
||||
# ── 参考文献(仅正文,不含标题行) ──
|
||||
ref = _find_section(content, ["参考文献"])
|
||||
if ref:
|
||||
data["reference"] = _get_section_body(content, ref)
|
||||
|
||||
# ── 附录(仅正文,不含标题行) ──
|
||||
app = _find_section(content, ["附录"])
|
||||
if app:
|
||||
data["appendix"] = _get_section_body(content, app)
|
||||
|
||||
return data
|
||||
118
transit/references.py
Normal file
118
transit/references.py
Normal file
@@ -0,0 +1,118 @@
|
||||
"""
|
||||
GB 7714 《文后参考文献著录规则》顺序编码制 — 参考文献解析与格式化。
|
||||
|
||||
将 Markdown 中 ``[N] ... [TYPE] ...`` 格式的参考文献逐条解析,
|
||||
按文献类型(书 M、期刊 J、会议 C、学位论文 D、标准 S、电子资源 EB/OL 等)
|
||||
重新编排为符合 GB 7714 规范的格式,并输出为独立段落。
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Optional
|
||||
|
||||
# 单行匹配: [N] 开头
|
||||
_RE_LINE = re.compile(r"^\[(\d+)\]\s*(.*)$")
|
||||
# 文献类型标记: [M] [J] [C] [D] [S] [EB/OL] [P] [N]
|
||||
_RE_TYPE = re.compile(r"\[(\w+(?:/\w+)?)\]")
|
||||
|
||||
|
||||
# GB 7714 中各文献类型的标准格式模板(仅用于说明,实际格式化直接拼接)
|
||||
TYPE_LABELS: dict[str, str] = {
|
||||
"M": "专著",
|
||||
"J": "期刊文章",
|
||||
"C": "会议论文",
|
||||
"D": "学位论文",
|
||||
"S": "标准",
|
||||
"EB/OL": "电子资源",
|
||||
"P": "专利",
|
||||
"N": "报纸文章",
|
||||
}
|
||||
|
||||
|
||||
def parse_reference_line(line: str) -> Optional[dict]:
|
||||
"""解析单行参考文献,提取序号、作者+标题、文献类型、来源信息。
|
||||
|
||||
期望格式::
|
||||
|
||||
[N] Authors. Title[TYPE]. Source info.
|
||||
"""
|
||||
line = line.strip()
|
||||
m = _RE_LINE.match(line)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
number = int(m.group(1))
|
||||
rest = m.group(2).strip()
|
||||
|
||||
# 定位文献类型标记 [TYPE]
|
||||
tm = _RE_TYPE.search(rest)
|
||||
if not tm:
|
||||
return None
|
||||
|
||||
before_type = rest[: tm.start()].strip().rstrip(".")
|
||||
doc_type = tm.group(1)
|
||||
after_type = rest[tm.end() :].strip().lstrip(".").strip()
|
||||
|
||||
return {
|
||||
"number": number,
|
||||
"before_type": before_type, # "Authors. Title"
|
||||
"doc_type": doc_type, # "M", "J", "EB/OL", …
|
||||
"after_type": after_type, # 来源信息
|
||||
}
|
||||
|
||||
|
||||
def _normalize_period(text: str) -> str:
|
||||
"""确保文本以英文句点结尾(GB 7714 要求)。"""
|
||||
text = text.rstrip()
|
||||
if text and not text.endswith("."):
|
||||
text += "."
|
||||
return text
|
||||
|
||||
|
||||
def format_gb7714(ref: dict) -> str:
|
||||
"""按 GB 7714 重新编排一条参考文献(不含序号前缀,由 Word 样式自动编号)。
|
||||
|
||||
格式::
|
||||
|
||||
Authors. Title[TYPE]. Source.
|
||||
"""
|
||||
bt = ref["before_type"]
|
||||
dt = ref["doc_type"]
|
||||
at = ref["after_type"]
|
||||
formatted = f"{bt}[{dt}]. {at}"
|
||||
return _normalize_period(formatted)
|
||||
|
||||
|
||||
def references_to_paragraphs(
|
||||
ref_text: str,
|
||||
ref_style: str = "列出段落1",
|
||||
) -> list[dict]:
|
||||
"""将参考文献原始文本转换为格式化段落列表。
|
||||
|
||||
返回的每个元素::
|
||||
|
||||
{"text": str, "level": 0, "style": ref_style}
|
||||
|
||||
每条参考文献为一个独立段落。
|
||||
"""
|
||||
if not ref_text or ref_text == "<None>":
|
||||
return [{"text": "<None>", "level": 0, "style": ref_style}]
|
||||
|
||||
lines = [l.strip() for l in ref_text.strip().split("\n") if l.strip()]
|
||||
paragraphs: list[dict] = []
|
||||
|
||||
for line in lines:
|
||||
ref = parse_reference_line(line)
|
||||
if ref:
|
||||
formatted = format_gb7714(ref)
|
||||
ref_id = ref["number"]
|
||||
else:
|
||||
# 无法解析时,至少去掉 [N] 前缀
|
||||
fallback = re.sub(r"^\[\d+\]\s*", "", line)
|
||||
formatted = _normalize_period(fallback)
|
||||
ref_id = None
|
||||
|
||||
paragraphs.append(
|
||||
{"text": formatted, "level": 0, "style": ref_style, "ref_id": ref_id}
|
||||
)
|
||||
|
||||
return paragraphs
|
||||
152
transit/renderer.py
Normal file
152
transit/renderer.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""
|
||||
论文生成编排器。
|
||||
|
||||
组装 配置 + 解析 + 模板渲染 + 正文注入 的完整流水线。
|
||||
"""
|
||||
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from docxtpl import DocxTemplate
|
||||
from docx import Document
|
||||
|
||||
from .config import load_config, ThesisConfig
|
||||
from .parser import parse_markdown
|
||||
from .body import body_to_paragraphs, replace_placeholder, link_body_citations
|
||||
from .references import references_to_paragraphs
|
||||
|
||||
|
||||
# 解析器可能产生的字段(用于填充报告)
|
||||
_PARSER_FIELDS = [
|
||||
"title",
|
||||
"abstact_cn_context",
|
||||
"abstract_cn_keywords",
|
||||
"abstract_en_context",
|
||||
"abstract_en_keywords",
|
||||
"acknowledgement",
|
||||
"reference",
|
||||
"appendix",
|
||||
"body_md",
|
||||
]
|
||||
|
||||
|
||||
def generate_thesis(
|
||||
template_path: str | Path,
|
||||
data_path: str | Path,
|
||||
config_path: str | Path | None = None,
|
||||
output_path: str | Path = "output.docx",
|
||||
) -> dict:
|
||||
"""执行从数据到 Word 的完整论文生成流程。
|
||||
|
||||
Parameters
|
||||
----------
|
||||
template_path : str | Path
|
||||
docxtpl 模板文件路径(.docx)。
|
||||
data_path : str | Path
|
||||
Markdown 论文正文文件路径(.md)。
|
||||
config_path : str | Path | None
|
||||
TOML 配置文件路径。为 ``None`` 时尝试自动查找。
|
||||
output_path : str | Path
|
||||
输出 Word 文件路径。
|
||||
"""
|
||||
data_path = Path(data_path)
|
||||
|
||||
# 1. 加载配置
|
||||
if config_path is None:
|
||||
candidates = [
|
||||
Path("thesis_config.toml"),
|
||||
data_path.with_suffix(".toml"),
|
||||
]
|
||||
config_path = next((p for p in candidates if p.exists()), None)
|
||||
|
||||
config: ThesisConfig | None = None
|
||||
if config_path and Path(config_path).exists():
|
||||
config = load_config(config_path)
|
||||
print(f"[配置] 配置文件: {config_path}")
|
||||
else:
|
||||
config = ThesisConfig()
|
||||
print("[配置] 未找到配置文件,使用默认值。")
|
||||
|
||||
# 2. 解析 Markdown
|
||||
with open(data_path, "r", encoding="utf-8") as f:
|
||||
md_text = f.read()
|
||||
|
||||
context = parse_markdown(
|
||||
md_text,
|
||||
body_start_kw=config.body_start_keywords,
|
||||
body_end_kw=config.body_end_keywords,
|
||||
)
|
||||
|
||||
# 3. 合并配置 → 上下文(配置填充解析器未产生的空白)
|
||||
for k, v in config.to_dict().items():
|
||||
if k == "title" and config.title_from_md and context.get("title"):
|
||||
continue # 以 markdown 标题为准
|
||||
context.setdefault(k, v)
|
||||
|
||||
# 4. 用 defaultdict 兜底缺失键
|
||||
ctx = defaultdict(lambda: "<None>", context)
|
||||
|
||||
# 5. 解析正文为段落列表
|
||||
body_md = ctx.get("body_md", "")
|
||||
body_paragraphs = (
|
||||
body_to_paragraphs(
|
||||
body_md,
|
||||
level_offset=config.level_offset,
|
||||
body_style=config.body_style,
|
||||
base_dir=data_path.parent,
|
||||
)
|
||||
if body_md else []
|
||||
)
|
||||
|
||||
# 6. 解析参考文献为段落列表
|
||||
ref_text = ctx.get("reference", "")
|
||||
ref_paragraphs = references_to_paragraphs(ref_text, ref_style=config.reference_style)
|
||||
|
||||
# 7. 占位符(替代模板变量,后处理时替换)
|
||||
ctx["body_placeholder"] = "__CONTEXT_PLACEHOLDER__"
|
||||
ctx["reference"] = "__REFERENCE_PLACEHOLDER__"
|
||||
|
||||
# 7. 渲染模板
|
||||
doc = DocxTemplate(str(template_path))
|
||||
doc.render(ctx)
|
||||
|
||||
# 8. 保存临时文件,再做后处理
|
||||
temp_path = Path(output_path).with_suffix(".tmp")
|
||||
doc.save(str(temp_path))
|
||||
|
||||
# 9. 正文注入+参考文献注入
|
||||
final_doc = Document(str(temp_path))
|
||||
replace_placeholder(
|
||||
final_doc, "__CONTEXT_PLACEHOLDER__", body_paragraphs,
|
||||
default_body_style=config.body_style,
|
||||
)
|
||||
|
||||
# 将正文中的 [N] 引用替换为超链接
|
||||
link_body_citations(final_doc)
|
||||
|
||||
replace_placeholder(
|
||||
final_doc, "__REFERENCE_PLACEHOLDER__", ref_paragraphs,
|
||||
default_body_style=config.reference_style,
|
||||
)
|
||||
final_doc.save(str(output_path))
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
print(f"[完成] 论文生成完成: {output_path}")
|
||||
|
||||
# 10. 字段填充报告(动态收集所有模板与解析字段)
|
||||
report_fields = list(dict.fromkeys([*config.metadata.keys(), *_PARSER_FIELDS]))
|
||||
print("\n--- 字段填充情况 ---")
|
||||
for key in report_fields:
|
||||
val = ctx.get(key, "<None>")
|
||||
if val == "<None>":
|
||||
print(f" [缺失] {key}")
|
||||
else:
|
||||
preview = str(val)[:60].replace("\n", " ")
|
||||
print(f" [OK] {key}: {preview}...")
|
||||
|
||||
missing = [k for k in report_fields if ctx.get(k, "<None>") == "<None>"]
|
||||
if missing:
|
||||
print("\n[警告] 以下字段缺失,已填充 '<None>':")
|
||||
for f in missing:
|
||||
print(f" - {f}")
|
||||
|
||||
return dict(ctx)
|
||||
191
uv.lock
generated
191
uv.lock
generated
@@ -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" },
|
||||
]
|
||||
|
||||
|
||||
Reference in New Issue
Block a user