pytex-preprocessor 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.
Files changed (119) hide show
  1. pytex/__init__.py +87 -0
  2. pytex/commands/__init__.py +51 -0
  3. pytex/commands/biblatex.py +98 -0
  4. pytex/commands/builtin.py +598 -0
  5. pytex/commands/captions.py +56 -0
  6. pytex/commands/cleveref.py +43 -0
  7. pytex/commands/colors.py +60 -0
  8. pytex/commands/conditionals.py +62 -0
  9. pytex/commands/counters.py +85 -0
  10. pytex/commands/definitions.py +109 -0
  11. pytex/commands/floats.py +93 -0
  12. pytex/commands/font.py +138 -0
  13. pytex/commands/fontawesome.py +88 -0
  14. pytex/commands/fontspec.py +75 -0
  15. pytex/commands/geometry.py +25 -0
  16. pytex/commands/glossaries.py +126 -0
  17. pytex/commands/graphics.py +68 -0
  18. pytex/commands/hooks.py +58 -0
  19. pytex/commands/hyperref.py +57 -0
  20. pytex/commands/lengths.py +200 -0
  21. pytex/commands/listings.py +63 -0
  22. pytex/commands/mdframed.py +43 -0
  23. pytex/commands/picture.py +32 -0
  24. pytex/commands/setspace.py +38 -0
  25. pytex/commands/tables.py +123 -0
  26. pytex/helpers/__init__.py +3 -0
  27. pytex/helpers/coerce.py +13 -0
  28. pytex/helpers/parenting.py +13 -0
  29. pytex/helpers/sanitize.py +54 -0
  30. pytex/helpers/with_package.py +61 -0
  31. pytex/interface/__init__.py +3 -0
  32. pytex/interface/control_sequence.py +29 -0
  33. pytex/interface/package.py +52 -0
  34. pytex/interface/tex.py +41 -0
  35. pytex/model/__init__.py +25 -0
  36. pytex/model/color.py +203 -0
  37. pytex/model/concat.py +31 -0
  38. pytex/model/control_sequence.py +72 -0
  39. pytex/model/document.py +120 -0
  40. pytex/model/document_class.py +29 -0
  41. pytex/model/empty.py +19 -0
  42. pytex/model/environment.py +30 -0
  43. pytex/model/image.py +137 -0
  44. pytex/model/include.py +21 -0
  45. pytex/model/length.py +54 -0
  46. pytex/model/math.py +401 -0
  47. pytex/model/package.py +132 -0
  48. pytex/model/raw.py +61 -0
  49. pytex/packages.py +221 -0
  50. pytex/registry.py +49 -0
  51. pytex_builder/__init__.py +8 -0
  52. pytex_builder/build.py +175 -0
  53. pytex_builder/console.py +77 -0
  54. pytex_builder/render.py +90 -0
  55. pytex_builder/tectonic.py +370 -0
  56. pytex_hsrtreport/__init__.py +116 -0
  57. pytex_hsrtreport/assets/fonts/Blender/Blender-Bold.ttf +0 -0
  58. pytex_hsrtreport/assets/fonts/Blender/Blender-BoldItalic.ttf +0 -0
  59. pytex_hsrtreport/assets/fonts/Blender/Blender-Book.ttf +0 -0
  60. pytex_hsrtreport/assets/fonts/Blender/Blender-BookItalic.ttf +0 -0
  61. pytex_hsrtreport/assets/fonts/Blender/Blender-Medium.ttf +0 -0
  62. pytex_hsrtreport/assets/fonts/Blender/Blender-MediumItalic.ttf +0 -0
  63. pytex_hsrtreport/assets/fonts/Blender/Blender-Strong.ttf +0 -0
  64. pytex_hsrtreport/assets/fonts/Blender/Blender-Thin.ttf +0 -0
  65. pytex_hsrtreport/assets/fonts/Blender/Blender-ThinItalic.ttf +0 -0
  66. pytex_hsrtreport/assets/fonts/DIN/DIN-Black.ttf +0 -0
  67. pytex_hsrtreport/assets/fonts/DIN/DIN-Bold.ttf +0 -0
  68. pytex_hsrtreport/assets/fonts/DIN/DIN-BoldItalic.ttf +0 -0
  69. pytex_hsrtreport/assets/fonts/DIN/DIN-Italic.ttf +0 -0
  70. pytex_hsrtreport/assets/fonts/DIN/DIN-Medium.ttf +0 -0
  71. pytex_hsrtreport/assets/fonts/DIN/DIN-Regular.ttf +0 -0
  72. pytex_hsrtreport/assets/fonts/Times New Roman.ttf +0 -0
  73. pytex_hsrtreport/assets/logos/ASTA.svg +79 -0
  74. pytex_hsrtreport/assets/logos/DUMMY.png +0 -0
  75. pytex_hsrtreport/assets/logos/DUMMY_FOOT.png +0 -0
  76. pytex_hsrtreport/assets/logos/ECHO.svg +226 -0
  77. pytex_hsrtreport/assets/logos/HSRT.pdf +0 -0
  78. pytex_hsrtreport/assets/logos/INF.pdf +0 -0
  79. pytex_hsrtreport/assets/logos/STUPA.pdf +0 -0
  80. pytex_hsrtreport/assets/logos/Skyline.pdf +0 -0
  81. pytex_hsrtreport/boxes.py +215 -0
  82. pytex_hsrtreport/citations.py +21 -0
  83. pytex_hsrtreport/cleveref_names.py +47 -0
  84. pytex_hsrtreport/colors.py +30 -0
  85. pytex_hsrtreport/document.py +307 -0
  86. pytex_hsrtreport/fonts.py +66 -0
  87. pytex_hsrtreport/glossary.py +61 -0
  88. pytex_hsrtreport/hyperref_config.py +49 -0
  89. pytex_hsrtreport/listings.py +90 -0
  90. pytex_hsrtreport/logos.py +234 -0
  91. pytex_hsrtreport/pagebreak.py +67 -0
  92. pytex_hsrtreport/pagesetup.py +33 -0
  93. pytex_hsrtreport/tex/pagesetup.tex +76 -0
  94. pytex_hsrtreport/titlepage.py +136 -0
  95. pytex_hsrtreport/variants.py +24 -0
  96. pytex_hsrtreport/voting.py +96 -0
  97. pytex_hsrtreport/watermark.py +63 -0
  98. pytex_hsrtreport/wordcount.py +33 -0
  99. pytex_koma/__init__.py +90 -0
  100. pytex_koma/commands.py +296 -0
  101. pytex_koma/document.py +138 -0
  102. pytex_markdown/__init__.py +62 -0
  103. pytex_markdown/convert.py +271 -0
  104. pytex_markdown/escape.py +11 -0
  105. pytex_preprocessor-0.1.0.dist-info/METADATA +82 -0
  106. pytex_preprocessor-0.1.0.dist-info/RECORD +119 -0
  107. pytex_preprocessor-0.1.0.dist-info/WHEEL +5 -0
  108. pytex_preprocessor-0.1.0.dist-info/entry_points.txt +2 -0
  109. pytex_preprocessor-0.1.0.dist-info/top_level.txt +7 -0
  110. pytex_protocol/__init__.py +37 -0
  111. pytex_protocol/convert.py +202 -0
  112. pytex_protocol/document.py +91 -0
  113. pytex_protocol/entries.py +96 -0
  114. pytex_protocol/frontmatter.py +80 -0
  115. pytex_protocol/header.py +139 -0
  116. pytex_protocol/shortcodes.py +130 -0
  117. pytex_protocol/signatures.py +84 -0
  118. pytex_tikz/__init__.py +25 -0
  119. pytex_tikz/tikz.py +272 -0
