endstation 0.2.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.
endstation/__init__.py ADDED
@@ -0,0 +1,60 @@
1
+ """Shared scaffolding for desktop scientific applications.
2
+
3
+ Not a science library. Everything here is the apparatus that surrounds an
4
+ analysis: guarding optional dependencies, reporting and cancelling slow work,
5
+ collecting form errors, locating research data that is deliberately not in the
6
+ checkout, running work off the UI thread, embedding a figure, rendering a
7
+ documentation page, and building a macOS bundle.
8
+
9
+ **The core imports nothing.** ``endstation``, ``endstation.optional``,
10
+ ``endstation.progress``, ``endstation.forms`` and ``endstation.paths`` have no
11
+ third-party dependencies at all, so a package that declares
12
+ ``dependencies = []`` can adopt them without giving that up. numpy, matplotlib
13
+ and a Qt binding live behind extras and are imported inside the functions that
14
+ need them:
15
+
16
+ ======================== =========== ====================================
17
+ Subpackage Extra Needs
18
+ ======================== =========== ====================================
19
+ ``endstation.qt`` ``qt`` PySide6, matplotlib
20
+ ``endstation.docs`` ``docs`` matplotlib, for math only
21
+ ``endstation.testing`` ``testing`` pytest
22
+ ``endstation.tools`` -- macOS ``sips``/``iconutil`` at runtime
23
+ ======================== =========== ====================================
24
+
25
+ Importing this module must therefore stay cheap and toolkit-free; the names
26
+ re-exported below are from the dependency-free core only. Reach into the
27
+ subpackages explicitly for anything else.
28
+ """
29
+
30
+ from __future__ import annotations
31
+
32
+ from .forms import ErrorCollector, FieldError, FormErrors
33
+ from .optional import MissingExtra, optional_module, require_extra
34
+ from .paths import MissingData, data_root, find_data, find_project_root, require_dir
35
+ from .progress import Cancelled, cancelling, report, throttled
36
+
37
+ __version__ = "0.2.0"
38
+
39
+ __all__ = [
40
+ "__version__",
41
+ # optional dependencies
42
+ "MissingExtra",
43
+ "optional_module",
44
+ "require_extra",
45
+ # progress and cancellation
46
+ "Cancelled",
47
+ "cancelling",
48
+ "report",
49
+ "throttled",
50
+ # form validation
51
+ "ErrorCollector",
52
+ "FieldError",
53
+ "FormErrors",
54
+ # paths
55
+ "MissingData",
56
+ "data_root",
57
+ "find_data",
58
+ "find_project_root",
59
+ "require_dir",
60
+ ]
@@ -0,0 +1,34 @@
1
+ """Documentation pages: an ordered index, and markdown rendered for a Qt viewer.
2
+
3
+ Four projects had a version of this and each had a capability the others
4
+ lacked -- one rendered LaTeX, one prerendered HTML for an older Qt, one shipped
5
+ its pages as package data, one published them to a GitHub wiki. This is the
6
+ union.
7
+
8
+ Toolkit-free: nothing here imports Qt. :mod:`endstation.docs.markdown` produces
9
+ HTML plus a dictionary of PNG bytes, and the widget layer registers those with
10
+ ``QTextDocument.addResource``. That keeps the whole pipeline testable down to
11
+ the bytes, with no display.
12
+
13
+ Needs matplotlib for math rendering, which is the ``mpl`` extra. Structure
14
+ conversion alone works without it -- pass ``render_math=False``.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ from .index import Page, PageIndex, load_pages, title_of
20
+ from .markdown import MATH_URL_PREFIX, RenderedPage, to_html
21
+ from .mathtext import Segment, split_segments, strip_boxed
22
+
23
+ __all__ = [
24
+ "MATH_URL_PREFIX",
25
+ "Page",
26
+ "PageIndex",
27
+ "RenderedPage",
28
+ "Segment",
29
+ "load_pages",
30
+ "split_segments",
31
+ "strip_boxed",
32
+ "title_of",
33
+ "to_html",
34
+ ]
@@ -0,0 +1,138 @@
1
+ """Which documentation pages exist, and what order they read in.
2
+
3
+ Toolkit-free on purpose, like the rest of the pure layer: a build script needs
4
+ to know what to generate, a test needs to check the set is current, and a CLI
5
+ ``--explain`` should be able to print the same prose the window shows. None of
6
+ those should need a GUI installed.
7
+
8
+ **Ordering is explicit, not alphabetical.** A reader should meet the overview
9
+ before the correction it motivates, and alphabetical ordering puts
10
+ ``facet-fitting`` before ``overview`` for no reason anyone can defend. A page
11
+ listed in the order but missing from disk is skipped rather than raising -- a
12
+ half-written set should not stop a window opening -- and a page found on disk
13
+ but absent from the order is appended, so adding a file does not also require
14
+ registering it in a second place.
15
+
16
+ **A missing directory degrades to an explanatory entry.** Pages that live at
17
+ the repository root ship in a checkout and not in a wheel; pages shipped as
18
+ package data ship in both. Either is a legitimate arrangement, but a Help menu
19
+ that silently loses half its items when installed is not -- so an unavailable
20
+ page still appears and says why.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ import re
26
+ from dataclasses import dataclass
27
+ from pathlib import Path
28
+
29
+ __all__ = ["Page", "PageIndex", "load_pages"]
30
+
31
+ _TITLE = re.compile(r"^#\s+(.+)$", re.MULTILINE)
32
+
33
+
34
+ @dataclass(frozen=True)
35
+ class Page:
36
+ """One documentation page, available or not."""
37
+
38
+ slug: str
39
+ title: str
40
+ path: Path | None = None
41
+ #: Why this page cannot be shown. Empty when it can.
42
+ unavailable: str = ""
43
+
44
+ @property
45
+ def available(self) -> bool:
46
+ return self.path is not None and not self.unavailable
47
+
48
+ def read(self) -> str:
49
+ """The markdown source, or the explanation if there is none."""
50
+ if not self.available:
51
+ return f"# {self.title}\n\n{self.unavailable}\n"
52
+ return self.path.read_text(encoding="utf-8")
53
+
54
+
55
+ def title_of(path: Path, fallback: str) -> str:
56
+ """The page's first ``# heading``, or a title made from its filename.
57
+
58
+ Reading the title from the content rather than a registry means renaming a
59
+ heading cannot leave a menu entry saying something the page does not.
60
+ """
61
+ try:
62
+ match = _TITLE.search(path.read_text(encoding="utf-8"))
63
+ except OSError: # pragma: no cover - unreadable file
64
+ return fallback
65
+ return match.group(1).strip() if match else fallback
66
+
67
+
68
+ def _pretty(slug: str) -> str:
69
+ return slug.replace("-", " ").replace("_", " ").strip().capitalize()
70
+
71
+
72
+ class PageIndex:
73
+ """An ordered collection of :class:`Page`, addressable by slug."""
74
+
75
+ def __init__(self, pages: list[Page]) -> None:
76
+ self._pages = list(pages)
77
+ self._by_slug = {p.slug: p for p in self._pages}
78
+
79
+ def __iter__(self):
80
+ return iter(self._pages)
81
+
82
+ def __len__(self) -> int:
83
+ return len(self._pages)
84
+
85
+ def __contains__(self, slug: object) -> bool:
86
+ return slug in self._by_slug
87
+
88
+ def __getitem__(self, slug: str) -> Page:
89
+ return self._by_slug[slug]
90
+
91
+ @property
92
+ def slugs(self) -> tuple[str, ...]:
93
+ return tuple(p.slug for p in self._pages)
94
+
95
+ def available(self) -> tuple[Page, ...]:
96
+ return tuple(p for p in self._pages if p.available)
97
+
98
+
99
+ def load_pages(
100
+ directory: Path | str,
101
+ order: tuple[str, ...] = (),
102
+ *,
103
+ missing_message: str | None = None,
104
+ ) -> PageIndex:
105
+ """Build an index from ``directory``.
106
+
107
+ ``order`` lists slugs -- filenames without ``.md`` -- in reading order.
108
+ Slugs in ``order`` but not on disk are skipped; files on disk but not in
109
+ ``order`` are appended in sorted order.
110
+
111
+ If ``directory`` does not exist and ``missing_message`` is given, every slug
112
+ in ``order`` still appears, marked unavailable and carrying that message.
113
+ Without a message, an absent directory yields an empty index.
114
+ """
115
+ directory = Path(directory)
116
+
117
+ if not directory.is_dir():
118
+ if missing_message is None:
119
+ return PageIndex([])
120
+ return PageIndex([
121
+ Page(slug=slug, title=_pretty(slug), unavailable=missing_message)
122
+ for slug in order
123
+ ])
124
+
125
+ on_disk = {p.stem: p for p in sorted(directory.glob("*.md"))}
126
+
127
+ pages: list[Page] = []
128
+ for slug in order:
129
+ path = on_disk.pop(slug, None)
130
+ if path is not None:
131
+ pages.append(Page(slug, title_of(path, _pretty(slug)), path))
132
+
133
+ # Whatever the order did not mention, so a new file shows up without also
134
+ # having to be registered.
135
+ for slug, path in on_disk.items():
136
+ pages.append(Page(slug, title_of(path, _pretty(slug)), path))
137
+
138
+ return PageIndex(pages)
@@ -0,0 +1,289 @@
1
+ r"""Documentation pages as rich text: markdown structure *and* typeset math.
2
+
3
+ Qt's rich-text engine parses a small HTML subset, which makes this cheap: this
4
+ module converts a page to that subset, and the widget layer only displays it.
5
+
6
+ Getting equations rendering and stopping there is a tempting place to stop, and
7
+ it goes bad: tables and bullet lists stay as literal pipes and dashes. Real
8
+ documentation puts the evidence *in* the tables -- an angle-convention
9
+ reconciliation, an errata list, a three-way comparison -- and those are the
10
+ content, in the one place a reader goes to understand what the software does.
11
+
12
+ **No markdown dependency.** The subset Qt renders is small enough that a full
13
+ CommonMark implementation would mostly be output this cannot use, and the
14
+ conversion has to interleave with math extraction anyway (see below). This
15
+ handles headings, paragraphs, bullet and numbered lists, tables, fenced and
16
+ inline code, bold, italic, links, block quotes and rules -- which is what the
17
+ pages actually contain.
18
+
19
+ Three things worth knowing before editing:
20
+
21
+ **Math is split out before anything is escaped.** Math spans in real pages
22
+ contain ``<`` and ``>``. Escaping first would hand mathtext ``t &lt; w``, which
23
+ it renders literally. So :func:`~endstation.docs.mathtext.split_segments` runs
24
+ on the raw source and only the *text* spans are escaped.
25
+
26
+ **Images are referenced by key, not embedded.** ``QTextDocument.loadResource``
27
+ does not handle ``data:`` URLs, so each rendered equation gets an
28
+ ``endstation-math:N`` src and the viewer registers the bytes with
29
+ ``addResource``. That also keeps this module free of base64 padding it would
30
+ only have to undo.
31
+
32
+ **A render failure degrades rather than crashing.** The raw ``$...$`` source
33
+ appears in a distinct colour, so a future edit using an unsupported macro is
34
+ diagnosable at a glance instead of taking down the page.
35
+ """
36
+
37
+ from __future__ import annotations
38
+
39
+ import html
40
+ import re
41
+ from dataclasses import dataclass, field
42
+
43
+ from .mathtext import render_math_png, split_segments
44
+
45
+ __all__ = ["MATH_URL_PREFIX", "RenderedPage", "UNRENDERED_COLOR", "to_html"]
46
+
47
+ #: Scheme the viewer registers rendered equations under. Not `data:`.
48
+ MATH_URL_PREFIX = "endstation-math:"
49
+
50
+ #: Colour for a math span mathtext could not parse. Visible, not alarming.
51
+ UNRENDERED_COLOR = "#b3541e"
52
+
53
+
54
+ @dataclass
55
+ class RenderedPage:
56
+ """HTML for a ``QTextDocument``, plus the images it refers to."""
57
+
58
+ html: str
59
+ images: dict[str, bytes] = field(default_factory=dict)
60
+ #: Math spans that failed to render, as raw LaTeX. Empty is the normal case.
61
+ unrendered: list[str] = field(default_factory=list)
62
+
63
+
64
+ # -- inline formatting ------------------------------------------------------
65
+
66
+ _INLINE_CODE = re.compile(r"`([^`]+)`")
67
+ _BOLD = re.compile(r"\*\*([^*]+)\*\*")
68
+ _ITALIC = re.compile(r"(?<![*\w])\*([^*\n]+)\*(?!\*)")
69
+ _LINK = re.compile(r"\[([^\]]+)\]\(([^)\s]+)\)")
70
+
71
+
72
+ def _inline(text: str) -> str:
73
+ """Escape, then apply inline markup. Order matters: escaping is first."""
74
+ out = html.escape(text)
75
+ # Code first, so `**` inside a code span is not turned into bold.
76
+ out = _INLINE_CODE.sub(
77
+ lambda m: f'<code style="background-color:#f0f0f0;">{m.group(1)}</code>',
78
+ out,
79
+ )
80
+ out = _BOLD.sub(r"<b>\1</b>", out)
81
+ out = _ITALIC.sub(r"<i>\1</i>", out)
82
+ out = _LINK.sub(r'<a href="\2">\1</a>', out)
83
+ return out
84
+
85
+
86
+ # -- block structure --------------------------------------------------------
87
+
88
+ _HEADING = re.compile(r"^(#{1,6})\s+(.*)$")
89
+ _BULLET = re.compile(r"^\s*[-*+]\s+(.*)$")
90
+ _NUMBERED = re.compile(r"^\s*\d+[.)]\s+(.*)$")
91
+ _RULE = re.compile(r"^\s*([-*_])\s*(\1\s*){2,}$")
92
+ _QUOTE = re.compile(r"^>\s?(.*)$")
93
+ _TABLE_DIVIDER = re.compile(r"^\s*\|?\s*:?-{2,}:?\s*(\|\s*:?-{2,}:?\s*)*\|?\s*$")
94
+
95
+
96
+ def _split_row(line: str) -> list[str]:
97
+ return [cell.strip() for cell in line.strip().strip("|").split("|")]
98
+
99
+
100
+ def _blocks_to_html(lines: list[str]) -> str:
101
+ """Convert already-math-substituted lines into Qt's HTML subset."""
102
+ out: list[str] = []
103
+ index = 0
104
+ total = len(lines)
105
+
106
+ while index < total:
107
+ line = lines[index]
108
+
109
+ if not line.strip():
110
+ index += 1
111
+ continue
112
+
113
+ # Fenced code
114
+ if line.lstrip().startswith("```"):
115
+ body: list[str] = []
116
+ index += 1
117
+ while index < total and not lines[index].lstrip().startswith("```"):
118
+ body.append(html.escape(lines[index]))
119
+ index += 1
120
+ index += 1 # closing fence
121
+ out.append(
122
+ '<pre style="background-color:#f0f0f0;">'
123
+ + "\n".join(body)
124
+ + "</pre>"
125
+ )
126
+ continue
127
+
128
+ if _RULE.match(line):
129
+ out.append("<hr />")
130
+ index += 1
131
+ continue
132
+
133
+ heading = _HEADING.match(line)
134
+ if heading:
135
+ level = len(heading.group(1))
136
+ out.append(f"<h{level}>{_inline(heading.group(2))}</h{level}>")
137
+ index += 1
138
+ continue
139
+
140
+ # Table: a header row followed by a divider row.
141
+ if (
142
+ "|" in line
143
+ and index + 1 < total
144
+ and _TABLE_DIVIDER.match(lines[index + 1])
145
+ ):
146
+ header = _split_row(line)
147
+ index += 2
148
+ rows = []
149
+ while index < total and "|" in lines[index] and lines[index].strip():
150
+ rows.append(_split_row(lines[index]))
151
+ index += 1
152
+ out.append(_table_html(header, rows))
153
+ continue
154
+
155
+ if _QUOTE.match(line):
156
+ body = []
157
+ while index < total and _QUOTE.match(lines[index]):
158
+ body.append(_QUOTE.match(lines[index]).group(1))
159
+ index += 1
160
+ out.append(
161
+ '<blockquote style="color:#555;">'
162
+ f"{_inline(' '.join(body))}</blockquote>"
163
+ )
164
+ continue
165
+
166
+ if _BULLET.match(line) or _NUMBERED.match(line):
167
+ numbered = bool(_NUMBERED.match(line))
168
+ tag = "ol" if numbered else "ul"
169
+ items = []
170
+ while index < total:
171
+ match = (_NUMBERED if numbered else _BULLET).match(lines[index])
172
+ if not match:
173
+ break
174
+ items.append(f"<li>{_inline(match.group(1))}</li>")
175
+ index += 1
176
+ out.append(f"<{tag}>{''.join(items)}</{tag}>")
177
+ continue
178
+
179
+ # Paragraph: consecutive non-blank lines that start nothing else.
180
+ body = []
181
+ while index < total and lines[index].strip():
182
+ candidate = lines[index]
183
+ if (
184
+ _HEADING.match(candidate)
185
+ or _BULLET.match(candidate)
186
+ or _NUMBERED.match(candidate)
187
+ or _RULE.match(candidate)
188
+ or _QUOTE.match(candidate)
189
+ or candidate.lstrip().startswith("```")
190
+ ):
191
+ break
192
+ body.append(candidate)
193
+ index += 1
194
+ if body:
195
+ out.append(f"<p>{_inline(' '.join(body))}</p>")
196
+
197
+ return "\n".join(out)
198
+
199
+
200
+ def _table_html(header: list[str], rows: list[list[str]]) -> str:
201
+ """A bordered table. Qt honours the `border` attribute, not CSS borders."""
202
+ head = "".join(
203
+ f'<th style="padding:4px 8px;background-color:#ececec;">{_inline(c)}</th>'
204
+ for c in header
205
+ )
206
+ body = []
207
+ for row in rows:
208
+ # Pad or trim to the header width; a ragged row is a typo, not a reason
209
+ # to drop the table.
210
+ cells = (row + [""] * len(header))[: len(header)]
211
+ body.append(
212
+ "<tr>"
213
+ + "".join(
214
+ f'<td style="padding:4px 8px;">{_inline(c)}</td>' for c in cells
215
+ )
216
+ + "</tr>"
217
+ )
218
+ return (
219
+ '<table border="1" cellspacing="0" cellpadding="0" '
220
+ 'style="margin:8px 0;">'
221
+ f"<tr>{head}</tr>{''.join(body)}</table>"
222
+ )
223
+
224
+
225
+ # -- the public entry point -------------------------------------------------
226
+
227
+ #: A paragraph containing nothing but a centred paragraph. See `to_html`.
228
+ _UNWRAP_NESTED_P = re.compile(r"<p>\s*(<p align=\"center\">.*?</p>)\s*</p>",
229
+ re.DOTALL)
230
+
231
+ #: Placeholder inserted where a math span was, before block parsing runs, so an
232
+ #: equation cannot be mistaken for a table pipe or a list bullet.
233
+ _TOKEN = "\x00MATH{}\x00"
234
+
235
+
236
+ def to_html(source: str, *, render_math: bool = True) -> RenderedPage:
237
+ """Convert a markdown page to the HTML subset ``QTextDocument`` renders.
238
+
239
+ ``render_math=False`` skips rasterisation entirely -- for a test that cares
240
+ about structure, or an environment with no matplotlib.
241
+ """
242
+ segments = split_segments(source)
243
+
244
+ images: dict[str, bytes] = {}
245
+ unrendered: list[str] = []
246
+ replacements: dict[str, str] = {}
247
+ text_parts: list[str] = []
248
+
249
+ for i, segment in enumerate(segments):
250
+ if not segment.is_math:
251
+ text_parts.append(segment.text)
252
+ continue
253
+
254
+ token = _TOKEN.format(i)
255
+ png = render_math_png(segment.text) if render_math else None
256
+
257
+ if png is None:
258
+ unrendered.append(segment.text)
259
+ raw = html.escape(f"${segment.text}$")
260
+ replacements[token] = (
261
+ f'<span style="color:{UNRENDERED_COLOR};">{raw}</span>'
262
+ )
263
+ else:
264
+ key = f"{MATH_URL_PREFIX}{len(images)}"
265
+ images[key] = png
266
+ tag = f'<img src="{key}" />'
267
+ # Display math gets its own centred line; inline sits in the run.
268
+ replacements[token] = (
269
+ f'<p align="center">{tag}</p>' if segment.kind == "display" else tag
270
+ )
271
+
272
+ text_parts.append(token)
273
+
274
+ html_body = _blocks_to_html("".join(text_parts).splitlines())
275
+
276
+ # Substituted after block parsing, so the `<img>` markup cannot be escaped
277
+ # by `_inline` on its way through.
278
+ for token, markup in replacements.items():
279
+ html_body = html_body.replace(html.escape(token), markup)
280
+ html_body = html_body.replace(token, markup)
281
+
282
+ # A display equation on its own line arrives here as a paragraph whose only
283
+ # content is a centred paragraph. Qt tolerates the nesting but lays it out
284
+ # with the outer paragraph's spacing *and* the inner one's, which reads as
285
+ # an accidental gap; unwrapping keeps display math spaced like every other
286
+ # block.
287
+ html_body = _UNWRAP_NESTED_P.sub(r"\1", html_body)
288
+
289
+ return RenderedPage(html=html_body, images=images, unrendered=unrendered)
@@ -0,0 +1,173 @@
1
+ r"""Rendering LaTeX math from a documentation page, without a LaTeX install.
2
+
3
+ A theory page's ``$$\Phi_m(x) = ...$$`` should not reach a reader as literal
4
+ source, backslashes and all. This is the pure half of the fix: splitting a
5
+ markdown string into text and math spans, and rasterizing the math spans to PNG
6
+ bytes via :func:`matplotlib.mathtext.math_to_image` -- which is already a
7
+ dependency wherever there are plots, and needs no live display, so the whole
8
+ pipeline down to PNG bytes is headlessly testable. Only the final step of
9
+ turning those bytes into an embeddable image needs a real document.
10
+
11
+ Three things worth knowing before touching this file.
12
+
13
+ **Splitting happens before any escaping.** Real documentation contains math
14
+ spans holding ``<`` and ``>`` -- ``$t < w$``, ``$\zeta < \theta_c$``,
15
+ ``$k > 0$``. Escaping first would hand mathtext the string ``t &lt; w``, which
16
+ it renders literally. So :func:`split_segments` runs on the raw source and only
17
+ the *text* spans are escaped afterwards; the LaTeX never is.
18
+
19
+ **``\boxed{...}`` is not supported by mathtext.** It raises ``ValueError``
20
+ wrapping a ``ParseFatalException``. :func:`strip_boxed` removes the wrapper by
21
+ counting braces rather than by regex, because the boxed content itself contains
22
+ nested braces -- ``\boxed{\;G_m = \int_0^1 e^{i\Phi_m(t)}\,dt\;}`` -- and a
23
+ naive ``\\boxed\{(.*)\}`` pattern would either stop at the first inner ``}`` or,
24
+ greedy, swallow past the intended end. The visual rectangle is not reproduced;
25
+ the equation still renders correctly and reads as its own centred line, which is
26
+ the part that matters.
27
+
28
+ **A render failure degrades, it does not crash the viewer.**
29
+ :func:`render_math_png` returns ``None`` rather than propagating the parser's
30
+ exception, so one unsupported macro in a future edit shows as visible raw source
31
+ for that one span -- diagnosable at a glance -- rather than taking down the
32
+ whole page.
33
+ """
34
+
35
+ from __future__ import annotations
36
+
37
+ import re
38
+ from dataclasses import dataclass
39
+
40
+ __all__ = [
41
+ "Segment",
42
+ "split_segments",
43
+ "strip_boxed",
44
+ "render_math_png",
45
+ ]
46
+
47
+ #: `$$...$$` first so display math is not mistaken for two inline spans.
48
+ #: A backslash-escaped `\$` is not a delimiter -- it is a dollar sign, which
49
+ #: appears in prose about cost and in any page quoting a shell prompt.
50
+ _MATH = re.compile(
51
+ r"(?<!\\)\$\$(?P<display>.+?)(?<!\\)\$\$"
52
+ r"|(?<!\\)\$(?P<inline>[^\n$]+?)(?<!\\)\$",
53
+ re.DOTALL,
54
+ )
55
+
56
+ #: Fenced blocks and inline code, which must be left entirely alone: `$PATH` in
57
+ #: a shell example is not the start of an equation.
58
+ _CODE = re.compile(r"```.*?```|``.+?``|`[^`\n]+?`", re.DOTALL)
59
+
60
+
61
+ @dataclass(frozen=True)
62
+ class Segment:
63
+ """One run of the source: prose, inline math, or display math."""
64
+
65
+ text: str
66
+ kind: str = "text" # "text" | "inline" | "display"
67
+
68
+ @property
69
+ def is_math(self) -> bool:
70
+ return self.kind in ("inline", "display")
71
+
72
+
73
+ def split_segments(source: str) -> list[Segment]:
74
+ """Split ``source`` into text and math spans, leaving code untouched.
75
+
76
+ Runs on raw markdown, before escaping. See the module docstring.
77
+ """
78
+ segments: list[Segment] = []
79
+ cursor = 0
80
+
81
+ # Walk code spans first and only look for math in the gaps between them.
82
+ for code in _CODE.finditer(source):
83
+ _split_prose(source[cursor:code.start()], segments)
84
+ segments.append(Segment(code.group(0), "text"))
85
+ cursor = code.end()
86
+ _split_prose(source[cursor:], segments)
87
+
88
+ return [s for s in segments if s.text]
89
+
90
+
91
+ def _split_prose(text: str, out: list[Segment]) -> None:
92
+ """Find math spans in a stretch known to contain no code."""
93
+ cursor = 0
94
+ for match in _MATH.finditer(text):
95
+ if match.start() > cursor:
96
+ out.append(Segment(text[cursor:match.start()], "text"))
97
+ if match.group("display") is not None:
98
+ out.append(Segment(match.group("display").strip(), "display"))
99
+ else:
100
+ out.append(Segment(match.group("inline").strip(), "inline"))
101
+ cursor = match.end()
102
+ if cursor < len(text):
103
+ out.append(Segment(text[cursor:], "text"))
104
+
105
+
106
+ def strip_boxed(latex: str) -> str:
107
+ r"""Remove ``\boxed{...}`` wrappers, counting braces rather than matching.
108
+
109
+ mathtext has no boxed macro and raises on one. The content is kept; only
110
+ the wrapper goes. Nested ``\boxed`` is handled by repeating until none is
111
+ left, which costs nothing and avoids a second surprise later.
112
+ """
113
+ marker = r"\boxed{"
114
+ while (start := latex.find(marker)) != -1:
115
+ depth = 0
116
+ for i in range(start + len(marker) - 1, len(latex)):
117
+ if latex[i] == "{":
118
+ depth += 1
119
+ elif latex[i] == "}":
120
+ depth -= 1
121
+ if depth == 0:
122
+ inner = latex[start + len(marker):i]
123
+ latex = latex[:start] + inner + latex[i + 1:]
124
+ break
125
+ else:
126
+ # Unbalanced: drop the marker and keep the rest rather than looping
127
+ # forever on malformed input.
128
+ latex = latex[:start] + latex[start + len(marker):]
129
+ return latex
130
+
131
+
132
+ def render_math_png(
133
+ latex: str,
134
+ *,
135
+ fontsize: int = 12,
136
+ dpi: int = 200,
137
+ color: str = "black",
138
+ ) -> bytes | None:
139
+ """Rasterize one math span. ``None`` if mathtext cannot parse it.
140
+
141
+ Returning ``None`` rather than raising is deliberate: an unsupported macro
142
+ should show as raw source for that one equation, not take down the page.
143
+
144
+ ``dpi`` is well above screen resolution because the result is displayed at
145
+ its natural size on a Retina display, where a 100-dpi raster looks soft.
146
+ """
147
+ import io
148
+
149
+ from matplotlib import mathtext
150
+
151
+ expression = strip_boxed(latex).strip()
152
+ if not expression:
153
+ return None
154
+
155
+ buffer = io.BytesIO()
156
+ try:
157
+ mathtext.math_to_image(
158
+ f"${expression}$",
159
+ buffer,
160
+ prop=_font_properties(fontsize),
161
+ dpi=dpi,
162
+ format="png",
163
+ color=color,
164
+ )
165
+ except Exception: # noqa: BLE001 - any parse failure degrades to raw source
166
+ return None
167
+ return buffer.getvalue()
168
+
169
+
170
+ def _font_properties(fontsize: int):
171
+ from matplotlib.font_manager import FontProperties
172
+
173
+ return FontProperties(size=fontsize)