mdstyledocx 0.1.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 YANG Zijie (杨子杰)
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,147 @@
1
+ Metadata-Version: 2.4
2
+ Name: mdstyledocx
3
+ Version: 0.1.0
4
+ Summary: Convert convention-based Markdown into standardized DOCX with reusable style presets.
5
+ Author: YANG Zijie
6
+ License: MIT
7
+ Requires-Python: >=3.11
8
+ Description-Content-Type: text/markdown
9
+ License-File: LICENSE
10
+ Requires-Dist: python-docx>=1.2.0
11
+ Dynamic: license-file
12
+
13
+ # mdstyledocx
14
+
15
+ 一个按约定编写 Markdown、再一键导出标准化 Word (`.docx`) 的小工具。
16
+
17
+ 当前设计重点不是“完整支持所有 Markdown 语法”,而是“稳定地把结构化 Markdown 落成统一版式的 Word 文档”。它适合做:
18
+
19
+ - 政府公文预设
20
+ - 单位通知 / 简报 / 汇报材料
21
+ - 团队内部统一模板
22
+
23
+ ## 核心思路
24
+
25
+ 把 Markdown 当成“内容源”,把版式规范抽成“preset”。
26
+
27
+ 你只要按固定约定写 Markdown:
28
+
29
+ - `#`:文档标题
30
+ - `##`:一级标题
31
+ - `###`:二级标题
32
+ - 空行分段
33
+ - `-` / `*` / `+`:无序列表
34
+ - `1.`:有序列表
35
+ - `<!-- pagebreak -->`:分页
36
+
37
+ 然后执行一次命令,就能得到带统一字体、字号、缩进、页边距的 `.docx`。
38
+
39
+ ## 内置预设
40
+
41
+ 各 preset 的详细说明都放在 `src/mdstyledocx/preset_specs/` 下:
42
+
43
+ - [default](src/mdstyledocx/preset_specs/default.md):通用正式文档版式
44
+ - [gov-cn](src/mdstyledocx/preset_specs/gov-cn.md):中文政府公文风格基线
45
+ - [gov-cn-hei](src/mdstyledocx/preset_specs/gov-cn-hei.md):黑体标题、楷体二级标题、仿宋正文
46
+
47
+ 目录约定:
48
+
49
+ - `*.json`:样式参数、页边距、字体、缩进等机器可读定义
50
+ - `*.md`:该模板的写作约定、推荐语法和边界说明
51
+
52
+ ## 使用方式
53
+
54
+ 推荐直接用 `uv`:
55
+
56
+ ```bash
57
+ uv sync
58
+ uv run python -m unittest
59
+ uv run mdstyledocx --list-presets
60
+ uv run mdstyledocx --show-preset-rules gov-cn
61
+ uv run mdstyledocx examples/gov_notice.md -o examples/gov_notice.docx --preset gov-cn
62
+ ```
63
+
64
+ 如果不使用 `uv`,也可以用传统方式:
65
+
66
+ ```bash
67
+ pip install -e .
68
+ python3 -m unittest
69
+ mdstyledocx examples/gov_notice.md -o examples/gov_notice.docx --preset gov-cn
70
+ ```
71
+
72
+ ## 自定义预设
73
+
74
+ 可以在内置 preset 基础上再叠加一个 JSON 覆盖文件:
75
+
76
+ ```bash
77
+ mdstyledocx input.md -o output.docx --preset gov-cn --preset-file my-preset.json
78
+ ```
79
+
80
+ 如果想先看某个模板要求什么 Markdown 写法:
81
+
82
+ ```bash
83
+ mdstyledocx --show-preset-rules gov-cn
84
+ ```
85
+
86
+ 示例:
87
+
88
+ ```json
89
+ {
90
+ "extends": "gov-cn",
91
+ "styles": {
92
+ "body": {
93
+ "line": 520,
94
+ "line_rule": "exact"
95
+ },
96
+ "title": {
97
+ "size_half_points": 40
98
+ }
99
+ }
100
+ }
101
+ ```
102
+
103
+ ## Markdown 约定
104
+
105
+ README 只保留通用约定。某个 preset 的专用写法,以对应的 `preset_specs/*.md` 为准。
106
+
107
+ 通用写法:
108
+
109
+ ```md
110
+ # 关于开展示例工作的通知
111
+
112
+ 各有关单位:
113
+
114
+ 为统一输出格式,现将有关事项通知如下。
115
+
116
+ ## 一、工作目标
117
+
118
+ 1. 统一内容源。
119
+ 2. 统一输出格式。
120
+
121
+ ## 二、工作要求
122
+
123
+ 请各单位按要求执行。
124
+ ```
125
+
126
+ ## 当前边界
127
+
128
+ 当前版本优先保证:
129
+
130
+ - 标题、段落、列表、分页可稳定导出
131
+ - 预设版式可复用
132
+ - 产物是标准 `.docx`
133
+
134
+ 暂未覆盖:
135
+
136
+ - 表格
137
+ - 图片
138
+ - 脚注
139
+ - 复杂嵌套列表
140
+ - 目录自动生成
141
+
142
+ 如果后面继续做,这个工具可以自然扩展成:
143
+
144
+ - 多个行业 preset 集合
145
+ - frontmatter 驱动的页面元信息
146
+ - 更完整的 Markdown 语法支持
147
+ - GUI 或 Web 包装层
@@ -0,0 +1,135 @@
1
+ # mdstyledocx
2
+
3
+ 一个按约定编写 Markdown、再一键导出标准化 Word (`.docx`) 的小工具。
4
+
5
+ 当前设计重点不是“完整支持所有 Markdown 语法”,而是“稳定地把结构化 Markdown 落成统一版式的 Word 文档”。它适合做:
6
+
7
+ - 政府公文预设
8
+ - 单位通知 / 简报 / 汇报材料
9
+ - 团队内部统一模板
10
+
11
+ ## 核心思路
12
+
13
+ 把 Markdown 当成“内容源”,把版式规范抽成“preset”。
14
+
15
+ 你只要按固定约定写 Markdown:
16
+
17
+ - `#`:文档标题
18
+ - `##`:一级标题
19
+ - `###`:二级标题
20
+ - 空行分段
21
+ - `-` / `*` / `+`:无序列表
22
+ - `1.`:有序列表
23
+ - `<!-- pagebreak -->`:分页
24
+
25
+ 然后执行一次命令,就能得到带统一字体、字号、缩进、页边距的 `.docx`。
26
+
27
+ ## 内置预设
28
+
29
+ 各 preset 的详细说明都放在 `src/mdstyledocx/preset_specs/` 下:
30
+
31
+ - [default](src/mdstyledocx/preset_specs/default.md):通用正式文档版式
32
+ - [gov-cn](src/mdstyledocx/preset_specs/gov-cn.md):中文政府公文风格基线
33
+ - [gov-cn-hei](src/mdstyledocx/preset_specs/gov-cn-hei.md):黑体标题、楷体二级标题、仿宋正文
34
+
35
+ 目录约定:
36
+
37
+ - `*.json`:样式参数、页边距、字体、缩进等机器可读定义
38
+ - `*.md`:该模板的写作约定、推荐语法和边界说明
39
+
40
+ ## 使用方式
41
+
42
+ 推荐直接用 `uv`:
43
+
44
+ ```bash
45
+ uv sync
46
+ uv run python -m unittest
47
+ uv run mdstyledocx --list-presets
48
+ uv run mdstyledocx --show-preset-rules gov-cn
49
+ uv run mdstyledocx examples/gov_notice.md -o examples/gov_notice.docx --preset gov-cn
50
+ ```
51
+
52
+ 如果不使用 `uv`,也可以用传统方式:
53
+
54
+ ```bash
55
+ pip install -e .
56
+ python3 -m unittest
57
+ mdstyledocx examples/gov_notice.md -o examples/gov_notice.docx --preset gov-cn
58
+ ```
59
+
60
+ ## 自定义预设
61
+
62
+ 可以在内置 preset 基础上再叠加一个 JSON 覆盖文件:
63
+
64
+ ```bash
65
+ mdstyledocx input.md -o output.docx --preset gov-cn --preset-file my-preset.json
66
+ ```
67
+
68
+ 如果想先看某个模板要求什么 Markdown 写法:
69
+
70
+ ```bash
71
+ mdstyledocx --show-preset-rules gov-cn
72
+ ```
73
+
74
+ 示例:
75
+
76
+ ```json
77
+ {
78
+ "extends": "gov-cn",
79
+ "styles": {
80
+ "body": {
81
+ "line": 520,
82
+ "line_rule": "exact"
83
+ },
84
+ "title": {
85
+ "size_half_points": 40
86
+ }
87
+ }
88
+ }
89
+ ```
90
+
91
+ ## Markdown 约定
92
+
93
+ README 只保留通用约定。某个 preset 的专用写法,以对应的 `preset_specs/*.md` 为准。
94
+
95
+ 通用写法:
96
+
97
+ ```md
98
+ # 关于开展示例工作的通知
99
+
100
+ 各有关单位:
101
+
102
+ 为统一输出格式,现将有关事项通知如下。
103
+
104
+ ## 一、工作目标
105
+
106
+ 1. 统一内容源。
107
+ 2. 统一输出格式。
108
+
109
+ ## 二、工作要求
110
+
111
+ 请各单位按要求执行。
112
+ ```
113
+
114
+ ## 当前边界
115
+
116
+ 当前版本优先保证:
117
+
118
+ - 标题、段落、列表、分页可稳定导出
119
+ - 预设版式可复用
120
+ - 产物是标准 `.docx`
121
+
122
+ 暂未覆盖:
123
+
124
+ - 表格
125
+ - 图片
126
+ - 脚注
127
+ - 复杂嵌套列表
128
+ - 目录自动生成
129
+
130
+ 如果后面继续做,这个工具可以自然扩展成:
131
+
132
+ - 多个行业 preset 集合
133
+ - frontmatter 驱动的页面元信息
134
+ - 更完整的 Markdown 语法支持
135
+ - GUI 或 Web 包装层
@@ -0,0 +1,27 @@
1
+ [build-system]
2
+ requires = ["setuptools>=69"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "mdstyledocx"
7
+ version = "0.1.0"
8
+ description = "Convert convention-based Markdown into standardized DOCX with reusable style presets."
9
+ readme = "README.md"
10
+ requires-python = ">=3.11"
11
+ authors = [{ name = "YANG Zijie" }]
12
+ license = { text = "MIT" }
13
+ dependencies = [
14
+ "python-docx>=1.2.0",
15
+ ]
16
+
17
+ [project.scripts]
18
+ mdstyledocx = "mdstyledocx.cli:main"
19
+
20
+ [tool.setuptools]
21
+ package-dir = {"" = "src"}
22
+
23
+ [tool.setuptools.packages.find]
24
+ where = ["src"]
25
+
26
+ [tool.setuptools.package-data]
27
+ mdstyledocx = ["preset_specs/*.json", "preset_specs/*.md"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,5 @@
1
+ """mdstyledocx package."""
2
+
3
+ __all__ = ["__version__"]
4
+
5
+ __version__ = "0.1.0"
@@ -0,0 +1,5 @@
1
+ from mdstyledocx.cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
@@ -0,0 +1,74 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import sys
5
+ from pathlib import Path
6
+ from typing import Sequence
7
+
8
+ from mdstyledocx.docx_writer import build_docx
9
+ from mdstyledocx.markdown import parse_markdown
10
+ from mdstyledocx.presets import list_presets, load_preset, load_preset_rules
11
+
12
+
13
+ def main(argv: Sequence[str] | None = None) -> int:
14
+ parser = argparse.ArgumentParser(
15
+ prog="mdstyledocx",
16
+ description="Convert convention-based Markdown into standardized DOCX using reusable presets.",
17
+ )
18
+ parser.add_argument("input", nargs="?", help="Input Markdown file path, or '-' to read from stdin.")
19
+ parser.add_argument("-o", "--output", help="Output DOCX file path.")
20
+ parser.add_argument(
21
+ "--preset",
22
+ default="default",
23
+ help="Built-in preset name. Use --list-presets to inspect available values.",
24
+ )
25
+ parser.add_argument(
26
+ "--preset-file",
27
+ type=Path,
28
+ help="Optional JSON override file to extend or replace parts of a preset.",
29
+ )
30
+ parser.add_argument(
31
+ "--list-presets",
32
+ action="store_true",
33
+ help="Print built-in presets and exit.",
34
+ )
35
+ parser.add_argument(
36
+ "--show-preset-rules",
37
+ metavar="PRESET",
38
+ help="Print the Markdown conventions and style notes for a built-in preset, then exit.",
39
+ )
40
+
41
+ args = parser.parse_args(list(argv) if argv is not None else None)
42
+
43
+ if args.list_presets:
44
+ for name, description in list_presets():
45
+ print(f"{name}: {description}")
46
+ return 0
47
+
48
+ if args.show_preset_rules:
49
+ print(load_preset_rules(args.show_preset_rules).rstrip())
50
+ return 0
51
+
52
+ if not args.input:
53
+ parser.error("an input Markdown file is required unless --list-presets is used")
54
+
55
+ if args.input == "-":
56
+ markdown_text = sys.stdin.read()
57
+ if not args.output:
58
+ parser.error("--output is required when reading Markdown from stdin")
59
+ output_path = Path(args.output)
60
+ else:
61
+ input_path = Path(args.input)
62
+ markdown_text = input_path.read_text(encoding="utf-8")
63
+ output_path = Path(args.output) if args.output else input_path.with_suffix(".docx")
64
+
65
+ preset = load_preset(args.preset, args.preset_file)
66
+ base_path = Path.cwd() if args.input == "-" else input_path.parent
67
+ document = parse_markdown(markdown_text, base_path=base_path)
68
+ output_path.write_bytes(build_docx(document, preset))
69
+ print(f"Wrote {output_path} with preset '{preset.name}'")
70
+ return 0
71
+
72
+
73
+ if __name__ == "__main__":
74
+ raise SystemExit(main())
@@ -0,0 +1,253 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass, field, replace
6
+ from datetime import datetime, timezone
7
+ from io import BytesIO
8
+
9
+ from docx import Document as WordDocument
10
+ from docx.document import Document as WordprocessingDocument
11
+ from docx.enum.text import WD_ALIGN_PARAGRAPH, WD_LINE_SPACING
12
+ from docx.oxml.ns import qn
13
+ from docx.shared import Pt, Twips
14
+
15
+ from mdstyledocx.model import Block, Document, ImageSpan, InlineElement, InlineSpan
16
+ from mdstyledocx.presets import Preset, Style
17
+
18
+
19
+ @dataclass
20
+ class BuildState:
21
+ preset: Preset
22
+ heading_counters: dict[int, int] = field(default_factory=dict)
23
+
24
+
25
+ def build_docx(document: Document, preset: Preset) -> bytes:
26
+ word_document = WordDocument()
27
+ _configure_document(word_document, preset)
28
+ _set_core_properties(word_document, _document_title(document))
29
+
30
+ state = BuildState(preset=preset)
31
+ for block in document.blocks:
32
+ _append_block(word_document, block, state)
33
+
34
+ buffer = BytesIO()
35
+ word_document.save(buffer)
36
+ return buffer.getvalue()
37
+
38
+
39
+ def _configure_document(word_document: WordprocessingDocument, preset: Preset) -> None:
40
+ section = word_document.sections[0]
41
+ section.page_width = Twips(preset.page.width)
42
+ section.page_height = Twips(preset.page.height)
43
+ section.top_margin = Twips(preset.page.margin_top)
44
+ section.right_margin = Twips(preset.page.margin_right)
45
+ section.bottom_margin = Twips(preset.page.margin_bottom)
46
+ section.left_margin = Twips(preset.page.margin_left)
47
+ section.header_distance = Twips(preset.page.header)
48
+ section.footer_distance = Twips(preset.page.footer)
49
+ section.gutter = Twips(preset.page.gutter)
50
+
51
+
52
+ def _set_core_properties(word_document: WordprocessingDocument, title: str) -> None:
53
+ properties = word_document.core_properties
54
+ properties.title = title
55
+ properties.author = "mdstyledocx"
56
+ properties.last_modified_by = "mdstyledocx"
57
+ now = datetime.now(timezone.utc).replace(microsecond=0, tzinfo=None)
58
+ properties.created = now
59
+ properties.modified = now
60
+
61
+
62
+ def _document_title(document: Document) -> str:
63
+ if document.metadata.get("title"):
64
+ return document.metadata["title"]
65
+ for block in document.blocks:
66
+ if block.kind == "heading" and block.level == 1:
67
+ return "".join(span.text for span in block.spans if isinstance(span, InlineSpan)).strip()
68
+ return "Document"
69
+
70
+
71
+ def _append_block(word_document: WordprocessingDocument, block: Block, state: BuildState) -> None:
72
+ if block.kind == "page_break":
73
+ word_document.add_page_break()
74
+ return
75
+
76
+ rendered_spans = _rendered_spans(block, state)
77
+ style = _resolve_style(block, state.preset)
78
+ if _spans_have_image(rendered_spans):
79
+ style = replace(style, line=240, line_rule="auto")
80
+
81
+ paragraph = word_document.add_paragraph()
82
+ _apply_paragraph_style(paragraph, style)
83
+
84
+ if block.kind == "list_item":
85
+ prefix = "• " if block.list_kind == "bullet" else f"{block.number}. "
86
+ _add_text_run(paragraph, InlineSpan(text=prefix), style)
87
+
88
+ for span in rendered_spans:
89
+ if isinstance(span, ImageSpan):
90
+ _add_image_run(paragraph, span, state)
91
+ elif span.text:
92
+ _add_text_run(paragraph, span, style)
93
+
94
+ if not paragraph.runs:
95
+ paragraph.add_run("")
96
+
97
+
98
+ def _resolve_style(block: Block, preset: Preset) -> Style:
99
+ if block.kind == "heading":
100
+ key = {1: "title", 2: "heading1", 3: "heading2"}.get(block.level, "heading3")
101
+ return preset.styles[key]
102
+
103
+ base = preset.styles["body"]
104
+ if block.kind == "list_item":
105
+ left_indent = (
106
+ preset.list_settings.base_left_indent
107
+ + block.list_level * preset.list_settings.level_step
108
+ )
109
+ return replace(
110
+ base,
111
+ first_line_indent=0,
112
+ left_indent=left_indent,
113
+ hanging=preset.list_settings.hanging,
114
+ )
115
+
116
+ return base
117
+
118
+
119
+ def _apply_paragraph_style(paragraph, style: Style) -> None:
120
+ alignment_map = {
121
+ "left": WD_ALIGN_PARAGRAPH.LEFT,
122
+ "center": WD_ALIGN_PARAGRAPH.CENTER,
123
+ "right": WD_ALIGN_PARAGRAPH.RIGHT,
124
+ "justify": WD_ALIGN_PARAGRAPH.JUSTIFY,
125
+ "both": WD_ALIGN_PARAGRAPH.JUSTIFY,
126
+ }
127
+ if style.align:
128
+ paragraph.alignment = alignment_map[style.align]
129
+
130
+ paragraph_format = paragraph.paragraph_format
131
+ paragraph_format.space_before = Twips(style.spacing_before)
132
+ paragraph_format.space_after = Twips(style.spacing_after)
133
+ paragraph_format.left_indent = Twips(style.left_indent)
134
+
135
+ if style.hanging:
136
+ paragraph_format.first_line_indent = Twips(-style.hanging)
137
+ else:
138
+ paragraph_format.first_line_indent = Twips(style.first_line_indent)
139
+
140
+ if style.line_rule == "exact":
141
+ paragraph_format.line_spacing = Pt(style.line / 20)
142
+ paragraph_format.line_spacing_rule = WD_LINE_SPACING.EXACTLY
143
+ elif style.line_rule == "auto":
144
+ paragraph_format.line_spacing = style.line / 240
145
+ else:
146
+ paragraph_format.line_spacing = style.line / 240
147
+
148
+
149
+ def _add_text_run(paragraph, span: InlineSpan, style: Style) -> None:
150
+ run = paragraph.add_run(span.text)
151
+ font_ascii = style.font_ascii
152
+ font_east_asia = style.font_east_asia
153
+ bold = style.bold or span.bold
154
+ italic = style.italic or span.italic
155
+
156
+ if span.code:
157
+ font_ascii = "Consolas"
158
+ font_east_asia = "等线"
159
+
160
+ run.font.name = font_ascii
161
+ run.font.size = Pt(style.size_half_points / 2)
162
+ run.bold = bold
163
+ run.italic = italic
164
+
165
+ r_fonts = run._element.get_or_add_rPr().get_or_add_rFonts()
166
+ r_fonts.set(qn("w:ascii"), font_ascii)
167
+ r_fonts.set(qn("w:hAnsi"), font_ascii)
168
+ r_fonts.set(qn("w:eastAsia"), font_east_asia)
169
+
170
+
171
+ def _add_image_run(paragraph, span: ImageSpan, state: BuildState) -> None:
172
+ run = paragraph.add_run()
173
+ inline_shape = run.add_picture(span.path)
174
+ max_width = _max_image_width(state.preset)
175
+ if inline_shape.width > max_width:
176
+ scale = max_width / inline_shape.width
177
+ inline_shape.width = max_width
178
+ inline_shape.height = int(inline_shape.height * scale)
179
+
180
+ description = span.alt_text or os.path.basename(span.path)
181
+ inline_shape._inline.docPr.set("descr", description)
182
+ inline_shape._inline.docPr.set("name", description)
183
+
184
+
185
+ def _max_image_width(preset: Preset) -> int:
186
+ return Twips(preset.page.width - preset.page.margin_left - preset.page.margin_right)
187
+
188
+
189
+ def _spans_have_image(spans: list[InlineElement]) -> bool:
190
+ return any(isinstance(span, ImageSpan) for span in spans)
191
+
192
+
193
+ def _rendered_spans(block: Block, state: BuildState) -> list[InlineElement]:
194
+ if block.kind != "heading":
195
+ return block.spans
196
+
197
+ scheme = state.preset.heading_numbering.get(block.level)
198
+ if not scheme:
199
+ return block.spans
200
+
201
+ _advance_heading_counters(state, block.level)
202
+ if _has_number_prefix(block.spans, scheme):
203
+ return block.spans
204
+
205
+ prefix = _format_heading_prefix(scheme, state.heading_counters[block.level])
206
+ return [InlineSpan(text=prefix)] + block.spans
207
+
208
+
209
+ def _advance_heading_counters(state: BuildState, level: int) -> None:
210
+ state.heading_counters[level] = state.heading_counters.get(level, 0) + 1
211
+ for deeper_level in list(state.heading_counters):
212
+ if deeper_level > level:
213
+ state.heading_counters[deeper_level] = 0
214
+
215
+
216
+ def _has_number_prefix(spans: list[InlineElement], scheme: str) -> bool:
217
+ text = "".join(span.text for span in spans if isinstance(span, InlineSpan)).lstrip()
218
+ patterns = {
219
+ "cn-section": r"^[一二三四五六七八九十百千万零〇两]+、",
220
+ "cn-paren": r"^([一二三四五六七八九十百千万零〇两]+)",
221
+ "arabic-dot": r"^\d+[..]\s*",
222
+ }
223
+ return re.match(patterns[scheme], text) is not None
224
+
225
+
226
+ def _format_heading_prefix(scheme: str, number: int) -> str:
227
+ if scheme == "cn-section":
228
+ return f"{_to_chinese_number(number)}、"
229
+ if scheme == "cn-paren":
230
+ return f"({_to_chinese_number(number)})"
231
+ if scheme == "arabic-dot":
232
+ return f"{number}. "
233
+ raise ValueError(f"Unsupported heading numbering scheme: {scheme}")
234
+
235
+
236
+ def _to_chinese_number(number: int) -> str:
237
+ digits = "零一二三四五六七八九"
238
+ units = ["", "十", "百", "千"]
239
+ raw = str(number)
240
+ parts: list[str] = []
241
+
242
+ for index, char in enumerate(raw):
243
+ digit = int(char)
244
+ unit_index = len(raw) - index - 1
245
+ if digit == 0:
246
+ if parts and parts[-1] != "零" and any(next_char != "0" for next_char in raw[index + 1 :]):
247
+ parts.append("零")
248
+ continue
249
+ if not (digit == 1 and unit_index == 1 and not parts and len(raw) == 2):
250
+ parts.append(digits[digit])
251
+ parts.append(units[unit_index])
252
+
253
+ return "".join(parts) or "零"