markdown-docx 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.
- markdown_docx/__init__.py +1 -0
- markdown_docx/assets/default.docx +0 -0
- markdown_docx/assets/syntax.json +47 -0
- markdown_docx/assets.py +15 -0
- markdown_docx/cli.py +318 -0
- markdown_docx/errors.py +118 -0
- markdown_docx/images.py +161 -0
- markdown_docx/markdown_body.py +121 -0
- markdown_docx/metadata.py +349 -0
- markdown_docx/models.py +180 -0
- markdown_docx/parser.py +456 -0
- markdown_docx/renderer.py +285 -0
- markdown_docx/skill.py +123 -0
- markdown_docx/styles.py +50 -0
- markdown_docx/template.py +121 -0
- markdown_docx-0.1.0.dist-info/METADATA +372 -0
- markdown_docx-0.1.0.dist-info/RECORD +20 -0
- markdown_docx-0.1.0.dist-info/WHEEL +4 -0
- markdown_docx-0.1.0.dist-info/entry_points.txt +2 -0
- markdown_docx-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from markdown_it.token import Token
|
|
6
|
+
|
|
7
|
+
from markdown_docx.errors import UnsupportedFeatureError
|
|
8
|
+
from markdown_docx.models import InlineFragment
|
|
9
|
+
|
|
10
|
+
FOOTNOTE_PATTERN = re.compile(r"\[\^[^\]]+\]")
|
|
11
|
+
TASK_PATTERN = re.compile(r"^\[[ xX]\]\s")
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def parse_inline(token: Token, *, line: int, input_path: str | None) -> list[InlineFragment]:
|
|
15
|
+
fragments: list[InlineFragment] = []
|
|
16
|
+
bold = False
|
|
17
|
+
italic = False
|
|
18
|
+
children = token.children or []
|
|
19
|
+
if FOOTNOTE_PATTERN.search(token.content) and any(child.type == "link_open" for child in children):
|
|
20
|
+
raise UnsupportedFeatureError(
|
|
21
|
+
"Footnote syntax is not supported.",
|
|
22
|
+
line=line,
|
|
23
|
+
input_path=input_path,
|
|
24
|
+
code="unsupported_markdown",
|
|
25
|
+
)
|
|
26
|
+
for child in children:
|
|
27
|
+
child_type = child.type
|
|
28
|
+
if child_type == "text":
|
|
29
|
+
_reject_footnote_text(child.content, line=line, input_path=input_path)
|
|
30
|
+
_append_text(fragments, child.content, bold=bold, italic=italic)
|
|
31
|
+
elif child_type == "code_inline":
|
|
32
|
+
fragments.append(InlineFragment(kind="text", text=child.content, bold=bold, italic=italic, code=True))
|
|
33
|
+
elif child_type == "softbreak":
|
|
34
|
+
_append_text(fragments, " ", bold=bold, italic=italic)
|
|
35
|
+
elif child_type == "hardbreak":
|
|
36
|
+
fragments.append(InlineFragment(kind="break", bold=bold, italic=italic))
|
|
37
|
+
elif child_type == "strong_open":
|
|
38
|
+
bold = True
|
|
39
|
+
elif child_type == "strong_close":
|
|
40
|
+
bold = False
|
|
41
|
+
elif child_type == "em_open":
|
|
42
|
+
italic = True
|
|
43
|
+
elif child_type == "em_close":
|
|
44
|
+
italic = False
|
|
45
|
+
elif child_type == "image":
|
|
46
|
+
raw_source = child.attrGet("src")
|
|
47
|
+
fragments.append(
|
|
48
|
+
InlineFragment(
|
|
49
|
+
kind="image",
|
|
50
|
+
src=raw_source if isinstance(raw_source, str) else "",
|
|
51
|
+
alt=child.content,
|
|
52
|
+
bold=bold,
|
|
53
|
+
italic=italic,
|
|
54
|
+
)
|
|
55
|
+
)
|
|
56
|
+
elif child_type in {"link_open", "link_close"}:
|
|
57
|
+
raise UnsupportedFeatureError(
|
|
58
|
+
"Links require a public python-docx hyperlink creation API and are not supported in 0.1.0.",
|
|
59
|
+
line=line,
|
|
60
|
+
input_path=input_path,
|
|
61
|
+
)
|
|
62
|
+
elif child_type == "html_inline":
|
|
63
|
+
raise UnsupportedFeatureError(
|
|
64
|
+
"Raw inline HTML is not supported.",
|
|
65
|
+
line=line,
|
|
66
|
+
input_path=input_path,
|
|
67
|
+
code="unsupported_markdown",
|
|
68
|
+
)
|
|
69
|
+
else:
|
|
70
|
+
raise UnsupportedFeatureError(
|
|
71
|
+
f"Inline Markdown token '{child_type}' is not supported.",
|
|
72
|
+
line=line,
|
|
73
|
+
input_path=input_path,
|
|
74
|
+
code="unsupported_markdown",
|
|
75
|
+
)
|
|
76
|
+
return fragments
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def is_standalone_image(fragments: list[InlineFragment]) -> bool:
|
|
80
|
+
meaningful = [fragment for fragment in fragments if fragment.kind != "text" or (fragment.text or "").strip()]
|
|
81
|
+
return len(meaningful) == 1 and meaningful[0].kind == "image"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def is_task_item(fragments: list[InlineFragment]) -> bool:
|
|
85
|
+
for fragment in fragments:
|
|
86
|
+
if fragment.kind == "text" and fragment.text:
|
|
87
|
+
return TASK_PATTERN.match(fragment.text) is not None
|
|
88
|
+
if fragment.kind != "break":
|
|
89
|
+
return False
|
|
90
|
+
return False
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _reject_footnote_text(text: str, *, line: int, input_path: str | None) -> None:
|
|
94
|
+
if FOOTNOTE_PATTERN.search(text):
|
|
95
|
+
raise UnsupportedFeatureError(
|
|
96
|
+
"Footnote syntax is not supported.",
|
|
97
|
+
line=line,
|
|
98
|
+
input_path=input_path,
|
|
99
|
+
code="unsupported_markdown",
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _append_text(
|
|
104
|
+
fragments: list[InlineFragment],
|
|
105
|
+
text: str,
|
|
106
|
+
*,
|
|
107
|
+
bold: bool,
|
|
108
|
+
italic: bool,
|
|
109
|
+
) -> None:
|
|
110
|
+
if not text:
|
|
111
|
+
return
|
|
112
|
+
if (
|
|
113
|
+
fragments
|
|
114
|
+
and fragments[-1].kind == "text"
|
|
115
|
+
and fragments[-1].bold == bold
|
|
116
|
+
and fragments[-1].italic == italic
|
|
117
|
+
and not fragments[-1].code
|
|
118
|
+
):
|
|
119
|
+
fragments[-1].text = (fragments[-1].text or "") + text
|
|
120
|
+
return
|
|
121
|
+
fragments.append(InlineFragment(kind="text", text=text, bold=bold, italic=italic))
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
from dataclasses import replace
|
|
5
|
+
from typing import Any, NoReturn, cast
|
|
6
|
+
|
|
7
|
+
import yaml
|
|
8
|
+
|
|
9
|
+
from markdown_docx.errors import ParseError
|
|
10
|
+
from markdown_docx.models import (
|
|
11
|
+
Alignment,
|
|
12
|
+
DocumentOptions,
|
|
13
|
+
FontOverrides,
|
|
14
|
+
ImageOptions,
|
|
15
|
+
Margins,
|
|
16
|
+
Orientation,
|
|
17
|
+
PageSize,
|
|
18
|
+
SectionSettings,
|
|
19
|
+
StyleMappings,
|
|
20
|
+
TableOptions,
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
EMU_PER_INCH = 914400
|
|
24
|
+
EMU_PER_CM = 360000
|
|
25
|
+
EMU_PER_MM = 36000
|
|
26
|
+
EMU_PER_PT = 12700
|
|
27
|
+
|
|
28
|
+
PAGE_SIZES = {
|
|
29
|
+
"letter": PageSize(int(8.5 * EMU_PER_INCH), 11 * EMU_PER_INCH, "letter"),
|
|
30
|
+
"legal": PageSize(int(8.5 * EMU_PER_INCH), 14 * EMU_PER_INCH, "legal"),
|
|
31
|
+
"a4": PageSize(210 * EMU_PER_MM, 297 * EMU_PER_MM, "a4"),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
LENGTH_PATTERN = re.compile(r"^(?P<value>(?:\d+(?:\.\d*)?|\.\d+))(?P<unit>in|cm|mm|pt)$", re.IGNORECASE)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class UniqueKeyLoader(yaml.SafeLoader):
|
|
38
|
+
pass
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def _construct_mapping(loader: UniqueKeyLoader, node: yaml.MappingNode, deep: bool = False) -> dict[Any, Any]:
|
|
42
|
+
mapping: dict[Any, Any] = {}
|
|
43
|
+
for key_node, value_node in node.value:
|
|
44
|
+
key = loader.construct_object(key_node, deep=deep)
|
|
45
|
+
if key in mapping:
|
|
46
|
+
raise yaml.constructor.ConstructorError(
|
|
47
|
+
"while constructing a mapping",
|
|
48
|
+
node.start_mark,
|
|
49
|
+
f"found duplicate key {key!r}",
|
|
50
|
+
key_node.start_mark,
|
|
51
|
+
)
|
|
52
|
+
mapping[key] = loader.construct_object(value_node, deep=deep)
|
|
53
|
+
return mapping
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
UniqueKeyLoader.add_constructor(yaml.resolver.BaseResolver.DEFAULT_MAPPING_TAG, _construct_mapping)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def default_document_options() -> DocumentOptions:
|
|
60
|
+
one_inch = EMU_PER_INCH
|
|
61
|
+
return DocumentOptions(
|
|
62
|
+
section=SectionSettings(
|
|
63
|
+
page_size=PAGE_SIZES["letter"],
|
|
64
|
+
orientation="portrait",
|
|
65
|
+
margins=Margins(one_inch, one_inch, one_inch, one_inch),
|
|
66
|
+
)
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def parse_yaml_payload(text: str, *, line: int, input_path: str | None, metadata_kind: str) -> Any:
|
|
71
|
+
try:
|
|
72
|
+
return yaml.load(text, Loader=UniqueKeyLoader)
|
|
73
|
+
except yaml.YAMLError as exc:
|
|
74
|
+
problem_line = line
|
|
75
|
+
problem_mark = getattr(exc, "problem_mark", None)
|
|
76
|
+
if problem_mark is not None:
|
|
77
|
+
problem_line += int(problem_mark.line) + 1
|
|
78
|
+
raise ParseError(
|
|
79
|
+
"metadata_parse_error",
|
|
80
|
+
f"Invalid {metadata_kind} metadata: {exc}",
|
|
81
|
+
line=problem_line,
|
|
82
|
+
input_path=input_path,
|
|
83
|
+
metadata_kind=metadata_kind,
|
|
84
|
+
) from exc
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def parse_document_options(value: Any, *, line: int, input_path: str | None) -> DocumentOptions:
|
|
88
|
+
mapping = _mapping(value, "document", line, input_path)
|
|
89
|
+
_known_keys(mapping, {"page_size", "orientation", "margins", "styles", "fonts"}, "document", line, input_path)
|
|
90
|
+
options = default_document_options()
|
|
91
|
+
section = _parse_section_fields(mapping, options.section, "document", line, input_path)
|
|
92
|
+
styles = _parse_styles(mapping.get("styles"), options.styles, line, input_path)
|
|
93
|
+
fonts = _parse_fonts(mapping.get("fonts"), options.fonts, line, input_path)
|
|
94
|
+
return DocumentOptions(section=section, styles=styles, fonts=fonts)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def parse_section_options(
|
|
98
|
+
value: Any,
|
|
99
|
+
*,
|
|
100
|
+
defaults: SectionSettings,
|
|
101
|
+
line: int,
|
|
102
|
+
input_path: str | None,
|
|
103
|
+
) -> SectionSettings:
|
|
104
|
+
if value == "default":
|
|
105
|
+
return defaults
|
|
106
|
+
mapping = _mapping(value, "section", line, input_path)
|
|
107
|
+
_known_keys(mapping, {"page_size", "orientation", "margins"}, "section", line, input_path)
|
|
108
|
+
return _parse_section_fields(mapping, defaults, "section", line, input_path)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def parse_table_options(value: Any, *, line: int, input_path: str | None) -> TableOptions:
|
|
112
|
+
mapping = _mapping(value, "table", line, input_path)
|
|
113
|
+
_known_keys(mapping, {"style", "alignment", "width", "column_widths"}, "table", line, input_path)
|
|
114
|
+
style = mapping.get("style")
|
|
115
|
+
if style is not None:
|
|
116
|
+
style = _nonempty_string(style, "style", "table", line, input_path)
|
|
117
|
+
alignment = cast(
|
|
118
|
+
Alignment,
|
|
119
|
+
_choice(mapping.get("alignment", "left"), {"left", "center", "right"}, "alignment", "table", line, input_path),
|
|
120
|
+
)
|
|
121
|
+
width = _choice(mapping.get("width", "page"), {"auto", "page"}, "width", "table", line, input_path)
|
|
122
|
+
ratios_value = mapping.get("column_widths")
|
|
123
|
+
ratios: tuple[float, ...] | None = None
|
|
124
|
+
if ratios_value is not None:
|
|
125
|
+
if not isinstance(ratios_value, list) or not ratios_value:
|
|
126
|
+
_invalid("column_widths must be a nonempty list", "table", line, input_path)
|
|
127
|
+
converted: list[float] = []
|
|
128
|
+
for ratio in ratios_value:
|
|
129
|
+
if isinstance(ratio, bool) or not isinstance(ratio, (int, float)) or ratio <= 0:
|
|
130
|
+
_invalid("column_widths entries must be positive numbers", "table", line, input_path)
|
|
131
|
+
converted.append(float(ratio))
|
|
132
|
+
ratios = tuple(converted)
|
|
133
|
+
return TableOptions(style=style, alignment=alignment, width=width, column_widths=ratios) # type: ignore[arg-type]
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def parse_image_options(value: Any, *, line: int, input_path: str | None) -> ImageOptions:
|
|
137
|
+
mapping = _mapping(value, "image", line, input_path)
|
|
138
|
+
_known_keys(mapping, {"width", "alignment"}, "image", line, input_path)
|
|
139
|
+
alignment = cast(
|
|
140
|
+
Alignment,
|
|
141
|
+
_choice(mapping.get("alignment", "left"), {"left", "center", "right"}, "alignment", "image", line, input_path),
|
|
142
|
+
)
|
|
143
|
+
width_value = mapping.get("width")
|
|
144
|
+
width: int | float | None = None
|
|
145
|
+
is_percent = False
|
|
146
|
+
if width_value is not None:
|
|
147
|
+
if isinstance(width_value, str) and width_value.endswith("%"):
|
|
148
|
+
try:
|
|
149
|
+
percentage = float(width_value[:-1])
|
|
150
|
+
except ValueError:
|
|
151
|
+
_invalid("width percentage is invalid", "image", line, input_path)
|
|
152
|
+
if percentage <= 0 or percentage > 100:
|
|
153
|
+
_invalid("width percentage must be greater than 0 and at most 100", "image", line, input_path)
|
|
154
|
+
width = percentage
|
|
155
|
+
is_percent = True
|
|
156
|
+
else:
|
|
157
|
+
width = parse_length(width_value, field="width", kind="image", line=line, input_path=input_path)
|
|
158
|
+
return ImageOptions(width=width, width_is_percent=is_percent, alignment=alignment)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def parse_length(value: Any, *, field: str, kind: str, line: int, input_path: str | None) -> int:
|
|
162
|
+
if not isinstance(value, str):
|
|
163
|
+
_invalid(f"{field} must include a unit", kind, line, input_path)
|
|
164
|
+
match = LENGTH_PATTERN.fullmatch(value.strip())
|
|
165
|
+
if match is None:
|
|
166
|
+
_invalid(f"{field} must use in, cm, mm, or pt", kind, line, input_path)
|
|
167
|
+
amount = float(match.group("value"))
|
|
168
|
+
if amount <= 0:
|
|
169
|
+
_invalid(f"{field} must be greater than zero", kind, line, input_path)
|
|
170
|
+
factors = {"in": EMU_PER_INCH, "cm": EMU_PER_CM, "mm": EMU_PER_MM, "pt": EMU_PER_PT}
|
|
171
|
+
return round(amount * factors[match.group("unit").lower()])
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _parse_section_fields(
|
|
175
|
+
mapping: dict[str, Any],
|
|
176
|
+
defaults: SectionSettings,
|
|
177
|
+
kind: str,
|
|
178
|
+
line: int,
|
|
179
|
+
input_path: str | None,
|
|
180
|
+
) -> SectionSettings:
|
|
181
|
+
page_size = defaults.page_size
|
|
182
|
+
if "page_size" in mapping:
|
|
183
|
+
page_size = _parse_page_size(mapping["page_size"], kind, line, input_path)
|
|
184
|
+
orientation = defaults.orientation
|
|
185
|
+
if "orientation" in mapping:
|
|
186
|
+
orientation = cast(
|
|
187
|
+
Orientation,
|
|
188
|
+
_choice(mapping["orientation"], {"portrait", "landscape"}, "orientation", kind, line, input_path),
|
|
189
|
+
)
|
|
190
|
+
margins = defaults.margins
|
|
191
|
+
if "margins" in mapping:
|
|
192
|
+
margin_values = _mapping(mapping["margins"], kind, line, input_path)
|
|
193
|
+
_known_keys(margin_values, {"top", "right", "bottom", "left"}, kind, line, input_path)
|
|
194
|
+
margins = replace(
|
|
195
|
+
margins,
|
|
196
|
+
**{
|
|
197
|
+
key: parse_length(value, field=f"margins.{key}", kind=kind, line=line, input_path=input_path)
|
|
198
|
+
for key, value in margin_values.items()
|
|
199
|
+
},
|
|
200
|
+
)
|
|
201
|
+
settings = SectionSettings(page_size=page_size, orientation=orientation, margins=margins)
|
|
202
|
+
if settings.usable_width <= 0 or settings.usable_height <= 0:
|
|
203
|
+
raise ParseError(
|
|
204
|
+
"invalid_page_geometry",
|
|
205
|
+
"Margins must leave a positive usable page width and height.",
|
|
206
|
+
line=line,
|
|
207
|
+
input_path=input_path,
|
|
208
|
+
metadata_kind=kind,
|
|
209
|
+
)
|
|
210
|
+
return settings
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _parse_page_size(value: Any, kind: str, line: int, input_path: str | None) -> PageSize:
|
|
214
|
+
if isinstance(value, str):
|
|
215
|
+
key = value.lower()
|
|
216
|
+
if key not in PAGE_SIZES:
|
|
217
|
+
_invalid("page_size must be letter, legal, a4, or a custom width and height", kind, line, input_path)
|
|
218
|
+
return PAGE_SIZES[key]
|
|
219
|
+
mapping = _mapping(value, kind, line, input_path)
|
|
220
|
+
_known_keys(mapping, {"width", "height"}, kind, line, input_path)
|
|
221
|
+
if set(mapping) != {"width", "height"}:
|
|
222
|
+
_invalid("custom page_size requires width and height", kind, line, input_path)
|
|
223
|
+
width = parse_length(mapping["width"], field="page_size.width", kind=kind, line=line, input_path=input_path)
|
|
224
|
+
height = parse_length(mapping["height"], field="page_size.height", kind=kind, line=line, input_path=input_path)
|
|
225
|
+
if width > height:
|
|
226
|
+
_invalid("custom page_size width must not exceed height", kind, line, input_path)
|
|
227
|
+
return PageSize(width, height)
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def _parse_styles(
|
|
231
|
+
value: Any,
|
|
232
|
+
defaults: StyleMappings,
|
|
233
|
+
line: int,
|
|
234
|
+
input_path: str | None,
|
|
235
|
+
) -> StyleMappings:
|
|
236
|
+
if value is None:
|
|
237
|
+
return defaults
|
|
238
|
+
mapping = _mapping(value, "document", line, input_path)
|
|
239
|
+
_known_keys(
|
|
240
|
+
mapping,
|
|
241
|
+
{"paragraph", "headings", "blockquote", "code_block", "ordered_list", "unordered_list", "table"},
|
|
242
|
+
"document",
|
|
243
|
+
line,
|
|
244
|
+
input_path,
|
|
245
|
+
)
|
|
246
|
+
result = StyleMappings(
|
|
247
|
+
paragraph=defaults.paragraph,
|
|
248
|
+
headings=dict(defaults.headings),
|
|
249
|
+
blockquote=defaults.blockquote,
|
|
250
|
+
code_block=defaults.code_block,
|
|
251
|
+
ordered_list=list(defaults.ordered_list),
|
|
252
|
+
unordered_list=list(defaults.unordered_list),
|
|
253
|
+
table=defaults.table,
|
|
254
|
+
)
|
|
255
|
+
for key in ("paragraph", "blockquote", "code_block", "table"):
|
|
256
|
+
if key in mapping:
|
|
257
|
+
setattr(result, key, _nonempty_string(mapping[key], key, "document", line, input_path))
|
|
258
|
+
if "headings" in mapping:
|
|
259
|
+
headings_value = mapping["headings"]
|
|
260
|
+
if not isinstance(headings_value, dict):
|
|
261
|
+
_invalid("headings must be a mapping", "document", line, input_path)
|
|
262
|
+
headings = headings_value
|
|
263
|
+
normalized: dict[int, str] = {}
|
|
264
|
+
for raw_level, style_name in headings.items():
|
|
265
|
+
try:
|
|
266
|
+
level = int(raw_level)
|
|
267
|
+
except (TypeError, ValueError):
|
|
268
|
+
_invalid("heading style keys must be levels 1 through 6", "document", line, input_path)
|
|
269
|
+
if isinstance(raw_level, bool) or level not in range(1, 7):
|
|
270
|
+
_invalid("heading style keys must be levels 1 through 6", "document", line, input_path)
|
|
271
|
+
normalized[level] = _nonempty_string(style_name, f"headings.{level}", "document", line, input_path)
|
|
272
|
+
result.headings.update(normalized)
|
|
273
|
+
for key in ("ordered_list", "unordered_list"):
|
|
274
|
+
if key in mapping:
|
|
275
|
+
values = mapping[key]
|
|
276
|
+
if not isinstance(values, list) or not values:
|
|
277
|
+
_invalid(f"{key} must be a nonempty list of style names", "document", line, input_path)
|
|
278
|
+
setattr(
|
|
279
|
+
result,
|
|
280
|
+
key,
|
|
281
|
+
[_nonempty_string(item, key, "document", line, input_path) for item in values],
|
|
282
|
+
)
|
|
283
|
+
return result
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def _parse_fonts(value: Any, defaults: FontOverrides, line: int, input_path: str | None) -> FontOverrides:
|
|
287
|
+
if value is None:
|
|
288
|
+
return defaults
|
|
289
|
+
mapping = _mapping(value, "document", line, input_path)
|
|
290
|
+
_known_keys(mapping, {"body", "headings", "monospace"}, "document", line, input_path)
|
|
291
|
+
result = replace(defaults)
|
|
292
|
+
for key in ("body", "headings", "monospace"):
|
|
293
|
+
if key in mapping:
|
|
294
|
+
setattr(result, key, _nonempty_string(mapping[key], f"fonts.{key}", "document", line, input_path))
|
|
295
|
+
return result
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _mapping(value: Any, kind: str, line: int, input_path: str | None) -> dict[str, Any]:
|
|
299
|
+
if not isinstance(value, dict) or any(not isinstance(key, str) for key in value):
|
|
300
|
+
_invalid(f"{kind} metadata must be a mapping with string keys", kind, line, input_path)
|
|
301
|
+
return value
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def _known_keys(
|
|
305
|
+
mapping: dict[str, Any],
|
|
306
|
+
allowed: set[str],
|
|
307
|
+
kind: str,
|
|
308
|
+
line: int,
|
|
309
|
+
input_path: str | None,
|
|
310
|
+
) -> None:
|
|
311
|
+
unknown = sorted(set(mapping) - allowed)
|
|
312
|
+
if unknown:
|
|
313
|
+
raise ParseError(
|
|
314
|
+
"unknown_metadata_key",
|
|
315
|
+
f"Unknown {kind} metadata key: {unknown[0]}",
|
|
316
|
+
line=line,
|
|
317
|
+
input_path=input_path,
|
|
318
|
+
metadata_kind=kind,
|
|
319
|
+
details={"key": unknown[0]},
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _choice(
|
|
324
|
+
value: Any,
|
|
325
|
+
choices: set[str],
|
|
326
|
+
field: str,
|
|
327
|
+
kind: str,
|
|
328
|
+
line: int,
|
|
329
|
+
input_path: str | None,
|
|
330
|
+
) -> str:
|
|
331
|
+
if not isinstance(value, str) or value not in choices:
|
|
332
|
+
_invalid(f"{field} must be one of: {', '.join(sorted(choices))}", kind, line, input_path)
|
|
333
|
+
return value
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def _nonempty_string(value: Any, field: str, kind: str, line: int, input_path: str | None) -> str:
|
|
337
|
+
if not isinstance(value, str) or not value.strip():
|
|
338
|
+
_invalid(f"{field} must be a nonempty string", kind, line, input_path)
|
|
339
|
+
return value
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _invalid(message: str, kind: str, line: int, input_path: str | None) -> NoReturn:
|
|
343
|
+
raise ParseError(
|
|
344
|
+
"metadata_parse_error",
|
|
345
|
+
message,
|
|
346
|
+
line=line,
|
|
347
|
+
input_path=input_path,
|
|
348
|
+
metadata_kind=kind,
|
|
349
|
+
)
|
markdown_docx/models.py
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass, field
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Literal, TypeAlias
|
|
6
|
+
|
|
7
|
+
Orientation = Literal["portrait", "landscape"]
|
|
8
|
+
Alignment = Literal["left", "center", "right"]
|
|
9
|
+
ListKind = Literal["ordered", "unordered"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@dataclass(slots=True, frozen=True)
|
|
13
|
+
class PageSize:
|
|
14
|
+
width: int
|
|
15
|
+
height: int
|
|
16
|
+
name: str | None = None
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass(slots=True, frozen=True)
|
|
20
|
+
class Margins:
|
|
21
|
+
top: int
|
|
22
|
+
right: int
|
|
23
|
+
bottom: int
|
|
24
|
+
left: int
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(slots=True, frozen=True)
|
|
28
|
+
class SectionSettings:
|
|
29
|
+
page_size: PageSize
|
|
30
|
+
orientation: Orientation
|
|
31
|
+
margins: Margins
|
|
32
|
+
|
|
33
|
+
@property
|
|
34
|
+
def effective_width(self) -> int:
|
|
35
|
+
return self.page_size.height if self.orientation == "landscape" else self.page_size.width
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def effective_height(self) -> int:
|
|
39
|
+
return self.page_size.width if self.orientation == "landscape" else self.page_size.height
|
|
40
|
+
|
|
41
|
+
@property
|
|
42
|
+
def usable_width(self) -> int:
|
|
43
|
+
return self.effective_width - self.margins.left - self.margins.right
|
|
44
|
+
|
|
45
|
+
@property
|
|
46
|
+
def usable_height(self) -> int:
|
|
47
|
+
return self.effective_height - self.margins.top - self.margins.bottom
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
@dataclass(slots=True)
|
|
51
|
+
class StyleMappings:
|
|
52
|
+
paragraph: str = "Normal"
|
|
53
|
+
headings: dict[int, str] = field(default_factory=lambda: {level: f"Heading {level}" for level in range(1, 7)})
|
|
54
|
+
blockquote: str = "Quote"
|
|
55
|
+
code_block: str = "Code Block"
|
|
56
|
+
ordered_list: list[str] = field(default_factory=lambda: ["List Number", "List Number 2", "List Number 3"])
|
|
57
|
+
unordered_list: list[str] = field(default_factory=lambda: ["List Bullet", "List Bullet 2", "List Bullet 3"])
|
|
58
|
+
table: str = "Table Grid"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass(slots=True)
|
|
62
|
+
class FontOverrides:
|
|
63
|
+
body: str | None = None
|
|
64
|
+
headings: str | None = None
|
|
65
|
+
monospace: str = "Consolas"
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@dataclass(slots=True)
|
|
69
|
+
class DocumentOptions:
|
|
70
|
+
section: SectionSettings
|
|
71
|
+
styles: StyleMappings = field(default_factory=StyleMappings)
|
|
72
|
+
fonts: FontOverrides = field(default_factory=FontOverrides)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(slots=True, frozen=True)
|
|
76
|
+
class TableOptions:
|
|
77
|
+
style: str | None = None
|
|
78
|
+
alignment: Alignment = "left"
|
|
79
|
+
width: Literal["auto", "page"] = "page"
|
|
80
|
+
column_widths: tuple[float, ...] | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(slots=True, frozen=True)
|
|
84
|
+
class ImageOptions:
|
|
85
|
+
width: int | float | None = None
|
|
86
|
+
width_is_percent: bool = False
|
|
87
|
+
alignment: Alignment = "left"
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
@dataclass(slots=True)
|
|
91
|
+
class InlineFragment:
|
|
92
|
+
kind: Literal["text", "break", "image"]
|
|
93
|
+
text: str | None = None
|
|
94
|
+
src: str | None = None
|
|
95
|
+
alt: str | None = None
|
|
96
|
+
bold: bool = False
|
|
97
|
+
italic: bool = False
|
|
98
|
+
code: bool = False
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@dataclass(slots=True)
|
|
102
|
+
class ParagraphBlock:
|
|
103
|
+
line: int
|
|
104
|
+
fragments: list[InlineFragment]
|
|
105
|
+
role: Literal["paragraph", "blockquote"] = "paragraph"
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(slots=True)
|
|
109
|
+
class HeadingBlock:
|
|
110
|
+
line: int
|
|
111
|
+
level: int
|
|
112
|
+
fragments: list[InlineFragment]
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
@dataclass(slots=True)
|
|
116
|
+
class CodeBlock:
|
|
117
|
+
line: int
|
|
118
|
+
text: str
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
@dataclass(slots=True)
|
|
122
|
+
class ListParagraphBlock:
|
|
123
|
+
line: int
|
|
124
|
+
fragments: list[InlineFragment]
|
|
125
|
+
list_kind: ListKind
|
|
126
|
+
depth: int
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
@dataclass(slots=True)
|
|
130
|
+
class TableCell:
|
|
131
|
+
fragments: list[InlineFragment]
|
|
132
|
+
alignment: Alignment
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
@dataclass(slots=True)
|
|
136
|
+
class TableBlock:
|
|
137
|
+
line: int
|
|
138
|
+
headers: list[TableCell]
|
|
139
|
+
rows: list[list[TableCell]]
|
|
140
|
+
options: TableOptions
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@dataclass(slots=True)
|
|
144
|
+
class ImageBlock:
|
|
145
|
+
line: int
|
|
146
|
+
src: str
|
|
147
|
+
alt: str
|
|
148
|
+
options: ImageOptions
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
@dataclass(slots=True)
|
|
152
|
+
class PageBreakBlock:
|
|
153
|
+
line: int
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass(slots=True)
|
|
157
|
+
class SectionBreakBlock:
|
|
158
|
+
line: int
|
|
159
|
+
settings: SectionSettings
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
Block: TypeAlias = (
|
|
163
|
+
ParagraphBlock
|
|
164
|
+
| HeadingBlock
|
|
165
|
+
| CodeBlock
|
|
166
|
+
| ListParagraphBlock
|
|
167
|
+
| TableBlock
|
|
168
|
+
| ImageBlock
|
|
169
|
+
| PageBreakBlock
|
|
170
|
+
| SectionBreakBlock
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
@dataclass(slots=True)
|
|
175
|
+
class DocumentModel:
|
|
176
|
+
input_path: Path | None
|
|
177
|
+
source_name: str
|
|
178
|
+
options: DocumentOptions
|
|
179
|
+
blocks: list[Block]
|
|
180
|
+
warnings: list[str] = field(default_factory=list)
|