mdstyledocx 0.1.0__py3-none-any.whl
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.
- mdstyledocx/__init__.py +5 -0
- mdstyledocx/__main__.py +5 -0
- mdstyledocx/cli.py +74 -0
- mdstyledocx/docx_writer.py +253 -0
- mdstyledocx/markdown.py +187 -0
- mdstyledocx/model.py +36 -0
- mdstyledocx/preset_specs/default.json +63 -0
- mdstyledocx/preset_specs/default.md +35 -0
- mdstyledocx/preset_specs/gov-cn-hei.json +69 -0
- mdstyledocx/preset_specs/gov-cn-hei.md +57 -0
- mdstyledocx/preset_specs/gov-cn.json +69 -0
- mdstyledocx/preset_specs/gov-cn.md +60 -0
- mdstyledocx/presets.py +128 -0
- mdstyledocx-0.1.0.dist-info/METADATA +147 -0
- mdstyledocx-0.1.0.dist-info/RECORD +19 -0
- mdstyledocx-0.1.0.dist-info/WHEEL +5 -0
- mdstyledocx-0.1.0.dist-info/entry_points.txt +2 -0
- mdstyledocx-0.1.0.dist-info/licenses/LICENSE +21 -0
- mdstyledocx-0.1.0.dist-info/top_level.txt +1 -0
mdstyledocx/__init__.py
ADDED
mdstyledocx/__main__.py
ADDED
mdstyledocx/cli.py
ADDED
|
@@ -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 "零"
|
mdstyledocx/markdown.py
ADDED
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
from mdstyledocx.model import Block, Document, ImageSpan, InlineElement, InlineSpan
|
|
7
|
+
|
|
8
|
+
HEADING_RE = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
9
|
+
BULLET_RE = re.compile(r"^(\s*)[-*+]\s+(.*?)\s*$")
|
|
10
|
+
ORDERED_RE = re.compile(r"^(\s*)(\d+)\.\s+(.*?)\s*$")
|
|
11
|
+
INLINE_TOKEN_RE = re.compile(r"(!\[[^\]]*]\([^)]+\)|\*\*[^*]+\*\*|\*[^*]+\*|`[^`]+`)")
|
|
12
|
+
IMAGE_RE = re.compile(r"^!\[([^\]]*)\]\(([^)]+)\)$")
|
|
13
|
+
PAGEBREAK_MARKERS = {"<!-- pagebreak -->", "<!--pagebreak-->", "\f", "\\f"}
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def parse_markdown(text: str, base_path: Path | None = None) -> Document:
|
|
17
|
+
normalized = text.replace("\r\n", "\n").replace("\r", "\n").lstrip("\ufeff")
|
|
18
|
+
lines = normalized.split("\n")
|
|
19
|
+
metadata, body_lines = _parse_frontmatter(lines)
|
|
20
|
+
blocks = _parse_blocks(body_lines, base_path)
|
|
21
|
+
|
|
22
|
+
has_title = any(block.kind == "heading" and block.level == 1 for block in blocks)
|
|
23
|
+
if metadata.get("title") and not has_title:
|
|
24
|
+
blocks.insert(0, Block(kind="heading", level=1, spans=parse_inline(metadata["title"], base_path)))
|
|
25
|
+
|
|
26
|
+
return Document(metadata=metadata, blocks=blocks)
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def parse_inline(text: str, base_path: Path | None = None) -> list[InlineElement]:
|
|
30
|
+
spans: list[InlineElement] = []
|
|
31
|
+
for token in INLINE_TOKEN_RE.split(text):
|
|
32
|
+
if not token:
|
|
33
|
+
continue
|
|
34
|
+
image_match = IMAGE_RE.match(token)
|
|
35
|
+
if image_match:
|
|
36
|
+
spans.append(
|
|
37
|
+
ImageSpan(
|
|
38
|
+
path=_resolve_asset_path(image_match.group(2), base_path),
|
|
39
|
+
alt_text=image_match.group(1),
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
elif token.startswith("**") and token.endswith("**") and len(token) > 4:
|
|
43
|
+
spans.append(InlineSpan(text=token[2:-2], bold=True))
|
|
44
|
+
elif token.startswith("*") and token.endswith("*") and len(token) > 2:
|
|
45
|
+
spans.append(InlineSpan(text=token[1:-1], italic=True))
|
|
46
|
+
elif token.startswith("`") and token.endswith("`") and len(token) > 2:
|
|
47
|
+
spans.append(InlineSpan(text=token[1:-1], code=True))
|
|
48
|
+
else:
|
|
49
|
+
spans.append(InlineSpan(text=token))
|
|
50
|
+
return spans or [InlineSpan(text="")]
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _parse_frontmatter(lines: list[str]) -> tuple[dict[str, str], list[str]]:
|
|
54
|
+
if not lines or lines[0].strip() != "---":
|
|
55
|
+
return {}, lines
|
|
56
|
+
|
|
57
|
+
metadata: dict[str, str] = {}
|
|
58
|
+
for index in range(1, len(lines)):
|
|
59
|
+
current = lines[index].strip()
|
|
60
|
+
if current == "---":
|
|
61
|
+
return metadata, lines[index + 1 :]
|
|
62
|
+
if ":" not in lines[index]:
|
|
63
|
+
return {}, lines
|
|
64
|
+
key, value = lines[index].split(":", 1)
|
|
65
|
+
metadata[key.strip()] = value.strip()
|
|
66
|
+
|
|
67
|
+
return {}, lines
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _parse_blocks(lines: list[str], base_path: Path | None) -> list[Block]:
|
|
71
|
+
blocks: list[Block] = []
|
|
72
|
+
index = 0
|
|
73
|
+
|
|
74
|
+
while index < len(lines):
|
|
75
|
+
raw = lines[index]
|
|
76
|
+
stripped = raw.strip()
|
|
77
|
+
|
|
78
|
+
if not stripped:
|
|
79
|
+
index += 1
|
|
80
|
+
continue
|
|
81
|
+
|
|
82
|
+
if stripped in PAGEBREAK_MARKERS:
|
|
83
|
+
blocks.append(Block(kind="page_break"))
|
|
84
|
+
index += 1
|
|
85
|
+
continue
|
|
86
|
+
|
|
87
|
+
heading_match = HEADING_RE.match(raw)
|
|
88
|
+
if heading_match:
|
|
89
|
+
blocks.append(
|
|
90
|
+
Block(
|
|
91
|
+
kind="heading",
|
|
92
|
+
level=len(heading_match.group(1)),
|
|
93
|
+
spans=parse_inline(heading_match.group(2), base_path),
|
|
94
|
+
)
|
|
95
|
+
)
|
|
96
|
+
index += 1
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
bullet_match = BULLET_RE.match(raw)
|
|
100
|
+
if bullet_match:
|
|
101
|
+
blocks.append(
|
|
102
|
+
Block(
|
|
103
|
+
kind="list_item",
|
|
104
|
+
list_kind="bullet",
|
|
105
|
+
list_level=len(bullet_match.group(1).replace("\t", " ")) // 2,
|
|
106
|
+
spans=parse_inline(bullet_match.group(2), base_path),
|
|
107
|
+
)
|
|
108
|
+
)
|
|
109
|
+
index += 1
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
ordered_match = ORDERED_RE.match(raw)
|
|
113
|
+
if ordered_match:
|
|
114
|
+
blocks.append(
|
|
115
|
+
Block(
|
|
116
|
+
kind="list_item",
|
|
117
|
+
list_kind="ordered",
|
|
118
|
+
list_level=len(ordered_match.group(1).replace("\t", " ")) // 2,
|
|
119
|
+
number=int(ordered_match.group(2)),
|
|
120
|
+
spans=parse_inline(ordered_match.group(3), base_path),
|
|
121
|
+
)
|
|
122
|
+
)
|
|
123
|
+
index += 1
|
|
124
|
+
continue
|
|
125
|
+
|
|
126
|
+
paragraph_lines: list[str] = []
|
|
127
|
+
while index < len(lines) and not _starts_new_block(lines[index]):
|
|
128
|
+
paragraph_lines.append(_strip_blockquote(lines[index].rstrip()))
|
|
129
|
+
index += 1
|
|
130
|
+
|
|
131
|
+
blocks.append(
|
|
132
|
+
Block(kind="paragraph", spans=parse_inline(_join_paragraph_lines(paragraph_lines), base_path))
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
return blocks
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _starts_new_block(line: str) -> bool:
|
|
139
|
+
stripped = line.strip()
|
|
140
|
+
if not stripped:
|
|
141
|
+
return True
|
|
142
|
+
if stripped in PAGEBREAK_MARKERS:
|
|
143
|
+
return True
|
|
144
|
+
if HEADING_RE.match(line):
|
|
145
|
+
return True
|
|
146
|
+
if BULLET_RE.match(line):
|
|
147
|
+
return True
|
|
148
|
+
if ORDERED_RE.match(line):
|
|
149
|
+
return True
|
|
150
|
+
return False
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _strip_blockquote(line: str) -> str:
|
|
154
|
+
stripped = line.lstrip()
|
|
155
|
+
if stripped.startswith(">"):
|
|
156
|
+
return stripped[1:].lstrip()
|
|
157
|
+
return line.strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _join_paragraph_lines(lines: list[str]) -> str:
|
|
161
|
+
if not lines:
|
|
162
|
+
return ""
|
|
163
|
+
|
|
164
|
+
result = lines[0].strip()
|
|
165
|
+
for line in lines[1:]:
|
|
166
|
+
candidate = line.strip()
|
|
167
|
+
if not candidate:
|
|
168
|
+
continue
|
|
169
|
+
if _needs_space(result[-1], candidate[0]):
|
|
170
|
+
result += " " + candidate
|
|
171
|
+
else:
|
|
172
|
+
result += candidate
|
|
173
|
+
return result
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _needs_space(previous_char: str, next_char: str) -> bool:
|
|
177
|
+
return previous_char.isascii() and next_char.isascii() and (
|
|
178
|
+
previous_char.isalnum() or previous_char in {")", "]"}
|
|
179
|
+
) and (next_char.isalnum() or next_char in {"(", "["})
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _resolve_asset_path(raw_path: str, base_path: Path | None) -> str:
|
|
183
|
+
candidate = raw_path.strip().strip("<>").strip()
|
|
184
|
+
path = Path(candidate)
|
|
185
|
+
if path.is_absolute() or base_path is None:
|
|
186
|
+
return str(path)
|
|
187
|
+
return str((base_path / path).resolve())
|
mdstyledocx/model.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass
|
|
7
|
+
class InlineSpan:
|
|
8
|
+
text: str
|
|
9
|
+
bold: bool = False
|
|
10
|
+
italic: bool = False
|
|
11
|
+
code: bool = False
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass
|
|
15
|
+
class ImageSpan:
|
|
16
|
+
path: str
|
|
17
|
+
alt_text: str = ""
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
InlineElement = InlineSpan | ImageSpan
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Block:
|
|
25
|
+
kind: str
|
|
26
|
+
spans: list[InlineElement] = field(default_factory=list)
|
|
27
|
+
level: int = 0
|
|
28
|
+
list_kind: str | None = None
|
|
29
|
+
list_level: int = 0
|
|
30
|
+
number: int | None = None
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class Document:
|
|
35
|
+
metadata: dict[str, str] = field(default_factory=dict)
|
|
36
|
+
blocks: list[Block] = field(default_factory=list)
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "default",
|
|
3
|
+
"description": "General-purpose clean document preset",
|
|
4
|
+
"page": {
|
|
5
|
+
"width": 11907,
|
|
6
|
+
"height": 16838,
|
|
7
|
+
"margin_top": 1440,
|
|
8
|
+
"margin_right": 1440,
|
|
9
|
+
"margin_bottom": 1440,
|
|
10
|
+
"margin_left": 1440
|
|
11
|
+
},
|
|
12
|
+
"styles": {
|
|
13
|
+
"title": {
|
|
14
|
+
"font_east_asia": "等线",
|
|
15
|
+
"font_ascii": "Calibri",
|
|
16
|
+
"size_half_points": 32,
|
|
17
|
+
"bold": true,
|
|
18
|
+
"align": "center",
|
|
19
|
+
"spacing_after": 240,
|
|
20
|
+
"line": 240
|
|
21
|
+
},
|
|
22
|
+
"heading1": {
|
|
23
|
+
"font_east_asia": "等线",
|
|
24
|
+
"font_ascii": "Calibri",
|
|
25
|
+
"size_half_points": 28,
|
|
26
|
+
"bold": true,
|
|
27
|
+
"spacing_before": 120,
|
|
28
|
+
"spacing_after": 120,
|
|
29
|
+
"line": 276
|
|
30
|
+
},
|
|
31
|
+
"heading2": {
|
|
32
|
+
"font_east_asia": "等线",
|
|
33
|
+
"font_ascii": "Calibri",
|
|
34
|
+
"size_half_points": 24,
|
|
35
|
+
"bold": true,
|
|
36
|
+
"spacing_before": 120,
|
|
37
|
+
"spacing_after": 80,
|
|
38
|
+
"line": 276
|
|
39
|
+
},
|
|
40
|
+
"heading3": {
|
|
41
|
+
"font_east_asia": "宋体",
|
|
42
|
+
"font_ascii": "Calibri",
|
|
43
|
+
"size_half_points": 22,
|
|
44
|
+
"bold": true,
|
|
45
|
+
"spacing_before": 80,
|
|
46
|
+
"spacing_after": 60,
|
|
47
|
+
"line": 276
|
|
48
|
+
},
|
|
49
|
+
"body": {
|
|
50
|
+
"font_east_asia": "宋体",
|
|
51
|
+
"font_ascii": "Calibri",
|
|
52
|
+
"size_half_points": 22,
|
|
53
|
+
"line": 276,
|
|
54
|
+
"line_rule": "auto",
|
|
55
|
+
"spacing_after": 120
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
"list_settings": {
|
|
59
|
+
"base_left_indent": 720,
|
|
60
|
+
"hanging": 360,
|
|
61
|
+
"level_step": 360
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# `default` preset
|
|
2
|
+
|
|
3
|
+
## 适用场景
|
|
4
|
+
|
|
5
|
+
通用正式文档,不强调行业模板约束,适合先把内容稳定导出成干净的 Word 版式。
|
|
6
|
+
|
|
7
|
+
## 样式说明
|
|
8
|
+
|
|
9
|
+
- 页面大小:A4
|
|
10
|
+
- 标题:居中、等线、较大字号
|
|
11
|
+
- 正文:宋体,常规正式文档风格
|
|
12
|
+
- 一级到三级标题:按层级逐步缩小字号
|
|
13
|
+
|
|
14
|
+
## Markdown 约定
|
|
15
|
+
|
|
16
|
+
- `#`:文档主标题
|
|
17
|
+
- `##`:一级标题
|
|
18
|
+
- `###`:二级标题
|
|
19
|
+
- 普通段落:空行分段
|
|
20
|
+
- `1.`:有序列表
|
|
21
|
+
- `-` / `*` / `+`:无序列表
|
|
22
|
+
- `<!-- pagebreak -->`:分页
|
|
23
|
+
|
|
24
|
+
## 示例
|
|
25
|
+
|
|
26
|
+
```md
|
|
27
|
+
# 项目阶段性汇报
|
|
28
|
+
|
|
29
|
+
本周工作进展如下。
|
|
30
|
+
|
|
31
|
+
## 一、已完成事项
|
|
32
|
+
|
|
33
|
+
1. 完成需求梳理。
|
|
34
|
+
2. 完成文档输出规范。
|
|
35
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gov-cn-hei",
|
|
3
|
+
"description": "Chinese government document preset with Hei title, Kai headings, and FangSong body",
|
|
4
|
+
"page": {
|
|
5
|
+
"width": 11907,
|
|
6
|
+
"height": 16838,
|
|
7
|
+
"margin_top": 1440,
|
|
8
|
+
"margin_right": 1800,
|
|
9
|
+
"margin_bottom": 1440,
|
|
10
|
+
"margin_left": 1800
|
|
11
|
+
},
|
|
12
|
+
"styles": {
|
|
13
|
+
"title": {
|
|
14
|
+
"font_east_asia": "黑体",
|
|
15
|
+
"font_ascii": "Times New Roman",
|
|
16
|
+
"size_half_points": 44,
|
|
17
|
+
"align": "center",
|
|
18
|
+
"spacing_after": 240,
|
|
19
|
+
"line": 560,
|
|
20
|
+
"line_rule": "exact"
|
|
21
|
+
},
|
|
22
|
+
"heading1": {
|
|
23
|
+
"font_east_asia": "黑体",
|
|
24
|
+
"font_ascii": "Times New Roman",
|
|
25
|
+
"size_half_points": 32,
|
|
26
|
+
"spacing_before": 80,
|
|
27
|
+
"spacing_after": 80,
|
|
28
|
+
"line": 560,
|
|
29
|
+
"line_rule": "exact"
|
|
30
|
+
},
|
|
31
|
+
"heading2": {
|
|
32
|
+
"font_east_asia": "楷体",
|
|
33
|
+
"font_ascii": "Times New Roman",
|
|
34
|
+
"size_half_points": 32,
|
|
35
|
+
"spacing_before": 60,
|
|
36
|
+
"spacing_after": 60,
|
|
37
|
+
"line": 560,
|
|
38
|
+
"line_rule": "exact"
|
|
39
|
+
},
|
|
40
|
+
"heading3": {
|
|
41
|
+
"font_east_asia": "仿宋",
|
|
42
|
+
"font_ascii": "Times New Roman",
|
|
43
|
+
"size_half_points": 32,
|
|
44
|
+
"bold": true,
|
|
45
|
+
"spacing_before": 40,
|
|
46
|
+
"spacing_after": 40,
|
|
47
|
+
"line": 560,
|
|
48
|
+
"line_rule": "exact"
|
|
49
|
+
},
|
|
50
|
+
"body": {
|
|
51
|
+
"font_east_asia": "仿宋",
|
|
52
|
+
"font_ascii": "Times New Roman",
|
|
53
|
+
"size_half_points": 32,
|
|
54
|
+
"first_line_indent": 640,
|
|
55
|
+
"line": 560,
|
|
56
|
+
"line_rule": "exact"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"list_settings": {
|
|
60
|
+
"base_left_indent": 720,
|
|
61
|
+
"hanging": 360,
|
|
62
|
+
"level_step": 360
|
|
63
|
+
},
|
|
64
|
+
"heading_numbering": {
|
|
65
|
+
"2": "cn-section",
|
|
66
|
+
"3": "cn-paren",
|
|
67
|
+
"4": "arabic-dot"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# `gov-cn-hei` preset
|
|
2
|
+
|
|
3
|
+
## 适用场景
|
|
4
|
+
|
|
5
|
+
中文政府公文、正式通知、情况报告、请示类材料的另一套常见字体方案。
|
|
6
|
+
|
|
7
|
+
## 当前版式基线
|
|
8
|
+
|
|
9
|
+
- 页面大小:A4
|
|
10
|
+
- 页边距:Word 默认“常规”(上/下 `2.54 cm`,左/右 `3.18 cm`)
|
|
11
|
+
- 大标题:`黑体`
|
|
12
|
+
- 一级标题:`黑体`
|
|
13
|
+
- 二级标题:`楷体`
|
|
14
|
+
- 正文:`仿宋`
|
|
15
|
+
- 正文字号:三号
|
|
16
|
+
- 正文行距:固定值 `28 磅`
|
|
17
|
+
- 正文首行:缩进两字符
|
|
18
|
+
- 三级标题:默认使用 `仿宋` 加粗
|
|
19
|
+
- 一级、二级、三级标题:自动编号
|
|
20
|
+
|
|
21
|
+
## 推荐 Markdown 写法
|
|
22
|
+
|
|
23
|
+
- `#`:公文标题
|
|
24
|
+
- 标题下方直接写主送机关或称谓段落
|
|
25
|
+
- `##`:一级标题,导出为 `一、XXXX`
|
|
26
|
+
- `###`:二级标题,导出为 `(一)XXXX`
|
|
27
|
+
- `####`:三级标题,导出为 `1. XXXX`
|
|
28
|
+
- 普通段落:正文内容,每段空一行
|
|
29
|
+
- ``:插入本地图片,按嵌入式输出;图片所在段落改为单倍行距
|
|
30
|
+
- `<!-- pagebreak -->`:需要分页时显式插入
|
|
31
|
+
|
|
32
|
+
编号由导出器自动补齐,Markdown 里只写标题内容,不要手写 `一、`、`(一)`、`1.`。
|
|
33
|
+
|
|
34
|
+
## 与 `gov-cn` 的区别
|
|
35
|
+
|
|
36
|
+
- 不使用 `方正小标宋简体`
|
|
37
|
+
- 不使用 `楷体_GB2312`
|
|
38
|
+
- 不使用 `仿宋_GB2312`
|
|
39
|
+
- 更适合系统内仅提供通用 `黑体 / 楷体 / 仿宋` 字体的环境
|
|
40
|
+
|
|
41
|
+
## 示例
|
|
42
|
+
|
|
43
|
+
```md
|
|
44
|
+
# 关于开展示例工作的通知
|
|
45
|
+
|
|
46
|
+
各有关单位:
|
|
47
|
+
|
|
48
|
+
为统一输出格式,现将有关事项通知如下。
|
|
49
|
+
|
|
50
|
+
## 工作目标
|
|
51
|
+
|
|
52
|
+
### 总体要求
|
|
53
|
+
|
|
54
|
+
#### 任务分工
|
|
55
|
+
|
|
56
|
+
请各单位提高认识,认真落实。
|
|
57
|
+
```
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "gov-cn",
|
|
3
|
+
"description": "Chinese government document baseline preset",
|
|
4
|
+
"page": {
|
|
5
|
+
"width": 11907,
|
|
6
|
+
"height": 16838,
|
|
7
|
+
"margin_top": 1440,
|
|
8
|
+
"margin_right": 1800,
|
|
9
|
+
"margin_bottom": 1440,
|
|
10
|
+
"margin_left": 1800
|
|
11
|
+
},
|
|
12
|
+
"styles": {
|
|
13
|
+
"title": {
|
|
14
|
+
"font_east_asia": "方正小标宋简体",
|
|
15
|
+
"font_ascii": "Times New Roman",
|
|
16
|
+
"size_half_points": 44,
|
|
17
|
+
"align": "center",
|
|
18
|
+
"spacing_after": 240,
|
|
19
|
+
"line": 560,
|
|
20
|
+
"line_rule": "exact"
|
|
21
|
+
},
|
|
22
|
+
"heading1": {
|
|
23
|
+
"font_east_asia": "黑体",
|
|
24
|
+
"font_ascii": "Times New Roman",
|
|
25
|
+
"size_half_points": 32,
|
|
26
|
+
"spacing_before": 80,
|
|
27
|
+
"spacing_after": 80,
|
|
28
|
+
"line": 560,
|
|
29
|
+
"line_rule": "exact"
|
|
30
|
+
},
|
|
31
|
+
"heading2": {
|
|
32
|
+
"font_east_asia": "楷体_GB2312",
|
|
33
|
+
"font_ascii": "Times New Roman",
|
|
34
|
+
"size_half_points": 32,
|
|
35
|
+
"spacing_before": 60,
|
|
36
|
+
"spacing_after": 60,
|
|
37
|
+
"line": 560,
|
|
38
|
+
"line_rule": "exact"
|
|
39
|
+
},
|
|
40
|
+
"heading3": {
|
|
41
|
+
"font_east_asia": "仿宋_GB2312",
|
|
42
|
+
"font_ascii": "Times New Roman",
|
|
43
|
+
"size_half_points": 32,
|
|
44
|
+
"bold": true,
|
|
45
|
+
"spacing_before": 40,
|
|
46
|
+
"spacing_after": 40,
|
|
47
|
+
"line": 560,
|
|
48
|
+
"line_rule": "exact"
|
|
49
|
+
},
|
|
50
|
+
"body": {
|
|
51
|
+
"font_east_asia": "仿宋_GB2312",
|
|
52
|
+
"font_ascii": "Times New Roman",
|
|
53
|
+
"size_half_points": 32,
|
|
54
|
+
"first_line_indent": 640,
|
|
55
|
+
"line": 560,
|
|
56
|
+
"line_rule": "exact"
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"list_settings": {
|
|
60
|
+
"base_left_indent": 720,
|
|
61
|
+
"hanging": 360,
|
|
62
|
+
"level_step": 360
|
|
63
|
+
},
|
|
64
|
+
"heading_numbering": {
|
|
65
|
+
"2": "cn-section",
|
|
66
|
+
"3": "cn-paren",
|
|
67
|
+
"4": "arabic-dot"
|
|
68
|
+
}
|
|
69
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# `gov-cn` preset
|
|
2
|
+
|
|
3
|
+
## 适用场景
|
|
4
|
+
|
|
5
|
+
中文政府公文、正式通知、情况报告、请示类材料的基线样式。
|
|
6
|
+
|
|
7
|
+
## 当前版式基线
|
|
8
|
+
|
|
9
|
+
- 页面大小:A4
|
|
10
|
+
- 页边距:Word 默认“常规”(上/下 `2.54 cm`,左/右 `3.18 cm`)
|
|
11
|
+
- 正文:`仿宋_GB2312`
|
|
12
|
+
- 正文字号:三号
|
|
13
|
+
- 正文行距:固定值 `28 磅`
|
|
14
|
+
- 正文首行:缩进两字符
|
|
15
|
+
- 标题:居中,默认使用 `方正小标宋简体`
|
|
16
|
+
- 一级标题:默认使用黑体
|
|
17
|
+
- 二级标题:默认使用 `楷体_GB2312`
|
|
18
|
+
- 三级标题:默认使用 `仿宋_GB2312` 加粗
|
|
19
|
+
- 一级、二级、三级标题:自动编号
|
|
20
|
+
|
|
21
|
+
## 推荐 Markdown 写法
|
|
22
|
+
|
|
23
|
+
- `#`:公文标题
|
|
24
|
+
- 标题下方直接写主送机关或称谓段落
|
|
25
|
+
- `##`:一级标题,导出为 `一、XXXX`
|
|
26
|
+
- `###`:二级标题,导出为 `(一)XXXX`
|
|
27
|
+
- `####`:三级标题,导出为 `1. XXXX`
|
|
28
|
+
- 普通段落:正文内容,每段空一行
|
|
29
|
+
- ``:插入本地图片,按嵌入式输出;图片所在段落改为单倍行距
|
|
30
|
+
- `<!-- pagebreak -->`:需要分页时显式插入
|
|
31
|
+
|
|
32
|
+
编号由导出器自动补齐,Markdown 里只写标题内容,不要手写 `一、`、`(一)`、`1.`。
|
|
33
|
+
|
|
34
|
+
## 示例
|
|
35
|
+
|
|
36
|
+
```md
|
|
37
|
+
# 关于开展示例工作的通知
|
|
38
|
+
|
|
39
|
+
各有关单位:
|
|
40
|
+
|
|
41
|
+
为统一输出格式,现将有关事项通知如下。
|
|
42
|
+
|
|
43
|
+
## 工作目标
|
|
44
|
+
|
|
45
|
+
### 总体要求
|
|
46
|
+
|
|
47
|
+
#### 任务分工
|
|
48
|
+
|
|
49
|
+
请各单位提高认识,认真落实。
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
## 当前边界
|
|
53
|
+
|
|
54
|
+
当前只是“公文样式基线”,还不是完整公文排版规范。以下规则后续可以继续补:
|
|
55
|
+
|
|
56
|
+
- 发文字号
|
|
57
|
+
- 签发人
|
|
58
|
+
- 主送机关的专门样式
|
|
59
|
+
- 成文日期与落款位置
|
|
60
|
+
- 附件说明和抄送规则
|
mdstyledocx/presets.py
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
from copy import deepcopy
|
|
5
|
+
from dataclasses import dataclass, field
|
|
6
|
+
from functools import lru_cache
|
|
7
|
+
from importlib.resources import files
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass
|
|
13
|
+
class PageSettings:
|
|
14
|
+
width: int
|
|
15
|
+
height: int
|
|
16
|
+
margin_top: int
|
|
17
|
+
margin_right: int
|
|
18
|
+
margin_bottom: int
|
|
19
|
+
margin_left: int
|
|
20
|
+
header: int = 708
|
|
21
|
+
footer: int = 708
|
|
22
|
+
gutter: int = 0
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class Style:
|
|
27
|
+
font_east_asia: str
|
|
28
|
+
font_ascii: str
|
|
29
|
+
size_half_points: int
|
|
30
|
+
bold: bool = False
|
|
31
|
+
italic: bool = False
|
|
32
|
+
align: str = "left"
|
|
33
|
+
first_line_indent: int = 0
|
|
34
|
+
left_indent: int = 0
|
|
35
|
+
hanging: int = 0
|
|
36
|
+
spacing_before: int = 0
|
|
37
|
+
spacing_after: int = 0
|
|
38
|
+
line: int = 240
|
|
39
|
+
line_rule: str = "auto"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class ListSettings:
|
|
44
|
+
base_left_indent: int = 720
|
|
45
|
+
hanging: int = 360
|
|
46
|
+
level_step: int = 360
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
@dataclass
|
|
50
|
+
class Preset:
|
|
51
|
+
name: str
|
|
52
|
+
description: str
|
|
53
|
+
page: PageSettings
|
|
54
|
+
styles: dict[str, Style]
|
|
55
|
+
list_settings: ListSettings
|
|
56
|
+
heading_numbering: dict[int, str] = field(default_factory=dict)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def list_presets() -> list[tuple[str, str]]:
|
|
60
|
+
return [
|
|
61
|
+
(name, definition["description"])
|
|
62
|
+
for name, definition in sorted(_builtin_definitions().items(), key=lambda item: item[0])
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def load_preset(name: str, preset_file: Path | None = None) -> Preset:
|
|
67
|
+
if preset_file:
|
|
68
|
+
override_data = json.loads(preset_file.read_text(encoding="utf-8"))
|
|
69
|
+
base_name = override_data.pop("extends", name)
|
|
70
|
+
base = _base_definition(base_name)
|
|
71
|
+
merged = _deep_merge(base, override_data)
|
|
72
|
+
return _build_preset(merged)
|
|
73
|
+
|
|
74
|
+
return _build_preset(_base_definition(name))
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def load_preset_rules(name: str) -> str:
|
|
78
|
+
rules_path = _preset_specs_dir().joinpath(f"{name}.md")
|
|
79
|
+
if not rules_path.is_file():
|
|
80
|
+
available = ", ".join(sorted(_builtin_definitions()))
|
|
81
|
+
raise ValueError(f"Unknown preset '{name}'. Available presets: {available}")
|
|
82
|
+
return rules_path.read_text(encoding="utf-8")
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _base_definition(name: str) -> dict[str, Any]:
|
|
86
|
+
definitions = _builtin_definitions()
|
|
87
|
+
if name not in definitions:
|
|
88
|
+
available = ", ".join(sorted(definitions))
|
|
89
|
+
raise ValueError(f"Unknown preset '{name}'. Available presets: {available}")
|
|
90
|
+
return deepcopy(definitions[name])
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
@lru_cache(maxsize=1)
|
|
94
|
+
def _builtin_definitions() -> dict[str, dict[str, Any]]:
|
|
95
|
+
definitions: dict[str, dict[str, Any]] = {}
|
|
96
|
+
for definition_path in _preset_specs_dir().iterdir():
|
|
97
|
+
if definition_path.suffix != ".json":
|
|
98
|
+
continue
|
|
99
|
+
raw = json.loads(definition_path.read_text(encoding="utf-8"))
|
|
100
|
+
definitions[raw["name"]] = raw
|
|
101
|
+
return definitions
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _preset_specs_dir():
|
|
105
|
+
return files("mdstyledocx").joinpath("preset_specs")
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _build_preset(data: dict[str, Any]) -> Preset:
|
|
109
|
+
return Preset(
|
|
110
|
+
name=data["name"],
|
|
111
|
+
description=data["description"],
|
|
112
|
+
page=PageSettings(**data["page"]),
|
|
113
|
+
styles={name: Style(**style) for name, style in data["styles"].items()},
|
|
114
|
+
list_settings=ListSettings(**data["list_settings"]),
|
|
115
|
+
heading_numbering={
|
|
116
|
+
int(level): scheme for level, scheme in data.get("heading_numbering", {}).items()
|
|
117
|
+
},
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def _deep_merge(base: dict[str, Any], override: dict[str, Any]) -> dict[str, Any]:
|
|
122
|
+
merged = deepcopy(base)
|
|
123
|
+
for key, value in override.items():
|
|
124
|
+
if isinstance(value, dict) and isinstance(merged.get(key), dict):
|
|
125
|
+
merged[key] = _deep_merge(merged[key], value)
|
|
126
|
+
else:
|
|
127
|
+
merged[key] = value
|
|
128
|
+
return merged
|
|
@@ -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,19 @@
|
|
|
1
|
+
mdstyledocx/__init__.py,sha256=4vSWxWbz4mSgVO8CfC3mqnjArLUjXbX2FWulkpYTh1o,77
|
|
2
|
+
mdstyledocx/__main__.py,sha256=_LB_VxImJA0ewYU5y_x9UsqOECJFom3PZHAsFwe9GR0,91
|
|
3
|
+
mdstyledocx/cli.py,sha256=0dst4k-WOHizwMIioaV14TypEze6isFbKn_sOYVMUY4,2583
|
|
4
|
+
mdstyledocx/docx_writer.py,sha256=PggklNBsOcyytZq1T9DHdqCLCTrfxEgUPojr-Hki7u8,8848
|
|
5
|
+
mdstyledocx/markdown.py,sha256=MKEra2G5rmbBAP9BfcK3mEqxRWHlETwSJN6Hr_hIM1U,6178
|
|
6
|
+
mdstyledocx/model.py,sha256=R9KpQ8ig3jX371Gwa95V4JF6x0pDuM2s-jdZTjp7NyI,650
|
|
7
|
+
mdstyledocx/presets.py,sha256=WpBKiJCQXHFpkUQF6E1KK4DcDP3-lEANFfVA2hZhcto,3649
|
|
8
|
+
mdstyledocx/preset_specs/default.json,sha256=yXwkf2BOiCvQSjPJKROlemCvDKbDWkjGPL7j5XLY4H0,1395
|
|
9
|
+
mdstyledocx/preset_specs/default.md,sha256=3pxQpz1pM0e_KZIViyP3Rt-LAx0DuqEd6OjFSJw9KME,690
|
|
10
|
+
mdstyledocx/preset_specs/gov-cn-hei.json,sha256=VX_qZXZvqW-FlJjF5rxA8YmGKNoXlz5CbfaV8CAlx38,1633
|
|
11
|
+
mdstyledocx/preset_specs/gov-cn-hei.md,sha256=1cSPh8wOor_BToO73sWa94OH3R6TQhAdVkXhlk5gM3s,1547
|
|
12
|
+
mdstyledocx/preset_specs/gov-cn.json,sha256=tmpKL3-iMgWB3N9U3NiLiRpCCiM6BFfADpLF2G2XL_Y,1626
|
|
13
|
+
mdstyledocx/preset_specs/gov-cn.md,sha256=4rX3S_eR1WYdWD2lR8FVCJuYr-96qN5vDwwC7uIMiFY,1655
|
|
14
|
+
mdstyledocx-0.1.0.dist-info/licenses/LICENSE,sha256=43iGV-F6hCXbP392AGPVFkJOX-eB4aZ5MmGUiZyYovE,1079
|
|
15
|
+
mdstyledocx-0.1.0.dist-info/METADATA,sha256=Bu1a7xVVoqYMzk7RK01OsqRaccLzNgpvSF2Z2qQvEfA,3352
|
|
16
|
+
mdstyledocx-0.1.0.dist-info/WHEEL,sha256=aeYiig01lYGDzBgS8HxWXOg3uV61G9ijOsup-k9o1sk,91
|
|
17
|
+
mdstyledocx-0.1.0.dist-info/entry_points.txt,sha256=FIQJ7HcdSo9BelxMVuipTJID0Qjcev5224vPD6UQy-I,53
|
|
18
|
+
mdstyledocx-0.1.0.dist-info/top_level.txt,sha256=gQ8aSe-ISLHZ1CcQ2rqJD1BF_FGSKnSJmIil6hOyvNU,12
|
|
19
|
+
mdstyledocx-0.1.0.dist-info/RECORD,,
|
|
@@ -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 @@
|
|
|
1
|
+
mdstyledocx
|