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.
@@ -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
+ )
@@ -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)