"""Markdown 切块 / 合并工具。 把一篇 Markdown 按标题层级(# / ##)切成"翻译单元"或"润色单元", 每个单元带稳定的 ID,便于断点续传和按需重跑。 核心约定: - H1(`# `)是一级块,通常对应 Chapter / 封面 / 前置件 - H2(`## `)是二级块,对应一个 section 或独立前置件(Disclaimer / Executive Summary / Abstract / Glossary / References) - 没有任何标题的文件头(frontmatter 区)归到第 0 块 切块粒度默认到 H2;如果某个 H2 下的正文特别长可以再按 H3 切,但这是 polish 的事情, translate 一般不需要。 """ from __future__ import annotations import hashlib import re from dataclasses import dataclass, field from pathlib import Path from typing import Iterable HEADER_RE = re.compile(r"^(#{1,6})\s+(.+?)\s*$", re.MULTILINE) @dataclass class MarkdownBlock: """一个翻译/润色单元。""" order: int # 在原文中的顺序(0-based) level: int # 0 = frontmatter; 1/2/... = H1/H2/... title: str # 标题原文(不含 # 号);frontmatter 为空串 anchor: str # 稳定 ID,用于断点续传(order + title hash) content: str # 完整内容(包含标题行本身,除 frontmatter 块外) parent_order: int | None = None # H2 的父 H1 order;H1 为 None word_count: int = 0 # 英文 word count 估算(只含 a-z) char_count: int = 0 # 字符数(含中文) meta: dict = field(default_factory=dict) @property def short_title(self) -> str: t = self.title.strip() if len(t) <= 50: return t return t[:47] + "..." def _count_words(text: str) -> int: return len(re.findall(r"[A-Za-z]+(?:[-'][A-Za-z]+)*", text)) def _stable_anchor(order: int, title: str) -> str: """order + title 生成稳定短 ID。同一文件改顺序不变,改标题重算。""" h = hashlib.sha1(title.strip().encode("utf-8")).hexdigest()[:8] return f"b{order:03d}-{h}" def split_by_headers(text: str, max_level: int = 2) -> list[MarkdownBlock]: """把 Markdown 按 H1..H{max_level} 切块。 返回的 block 按出现顺序排列。第 0 块可能是 frontmatter(level=0,无 title)。 """ lines = text.splitlines(keepends=False) # 先找出所有 header 位置 header_positions: list[tuple[int, int, str]] = [] # (line_idx, level, title) in_code_block = False for i, ln in enumerate(lines): if ln.lstrip().startswith("```"): in_code_block = not in_code_block continue if in_code_block: continue m = re.match(r"^(#{1,6})\s+(.+?)\s*$", ln) if m: level = len(m.group(1)) if level <= max_level: header_positions.append((i, level, m.group(2))) blocks: list[MarkdownBlock] = [] order = 0 # frontmatter:第一个 header 前的所有内容 first_header_line = header_positions[0][0] if header_positions else len(lines) frontmatter_text = "\n".join(lines[:first_header_line]).rstrip() if frontmatter_text.strip(): blocks.append( MarkdownBlock( order=order, level=0, title="", anchor=_stable_anchor(order, "__frontmatter__"), content=frontmatter_text, word_count=_count_words(frontmatter_text), char_count=len(frontmatter_text), ) ) order += 1 # 为每个 header 建一个 block:内容 = 本 header 行 + 下一 header 行前所有内容 last_h1_order: int | None = None for idx, (line_idx, level, title) in enumerate(header_positions): end_line = ( header_positions[idx + 1][0] if idx + 1 < len(header_positions) else len(lines) ) content = "\n".join(lines[line_idx:end_line]).rstrip() block = MarkdownBlock( order=order, level=level, title=title, anchor=_stable_anchor(order, title), content=content, parent_order=last_h1_order if level > 1 else None, word_count=_count_words(content), char_count=len(content), ) blocks.append(block) if level == 1: last_h1_order = order order += 1 return blocks def merge_blocks(blocks: Iterable[MarkdownBlock], separator: str = "\n\n") -> str: """按 order 拼回完整 Markdown。""" return separator.join(b.content for b in sorted(blocks, key=lambda x: x.order)) def chapter_stem_from_title(title: str) -> str: """从 `# Chapter 1 — foo bar` 生成文件名 stem 如 `ch01`。找不到就 fallback。""" m = re.search(r"chapter\s+(\d+)", title, re.IGNORECASE) if m: return f"ch{int(m.group(1)):02d}" # Executive Summary / Abstract / Glossary / Disclaimer / References / Version History slug = re.sub(r"[^a-z0-9]+", "-", title.lower()).strip("-") return slug[:40] or "untitled" def count_chinese_chars(text: str) -> int: return sum(1 for c in text if "\u4e00" <= c <= "\u9fff") if __name__ == "__main__": import sys src = Path(sys.argv[1]) for b in split_by_headers(src.read_text(encoding="utf-8")): print(f"[{b.order:3d}] L{b.level} {b.word_count:>5}w {b.char_count:>6}c {b.short_title}")