@@ -0,0 +1,202 @@
1
+ """A Markdown converter for meeting protocols.
2
+
3
+ Extends the base :class:`pytex_markdown.convert.MarkdownConverter` with:
4
+
5
+ * protocol callouts - ``> [!beschluss]``, ``> [!abstimmung]`` (parsed into a
6
+ vote tally), ``> [!aufgabe]``, ``> [!frist]`` - on top of the inherited
7
+ GitHub/Obsidian callouts;
8
+ * inline ``{{shortcode}}`` expansion in every run of text.
9
+
10
+ Agenda items (TOPs) are plain Markdown headings, so they need no special
11
+ handling - they become numbered sections via the base converter.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import re
17
+ from typing import TYPE_CHECKING, Final, cast, override
18
+
19
+ from pytex.model.concat import Concat
20
+ from pytex_markdown.convert import CALLOUT_RE, MarkdownConverter
21
+
22
+ from .entries import ActionItem, Deadline, Decision, Vote
23
+ from .shortcodes import expand_inline_shortcodes
24
+ from .signatures import SignatureLines
25
+
26
+ if TYPE_CHECKING:
27
+ from collections.abc import Callable, Mapping
28
+
29
+ from pytex.interface.tex import TeX
30
+
31
+ from .frontmatter import FrontmatterValue
32
+
33
+ __all__ = ["ProtocolConverter"]
34
+
35
+ # Callout marker -> single-body entry factory.
36
+ _PROTOCOL_CALLOUTS: Final[dict[str, Callable[[TeX | str], TeX]]] = {
37
+ "BESCHLUSS": Decision,
38
+ "DECISION": Decision,
39
+ "AUFGABE": ActionItem,
40
+ "TODO": ActionItem,
41
+ "ACTION": ActionItem,
42
+ "FRIST": Deadline,
43
+ "DEADLINE": Deadline,
44
+ }
45
+ _VOTE_MARKERS: Final[frozenset[str]] = frozenset({"ABSTIMMUNG", "VOTE"})
46
+ _SIGNATURE_MARKERS: Final[frozenset[str]] = frozenset({"UNTERSCHRIFTEN", "SIGNATURES"})
47
+
48
+ _TALLY_RE: Final[dict[str, re.Pattern[str]]] = {
49
+ "yes": re.compile(r"(?:ja|yes)\s*[:=]?\s*(\d+)", re.IGNORECASE),
50
+ "no": re.compile(r"(?:nein|no)\s*[:=]?\s*(\d+)", re.IGNORECASE),
51
+ "abstain": re.compile(r"(?:enthaltung|enth\.?|abstain)\s*[:=]?\s*(\d+)", re.I),
52
+ }
53
+
54
+
55
+ def _kind(node: object) -> str:
56
+ return type(node).__name__
57
+
58
+
59
+ def _text(node: object) -> str | None:
60
+ children = getattr(node, "children", None)
61
+ return children if isinstance(children, str) else None
62
+
63
+
64
+ def _children(node: object) -> list[object]:
65
+ children = getattr(node, "children", None)
66
+ return cast("list[object]", children) if isinstance(children, list) else []
67
+
68
+
69
+ def _all_text(node: object) -> str:
70
+ """Concatenate every literal text fragment beneath `node`."""
71
+ text = _text(node)
72
+ if text is not None:
73
+ return text
74
+ return "".join(_all_text(c) for c in _children(node))
75
+
76
+
77
+ def _tally(text: str, key: str) -> int:
78
+ match = _TALLY_RE[key].search(text)
79
+ return int(match.group(1)) if match else 0
80
+
81
+
82
+ def _leaf_texts(node: object) -> list[str]:
83
+ """Every leaf text fragment beneath `node`, in order (one per source line)."""
84
+ text = _text(node)
85
+ if text is not None:
86
+ return [text]
87
+ out: list[str] = []
88
+ for child in _children(node):
89
+ out.extend(_leaf_texts(child))
90
+ return out
91
+
92
+
93
+ class ProtocolConverter(MarkdownConverter):
94
+ """`MarkdownConverter` with protocol callouts and inline shortcodes."""
95
+
96
+ meta: Mapping[str, FrontmatterValue]
97
+
98
+ def __init__(
99
+ self,
100
+ *,
101
+ meta: Mapping[str, FrontmatterValue] | None = None,
102
+ base_level: int = 0,
103
+ callouts: bool = True,
104
+ ) -> None:
105
+ super().__init__(base_level=base_level, callouts=callouts)
106
+ self.meta = meta or {}
107
+
108
+ # -- inline: splice {{shortcodes}} into otherwise-plain text ----------
109
+
110
+ @override
111
+ def inline(self, node: object) -> TeX:
112
+ if _kind(node) != "CodeSpan":
113
+ text = _text(node)
114
+ if text is not None and "{{" in text:
115
+ return expand_inline_shortcodes(text, self.meta)
116
+ return super().inline(node)
117
+
118
+ # -- blocks: protocol callouts ----------------------------------------
119
+
120
+ @override
121
+ def _as_callout(self, kids: list[object]) -> TeX | None:
122
+ marker = self._protocol_marker(kids)
123
+ if marker is None:
124
+ return super()._as_callout(kids)
125
+ name, title, head_rest, rest_blocks = marker
126
+ if name in _VOTE_MARKERS:
127
+ return self._vote_callout(title, kids)
128
+ if name in _SIGNATURE_MARKERS:
129
+ return self._signature_callout(kids)
130
+ factory = _PROTOCOL_CALLOUTS.get(name)
131
+ if factory is None:
132
+ return super()._as_callout(kids)
133
+ # First paragraph: marker title + its remaining inlines; then any
134
+ # following blocks of the callout.
135
+ head = Concat(self.inline_text(title), *(self.inline(c) for c in head_rest))
136
+ body_parts = [head, *(self.block(b) for b in rest_blocks)]
137
+ return factory(Concat(*_join(body_parts)))
138
+
139
+ def _protocol_marker(
140
+ self, kids: list[object]
141
+ ) -> tuple[str, str, list[object], list[object]] | None:
142
+ """Return (MARKER, title-after-marker, rest-of-first-paragraph,
143
+ following-blocks) if kids open a protocol callout we own, else None."""
144
+ if not kids or _kind(kids[0]) != "Paragraph":
145
+ return None
146
+ inner = _children(kids[0])
147
+ first = _text(inner[0]) if inner else None
148
+ if first is None:
149
+ return None
150
+ match = CALLOUT_RE.match(first)
151
+ if match is None:
152
+ return None
153
+ name = match.group(1).upper()
154
+ if (
155
+ name not in _PROTOCOL_CALLOUTS
156
+ and name not in _VOTE_MARKERS
157
+ and name not in _SIGNATURE_MARKERS
158
+ ):
159
+ return None
160
+ title = first[match.end() :].strip()
161
+ return name, title, inner[1:], kids[1:]
162
+
163
+ def _signature_callout(self, kids: list[object]) -> TeX:
164
+ # Each line "Rolle: Name" becomes one signer; the marker is stripped
165
+ # from the first line.
166
+ lines = [frag for k in kids for frag in _leaf_texts(k)]
167
+ if lines:
168
+ lines[0] = CALLOUT_RE.sub("", lines[0], count=1)
169
+ signers: list[tuple[str, str]] = []
170
+ for line in (entry.strip() for entry in lines):
171
+ if not line:
172
+ continue
173
+ role, _, person = line.partition(":")
174
+ signers.append((role.strip(), person.strip()))
175
+ return SignatureLines(*signers)
176
+
177
+ def _vote_callout(self, title: str, kids: list[object]) -> TeX:
178
+ text = " ".join(_all_text(k) for k in kids)
179
+ return Vote(
180
+ yes=_tally(text, "yes"),
181
+ no=_tally(text, "no"),
182
+ abstain=_tally(text, "abstain"),
183
+ body=self.inline_text(title) if title else "",
184
+ )
185
+
186
+ def inline_text(self, text: str) -> TeX:
187
+ """Expand `{{shortcodes}}` and escape the rest of a bare string."""
188
+ return expand_inline_shortcodes(text, self.meta)
189
+
190
+
191
+ def _join(blocks: list[TeX]) -> list[TeX]:
192
+ from pytex.model.empty import Empty
193
+ from pytex.model.raw import Raw
194
+
195
+ parbreak = Raw("\n\n")
196
+ kept = [b for b in blocks if b is not Empty]
197
+ out: list[TeX] = []
198
+ for i, b in enumerate(kept):
199
+ if i:
200
+ out.append(parbreak)
201
+ out.append(b)
202
+ return out
@@ -0,0 +1,91 @@
1
+ """Render a STUPA/AStA meeting protocol from Obsidian-flavoured Markdown.
2
+
3
+ The protocol is an :class:`~pytex_hsrtreport.document.HSRTReport` configured for
4
+ a short minutes document: no title page, a compact header block (built from the
5
+ frontmatter) at the top, agenda items as numbered sections, and protocol
6
+ entries (decisions, votes, action items) rendered as HSRT callout boxes.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ import marko
15
+
16
+ from pytex.model.concat import Concat
17
+ from pytex.model.raw import Raw
18
+ from pytex.registry import Registry
19
+ from pytex_hsrtreport.document import HSRTReport
20
+ from pytex_hsrtreport.variants import Variant
21
+
22
+ from .convert import ProtocolConverter
23
+ from .frontmatter import FrontmatterValue, split_frontmatter
24
+ from .header import header_from_meta
25
+ from .signatures import signature_block_from_meta
26
+
27
+ if TYPE_CHECKING:
28
+ from pytex.interface.tex import TeX
29
+
30
+ __all__ = ["IncludeProtocol", "Protocol", "render_protocol"]
31
+
32
+ _PARSER = marko.Markdown()
33
+
34
+ _VARIANTS: dict[str, Variant] = {
35
+ "stupa": Variant.STUPA,
36
+ "asta": Variant.ASTA,
37
+ "echo": Variant.ECHO,
38
+ }
39
+
40
+
41
+ def _variant(meta: dict[str, FrontmatterValue]) -> Variant:
42
+ gremium = meta.get("gremium", "")
43
+ key = gremium.lower().strip() if isinstance(gremium, str) else ""
44
+ return _VARIANTS.get(key, Variant.STUPA)
45
+
46
+
47
+ def _document_title(meta: dict[str, FrontmatterValue]) -> str:
48
+ gremium = meta.get("gremium")
49
+ datum = meta.get("datum")
50
+ head = (
51
+ f"{gremium} — Protokoll"
52
+ if isinstance(gremium, str) and gremium
53
+ else "Protokoll"
54
+ )
55
+ return f"{head} ({datum})" if isinstance(datum, str) and datum else head
56
+
57
+
58
+ def render_protocol(text: str, *, base_level: int = 0) -> HSRTReport:
59
+ """Build an `HSRTReport` from the Markdown source of a protocol."""
60
+ meta, body_md = split_frontmatter(text)
61
+ converter = ProtocolConverter(meta=meta, base_level=base_level)
62
+ converted = converter.block(_PARSER.parse(body_md))
63
+ header = header_from_meta(meta)
64
+ # Optional sign-off block, appended when the frontmatter lists `unterschriften`.
65
+ signatures = signature_block_from_meta(meta)
66
+ tail = (Raw("\n\n"), signatures) if signatures is not None else ()
67
+ return HSRTReport(
68
+ variant=_variant(meta),
69
+ document_class="scrbook",
70
+ show_titlepage=False,
71
+ show_toc=False,
72
+ show_footer_logos=True,
73
+ title=_document_title(meta),
74
+ # Agenda items are top-level `#` headings -> \section. In scrbook a
75
+ # chapterless section would number as "0.1"; flatten it to a plain
76
+ # arabic counter so TOPs read 1, 2, 3, ...
77
+ user_preamble=Raw(r"\renewcommand*{\thesection}{\arabic{section}}"),
78
+ body=Concat(header, Raw("\n\n"), converted, *tail),
79
+ )
80
+
81
+
82
+ @Registry.add
83
+ def Protocol(text: str, *, base_level: int = 0) -> TeX:
84
+ """Convert a protocol Markdown string to a renderable `HSRTReport`."""
85
+ return render_protocol(text, base_level=base_level)
86
+
87
+
88
+ @Registry.add
89
+ def IncludeProtocol(path: str | Path, *, encoding: str = "utf-8") -> TeX:
90
+ """Read a protocol Markdown file and render it (see :func:`render_protocol`)."""
91
+ return render_protocol(Path(path).read_text(encoding=encoding))
@@ -0,0 +1,96 @@
1
+ """Protocol entry factories, styled to match the HSRTReport callout language.
2
+
3
+ Each entry is a coloured box (or, for votes, the existing ``VotingResults``
4
+ tally) so a protocol reads with the same visual vocabulary as a report. Labels
5
+ are German, matching the STUPA/AStA context.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ from pytex.commands.builtin import Textbf, Textit
13
+ from pytex.commands.colors import Textcolor
14
+ from pytex.commands.fontawesome import FaIcon
15
+ from pytex.model.concat import Concat
16
+ from pytex.registry import Registry
17
+ from pytex_hsrtreport.boxes import ColoredBox
18
+ from pytex_hsrtreport.voting import VotingResults
19
+
20
+ if TYPE_CHECKING:
21
+ from pytex.interface.tex import TeX
22
+
23
+ __all__ = [
24
+ "ActionItem",
25
+ "Deadline",
26
+ "Decision",
27
+ "Timestamp",
28
+ "Vote",
29
+ ]
30
+
31
+
32
+ def _labelled_box(
33
+ label: str,
34
+ body: TeX | str,
35
+ *,
36
+ icon: str,
37
+ color: str,
38
+ ) -> TeX:
39
+ """A ColoredBox whose body is prefixed with a bold label, matching _preset."""
40
+ return ColoredBox(
41
+ body=Concat(Textbf(f"{label}: "), body),
42
+ icon=FaIcon(icon),
43
+ icon_color=color,
44
+ icon_size="24pt",
45
+ icon_offset_x="1.5pt",
46
+ background_color=color,
47
+ )
48
+
49
+
50
+ @Registry.add
51
+ def Decision(body: TeX | str) -> TeX:
52
+ """A resolution/`Beschluss` box (gavel, HSRT green)."""
53
+ return _labelled_box("Beschluss", body, icon="gavel", color="britishracinggreen")
54
+
55
+
56
+ @Registry.add
57
+ def Deadline(body: TeX | str, due: str | None = None) -> TeX:
58
+ """A `Frist` (deadline) box. `due` is appended in parentheses when given."""
59
+ content = Concat(body, Textit(f" (bis {due})")) if due else body
60
+ return _labelled_box("Frist", content, icon="hourglass-half", color="orange")
61
+
62
+
63
+ @Registry.add
64
+ def ActionItem(
65
+ body: TeX | str,
66
+ who: str | None = None,
67
+ due: str | None = None,
68
+ ) -> TeX:
69
+ """An `Aufgabe` (action item) box with optional assignee and due date."""
70
+ meta = ", ".join(
71
+ part
72
+ for part in (
73
+ f"Zuständig: {who}" if who else "",
74
+ f"Frist: {due}" if due else "",
75
+ )
76
+ if part
77
+ )
78
+ content = Concat(body, Textit(f" ({meta})")) if meta else body
79
+ return _labelled_box("Aufgabe", content, icon="clipboard-check", color="navyblue")
80
+
81
+
82
+ @Registry.add
83
+ def Vote(
84
+ yes: int,
85
+ no: int,
86
+ abstain: int = 0,
87
+ body: TeX | str = "",
88
+ ) -> TeX:
89
+ """A voting tally, reusing the report's `VotingResults` box."""
90
+ return VotingResults(yes=yes, no=no, abstain=abstain, body=body)
91
+
92
+
93
+ @Registry.add
94
+ def Timestamp(time: str) -> TeX:
95
+ """Inline timestamp: a clock glyph plus the time, in HSRT blue."""
96
+ return Textcolor("hanblue", Concat(FaIcon("clock"), " ", Textbf(time)))
@@ -0,0 +1,80 @@
1
+ """Minimal YAML-frontmatter splitter for protocol Markdown files.
2
+
3
+ Obsidian writes a ``---`` fenced YAML block at the top of a note. We only need
4
+ a tiny subset - scalars, inline flow lists (``[a, b]``) and block lists
5
+ (``- item``) - so this is parsed without a YAML dependency.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ __all__ = ["FrontmatterValue", "split_frontmatter"]
11
+
12
+ type FrontmatterValue = str | list[str]
13
+
14
+
15
+ def _strip_quotes(text: str) -> str:
16
+ text = text.strip()
17
+ if len(text) >= 2 and text[0] in "\"'" and text[-1] == text[0]:
18
+ return text[1:-1]
19
+ return text
20
+
21
+
22
+ def _parse_flow_list(text: str) -> list[str]:
23
+ """Parse an inline ``[a, b, c]`` list into stripped, unquoted items."""
24
+ inner = text.strip()[1:-1]
25
+ return [_strip_quotes(item) for item in inner.split(",") if item.strip()]
26
+
27
+
28
+ def _parse_block(lines: list[str]) -> dict[str, FrontmatterValue]:
29
+ meta: dict[str, FrontmatterValue] = {}
30
+ key: str | None = None
31
+ for raw in lines:
32
+ line = raw.rstrip()
33
+ if not line.strip():
34
+ continue
35
+ # A block-list continuation belongs to the most recent key.
36
+ if line.lstrip().startswith("- ") and key is not None:
37
+ item = _strip_quotes(line.lstrip()[2:])
38
+ existing = meta.get(key)
39
+ if isinstance(existing, list):
40
+ existing.append(item)
41
+ else:
42
+ meta[key] = [item]
43
+ continue
44
+ if ":" not in line:
45
+ continue
46
+ key, _, value = line.partition(":")
47
+ key = key.strip()
48
+ value = value.strip()
49
+ if not value:
50
+ # Either a block list follows, or an empty scalar.
51
+ meta[key] = ""
52
+ elif value.startswith("[") and value.endswith("]"):
53
+ meta[key] = _parse_flow_list(value)
54
+ else:
55
+ meta[key] = _strip_quotes(value)
56
+ return meta
57
+
58
+
59
+ def split_frontmatter(text: str) -> tuple[dict[str, FrontmatterValue], str]:
60
+ """Split ``text`` into (frontmatter mapping, remaining body).
61
+
62
+ Returns an empty mapping and the unchanged text when no ``---`` fence opens
63
+ the document.
64
+ """
65
+ if not text.lstrip().startswith("---"):
66
+ return {}, text
67
+ # Normalise to make the leading fence easy to consume.
68
+ stripped = text.lstrip("\n")
69
+ lines = stripped.splitlines()
70
+ if not lines or lines[0].strip() != "---":
71
+ return {}, text
72
+ end = next(
73
+ (i for i in range(1, len(lines)) if lines[i].strip() == "---"),
74
+ None,
75
+ )
76
+ if end is None:
77
+ return {}, text
78
+ meta = _parse_block(lines[1:end])
79
+ body = "\n".join(lines[end + 1 :]).lstrip("\n")
80
+ return meta, body
@@ -0,0 +1,139 @@
1
+ """The compact protocol header block rendered at the top of a protocol.
2
+
3
+ Maps the parsed frontmatter to a single HSRT-styled box: meeting body + date
4
+ on top, then the organisational fields and the attendance lists. German labels
5
+ throughout (STUPA/AStA context).
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from dataclasses import dataclass, field
11
+ from typing import TYPE_CHECKING, override
12
+
13
+ from pytex.commands.builtin import Textbf
14
+ from pytex.commands.fontawesome import FaIcon
15
+ from pytex.helpers.sanitize import escape_latex
16
+ from pytex.interface.tex import TeX
17
+ from pytex.model.concat import Concat
18
+ from pytex.model.raw import Raw
19
+ from pytex.registry import Registry
20
+ from pytex_hsrtreport.boxes import ColoredBox
21
+
22
+ if TYPE_CHECKING:
23
+ from collections.abc import Iterator, Mapping
24
+
25
+ from .frontmatter import FrontmatterValue
26
+
27
+ __all__ = ["ProtocolHeader", "header_from_meta"]
28
+
29
+ # Frontmatter key -> human label for the single-value organisational fields.
30
+ _FIELD_LABELS: tuple[tuple[str, str], ...] = (
31
+ ("ort", "Ort"),
32
+ ("sitzungsleitung", "Sitzungsleitung"),
33
+ ("protokoll", "Protokoll"),
34
+ )
35
+ # Frontmatter key -> label for the attendance lists (rendered with a count).
36
+ _LIST_LABELS: tuple[tuple[str, str], ...] = (
37
+ ("anwesend", "Anwesend"),
38
+ ("entschuldigt", "Entschuldigt"),
39
+ ("abwesend", "Abwesend"),
40
+ ("gaeste", "Gäste"),
41
+ )
42
+
43
+
44
+ def _as_list(value: FrontmatterValue | None) -> list[str]:
45
+ if value is None:
46
+ return []
47
+ if isinstance(value, list):
48
+ return value
49
+ return [v.strip() for v in value.split(",") if v.strip()]
50
+
51
+
52
+ def _line(label: str, value: str) -> TeX:
53
+ return Concat(Textbf(f"{label}: "), Raw(escape_latex(value)))
54
+
55
+
56
+ @Registry.add
57
+ @dataclass(frozen=True)
58
+ class ProtocolHeader(TeX):
59
+ """A compact meeting-header box: title, date/time, fields, attendance."""
60
+
61
+ gremium: str = ""
62
+ datum: str = ""
63
+ beginn: str = ""
64
+ ende: str = ""
65
+ fields: Mapping[str, str] = field(default_factory=dict[str, str])
66
+ attendance: Mapping[str, list[str]] = field(default_factory=dict[str, list[str]])
67
+ _parent: TeX | None = field(default=None, init=False, compare=False, repr=False)
68
+
69
+ @property
70
+ def title(self) -> str:
71
+ body = self.gremium.strip()
72
+ return f"{body} — Protokoll" if body else "Protokoll"
73
+
74
+ def _datetime_line(self) -> str | None:
75
+ when = " – ".join(t for t in (self.beginn, self.ende) if t) # noqa: RUF001
76
+ parts = ", ".join(p for p in (self.datum, when) if p)
77
+ return parts or None
78
+
79
+ def _lines(self) -> Iterator[TeX]:
80
+ yield Textbf(Raw(escape_latex(self.title)))
81
+ dt = self._datetime_line()
82
+ if dt:
83
+ yield _line("Datum", dt)
84
+ for key, label in _FIELD_LABELS:
85
+ value = self.fields.get(key, "")
86
+ if value:
87
+ yield _line(label, value)
88
+ for key, label in _LIST_LABELS:
89
+ people = self.attendance.get(key) or []
90
+ if people:
91
+ yield _line(f"{label} ({len(people)})", ", ".join(people))
92
+
93
+ @property
94
+ @override
95
+ def rendered(self) -> str:
96
+ body = Concat(*_intersperse(self._lines(), Raw(r"\\")))
97
+ return ColoredBox(
98
+ body=body,
99
+ icon=FaIcon("users"),
100
+ icon_color="hanblue",
101
+ icon_size="26pt",
102
+ background_color="hanblue",
103
+ ).rendered
104
+
105
+
106
+ def _intersperse(items: Iterator[TeX], sep: TeX) -> Iterator[TeX]:
107
+ for i, item in enumerate(items):
108
+ if i:
109
+ yield sep
110
+ yield item
111
+
112
+
113
+ def header_from_meta(meta: Mapping[str, FrontmatterValue]) -> ProtocolHeader:
114
+ """Build a `ProtocolHeader` from parsed frontmatter (German keys, aliases)."""
115
+
116
+ def scalar(*keys: str) -> str:
117
+ for key in keys:
118
+ value = meta.get(key)
119
+ if isinstance(value, str) and value:
120
+ return value
121
+ return ""
122
+
123
+ fields = {key: scalar(key) for key, _ in _FIELD_LABELS if scalar(key)}
124
+ attendance = {
125
+ key: _as_list(meta.get(key))
126
+ for key, _ in _LIST_LABELS
127
+ if _as_list(meta.get(key))
128
+ }
129
+ # `gäste` is the natural German spelling; accept it as an alias for `gaeste`.
130
+ if "gaeste" not in attendance and _as_list(meta.get("gäste")):
131
+ attendance["gaeste"] = _as_list(meta.get("gäste"))
132
+ return ProtocolHeader(
133
+ gremium=scalar("gremium", "gremium"),
134
+ datum=scalar("datum", "date"),
135
+ beginn=scalar("beginn", "start"),
136
+ ende=scalar("ende", "end"),
137
+ fields=fields,
138
+ attendance=attendance,
139
+ )