labforge 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.
labforge/__init__.py ADDED
@@ -0,0 +1,17 @@
1
+ """
2
+ labforge: wrap plain Python functions into a small scientific desktop app,
3
+ following the theory -> simulation -> visualization -> analysis workflow.
4
+
5
+ Lab collects the registrations and launches the app; Param specifies a kwarg's
6
+ control; ScanResult is what a parameter scan hands to viz and analysis
7
+ functions; style is the optional house figure treatment; palette exposes the
8
+ active theme's colours; themes lists what open(theme=...) accepts.
9
+ """
10
+
11
+ from .figures import palette, style
12
+ from .lab import Lab
13
+ from .param import Param
14
+ from .scan import ScanResult
15
+ from .theme import names as themes
16
+
17
+ __all__ = ["Lab", "Param", "ScanResult", "style", "palette", "themes"]
labforge/controls.py ADDED
@@ -0,0 +1,278 @@
1
+ """
2
+ Param -> a Flet control row plus a read() closure for its current value.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+
7
+ import flet as ft
8
+
9
+ from .ui import FONT_MONO
10
+
11
+ # Parameter names and values are data, so every label, readout and field in a
12
+ # control row speaks the app's monospace voice.
13
+ MONO = ft.TextStyle(size=13, font_family=FONT_MONO)
14
+ MONO_LABEL = ft.TextStyle(size=12, font_family=FONT_MONO)
15
+
16
+
17
+ @dataclass
18
+ class ControlBinding:
19
+ """
20
+ One built control row and its value accessor.
21
+
22
+ Parameters
23
+ ----------
24
+ row: Control
25
+ The labelled row to place in the page tree.
26
+ read: callable
27
+ Zero-arg closure returning the current parsed value (scalar, tuple,
28
+ or list of scan values).
29
+ """
30
+
31
+ row: object
32
+ read: callable
33
+
34
+
35
+ def build_control(name, param, values, page):
36
+ """
37
+ Build the control row for one Param.
38
+
39
+ Every control commits to values as it changes, so edits survive navigation
40
+ even before the next Run; read() is also what Run, Render and Compute pull.
41
+
42
+ Parameters
43
+ ----------
44
+ name: str
45
+ The kwarg name; keys the values dict and labels the row by default.
46
+ param: Param
47
+ The normalized spec (default resolved, step filled for bounded kinds).
48
+ values: dict
49
+ The live values dict (state.worker_values or a per-tab kwargs dict);
50
+ read at build time and committed to on every change.
51
+ page: ft.Page
52
+ Needed to flush live readout updates during a slider drag.
53
+
54
+ Returns
55
+ -------
56
+ ControlBinding for the row.
57
+ """
58
+ label = param.label or name
59
+ value = values.get(name, param.default)
60
+ if param.kind == "tuple":
61
+ return tuple_control(name, label, param, value, values)
62
+ if param.bounds is None and not param.scan:
63
+ return text_control(name, label, param, value, values)
64
+ if param.bounds is None:
65
+ return scan_control(name, label, param, value, values)
66
+ if not param.scan:
67
+ return slider_control(name, label, param, value, values, page)
68
+ return toggled_control(name, label, param, value, values, page)
69
+
70
+
71
+ def slider_control(name, label, param, value, values, page):
72
+ """A bounded param: a slider with a live readout."""
73
+ lo, hi = param.bounds
74
+ if isinstance(value, list): # scan values left over from a stale spec edit
75
+ value = param.default
76
+ # Built here and returned inside the tree, so it is mounted before any drag
77
+ # fires; on_change mutates it and flushes via page.update(), never
78
+ # readout.update(), which Flet 0.86 drops.
79
+ readout = ft.Text(format_scalar(param, value), width=64, style=MONO)
80
+ slider = ft.Slider(
81
+ min=lo,
82
+ max=hi,
83
+ divisions=max(1, round((hi - lo) / param.step)),
84
+ value=value,
85
+ label="{value}",
86
+ )
87
+
88
+ def on_change(e):
89
+ readout.value = format_scalar(param, cast(param, e.control.value))
90
+ page.update()
91
+
92
+ def on_commit(e):
93
+ values[name] = cast(param, e.control.value)
94
+
95
+ slider.on_change = on_change
96
+ slider.on_change_end = on_commit # commit on release, not on every frame
97
+
98
+ def read():
99
+ return cast(param, slider.value)
100
+
101
+ return ControlBinding(row=slider_row(label, slider, readout), read=read)
102
+
103
+
104
+ def text_control(name, label, param, value, values):
105
+ """An unbounded scalar or int: a validated text field."""
106
+ field = ft.TextField(
107
+ label=label,
108
+ value=format_scalar(param, value),
109
+ width=160,
110
+ text_style=MONO,
111
+ label_style=MONO_LABEL,
112
+ )
113
+
114
+ def read():
115
+ parsed = parse_scalar(param, field.value, fallback=values.get(name, param.default))
116
+ values[name] = parsed
117
+ return parsed
118
+
119
+ field.on_change = lambda e: read()
120
+ return ControlBinding(row=field, read=read)
121
+
122
+
123
+ def scan_control(name, label, param, value, values):
124
+ """An unbounded scan param: always a comma-separated values field."""
125
+ field = ft.TextField(
126
+ label=f"{label} (comma-separated to scan)",
127
+ value=format_values(param, value),
128
+ width=260,
129
+ text_style=MONO,
130
+ label_style=MONO_LABEL,
131
+ )
132
+
133
+ def read():
134
+ parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
135
+ values[name] = parsed
136
+ return parsed
137
+
138
+ field.on_change = lambda e: read()
139
+ return ControlBinding(row=field, read=read)
140
+
141
+
142
+ def toggled_control(name, label, param, value, values, page):
143
+ """
144
+ A bounded scan param: a slider row with a scan Switch.
145
+
146
+ The switch swaps the content of a mounted Container between the slider and a
147
+ comma-list field — the same mutate-in-place pattern the pages use for their
148
+ result panes. Starting from a list value (a scan committed earlier) restores
149
+ scan mode.
150
+ """
151
+ scanning = isinstance(value, list)
152
+ slider_binding = slider_control(
153
+ name, label, param, value if not scanning else param.default, values, page
154
+ )
155
+ field = ft.TextField(
156
+ label=f"{label} values",
157
+ value=format_values(param, value),
158
+ width=220,
159
+ text_style=MONO,
160
+ label_style=MONO_LABEL,
161
+ )
162
+ holder = ft.Container(content=field if scanning else slider_binding.row, expand=True)
163
+ switch = ft.Switch(label="SCAN", value=scanning, label_position=ft.LabelPosition.RIGHT)
164
+
165
+ def read():
166
+ if switch.value:
167
+ parsed = parse_values(param, field.value, fallback=values.get(name, param.default))
168
+ else:
169
+ parsed = slider_binding.read()
170
+ values[name] = parsed
171
+ return parsed
172
+
173
+ def on_toggle(e):
174
+ if switch.value:
175
+ # Carry the slider's value across, so switching to scan starts there.
176
+ field.value = format_values(param, slider_binding.read())
177
+ holder.content = field
178
+ else:
179
+ holder.content = slider_binding.row
180
+ read()
181
+ page.update()
182
+
183
+ switch.on_change = on_toggle
184
+ field.on_change = lambda e: read()
185
+ row = ft.Row(vertical_alignment=ft.CrossAxisAlignment.CENTER, controls=[holder, switch])
186
+ return ControlBinding(row=row, read=read)
187
+
188
+
189
+ def tuple_control(name, label, param, value, values):
190
+ """A fixed-size tuple: one small text field per element."""
191
+ fields = [
192
+ ft.TextField(value=f"{element:g}", width=90, text_align=ft.TextAlign.RIGHT, text_style=MONO)
193
+ for element in tuple(value)
194
+ ]
195
+
196
+ def read():
197
+ previous = tuple(values.get(name, param.default))
198
+ parsed = tuple(
199
+ parse_scalar(param, field.value, fallback=element)
200
+ for field, element in zip(fields, previous)
201
+ )
202
+ values[name] = parsed
203
+ return parsed
204
+
205
+ for field in fields:
206
+ field.on_change = lambda e: read()
207
+ row = ft.Row(
208
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
209
+ controls=[ft.Text(label, width=120, style=MONO), *fields],
210
+ )
211
+ return ControlBinding(row=row, read=read)
212
+
213
+
214
+ def slider_row(label, slider, readout):
215
+ """A labelled slider with its live readout to the right."""
216
+ return ft.Row(
217
+ vertical_alignment=ft.CrossAxisAlignment.CENTER,
218
+ controls=[
219
+ ft.Text(label, width=120, style=MONO),
220
+ ft.Container(slider, expand=True),
221
+ readout,
222
+ ],
223
+ )
224
+
225
+
226
+ def cast(param, value):
227
+ """
228
+ Cast a slider value or one token of user text to the param's kind, rounding
229
+ to the nearest int; raises on unparseable text.
230
+
231
+ A tuple param's elements are floats, so they take the non-int branch.
232
+ """
233
+ return int(round(float(value))) if param.kind == "int" else float(value)
234
+
235
+
236
+ def format_scalar(param, value):
237
+ """Display form of one value: thousands-grouped ints, compact floats."""
238
+ return f"{int(value):,}" if param.kind == "int" else f"{float(value):g}"
239
+
240
+
241
+ def format_values(param, value):
242
+ """
243
+ Display form of a committed value or scan list for a comma field.
244
+
245
+ Ints are ungrouped here, unlike format_scalar: a thousands separator inside
246
+ a comma-separated list would parse back as two values.
247
+ """
248
+ entries = value if isinstance(value, list) else [value]
249
+ return ", ".join(f"{int(v):d}" if param.kind == "int" else f"{float(v):g}" for v in entries)
250
+
251
+
252
+ def parse_scalar(param, text, fallback):
253
+ """Parse one value of the param's kind, keeping fallback on bad input."""
254
+ try:
255
+ return cast(param, text)
256
+ except (TypeError, ValueError):
257
+ return fallback
258
+
259
+
260
+ def parse_values(param, text, fallback):
261
+ """
262
+ Parse a comma-separated scan entry.
263
+
264
+ A single valid value returns a scalar (a one-point scan is just a run);
265
+ several return a list; no valid values return the fallback.
266
+ """
267
+ parsed = []
268
+ for token in str(text).split(","):
269
+ token = token.strip()
270
+ if not token:
271
+ continue
272
+ try:
273
+ parsed.append(cast(param, token))
274
+ except ValueError:
275
+ return fallback
276
+ if not parsed:
277
+ return fallback
278
+ return parsed[0] if len(parsed) == 1 else parsed
labforge/figures.py ADDED
@@ -0,0 +1,92 @@
1
+ """
2
+ Serialize the author's matplotlib figures to base64 PNGs, plus an optional
3
+ house style.
4
+ """
5
+
6
+ import base64
7
+ import io
8
+
9
+ import matplotlib
10
+
11
+ matplotlib.use("Agg") # render to a buffer; no GUI backend on the UI thread
12
+ import matplotlib.pyplot as plt
13
+ from matplotlib.figure import Figure
14
+
15
+ from . import theme
16
+
17
+ RENDER_DPI = 150
18
+
19
+
20
+ def palette():
21
+ """
22
+ The active Theme, for an author colouring their own figure; its data /
23
+ model / highlight / ink / grid fields are the plot palette. Read at call
24
+ time rather than bound at import, so a figure drawn with palette().data
25
+ follows whichever theme open(theme=...) selected.
26
+ """
27
+ return theme.active()
28
+
29
+
30
+ def is_figure(value):
31
+ """True for a matplotlib Figure or a (fig, ...) tuple, the two viz returns."""
32
+ if isinstance(value, tuple):
33
+ return bool(value) and isinstance(value[0], Figure)
34
+ return isinstance(value, Figure)
35
+
36
+
37
+ def unpack_figure(value):
38
+ """Extract the Figure from a viz return: a bare Figure or a (fig, ...) tuple."""
39
+ if not is_figure(value):
40
+ raise TypeError(
41
+ f"Expected a matplotlib Figure or a (fig, ax) tuple, got {type(value).__name__}."
42
+ )
43
+ return value[0] if isinstance(value, tuple) else value
44
+
45
+
46
+ def figure_to_base64(fig):
47
+ """
48
+ Serialize a matplotlib figure to a base64 PNG and close it, so repeated
49
+ renders never leak pyplot state.
50
+ """
51
+ buffer = io.BytesIO()
52
+ # transparent=True clears the figure and axes patches so the app surface
53
+ # shows through; the pale ink still reads on the dark theme.
54
+ fig.savefig(buffer, format="png", dpi=RENDER_DPI, bbox_inches="tight", transparent=True)
55
+ plt.close(fig)
56
+ return base64.b64encode(buffer.getvalue()).decode("ascii")
57
+
58
+
59
+ def style(fig, ax):
60
+ """
61
+ Apply the optional house figure treatment in place.
62
+
63
+ Open top/right spines, a light dotted grid behind the data, muted ink and
64
+ compact type, and a frameless legend — so an opted-in figure reads as part
65
+ of the app rather than a stock matplotlib window.
66
+
67
+ Parameters
68
+ ----------
69
+ fig: Figure
70
+ The figure (accepted for signature symmetry with viz returns).
71
+ ax: Axes
72
+ The axes to restyle, already populated by the caller.
73
+ """
74
+ colors = theme.active()
75
+ for side in ("top", "right"):
76
+ ax.spines[side].set_visible(False)
77
+ for side in ("left", "bottom"):
78
+ ax.spines[side].set_color(colors.grid)
79
+ ax.grid(True, axis="y", color=colors.grid, alpha=0.35, linewidth=0.6, linestyle=":")
80
+ ax.set_axisbelow(True)
81
+ ax.tick_params(colors=colors.ink, labelsize=9)
82
+ ax.title.set_color(colors.ink)
83
+ ax.title.set_fontsize(11)
84
+ for label in (ax.xaxis.label, ax.yaxis.label):
85
+ label.set_color(colors.ink)
86
+ label.set_fontsize(10)
87
+ legend = ax.get_legend()
88
+ if legend is not None:
89
+ legend.set_frame_on(False)
90
+ for text in legend.get_texts():
91
+ text.set_color(colors.ink)
92
+ text.set_fontsize(9)
labforge/lab.py ADDED
@@ -0,0 +1,165 @@
1
+ """
2
+ Lab: register a worker, visualizations, analyses and theory, then open the app.
3
+ """
4
+
5
+ import os
6
+
7
+ from .param import normalize_spec
8
+ from .registry import AnalysisSpec, VizSpec, WorkerSpec
9
+ from .theme import DEFAULT as DEFAULT_THEME
10
+
11
+
12
+ class Lab:
13
+ """
14
+ A four-page scientific desktop app assembled from plain functions.
15
+
16
+ Every registration is validated on the spot — spec keys checked against the
17
+ function signature, defaults resolved — so a malformed app fails at
18
+ assembly rather than mid-navigation.
19
+
20
+ Parameters
21
+ ----------
22
+ title: str
23
+ App name shown in the top bar.
24
+ page_title: str
25
+ Window title; defaults to title.
26
+ icon: str
27
+ A Flet icon constant for the top-bar app mark; a volume mark by default.
28
+ """
29
+
30
+ def __init__(self, title, page_title=None, icon=""):
31
+ self.title = title
32
+ self.page_title = page_title or title
33
+ self.icon = icon
34
+ self.theory_source = None
35
+ self.worker = None
36
+ self.vizzes = []
37
+ self.analyses = []
38
+
39
+ def set_theory(self, source):
40
+ """
41
+ Register the Theory page content.
42
+
43
+ Parameters
44
+ ----------
45
+ source: str or Path
46
+ Path to a markdown file, or raw markdown. Displayed equations in
47
+ $$...$$ blocks render as LaTeX images.
48
+ """
49
+ source = os.fspath(source)
50
+ if os.path.isfile(source):
51
+ with open(source, encoding="utf-8") as handle:
52
+ source = handle.read()
53
+ self.theory_source = source
54
+
55
+ def add_worker(self, func, spec=None):
56
+ """
57
+ Register the data-producing function.
58
+
59
+ Parameters
60
+ ----------
61
+ func: callable
62
+ Called with one scalar per kwarg; a scan calls it once per grid
63
+ point. Its return is the data handed to viz and analysis functions.
64
+ spec: dict
65
+ {kwarg: Param or shorthand str}; unspecced kwargs are inferred from
66
+ their signature defaults.
67
+ """
68
+ # One worker per Lab: a second would need a picker and a workspace stash
69
+ # to switch between their results, which is a larger design than this.
70
+ if self.worker is not None:
71
+ raise ValueError(
72
+ "labforge supports one worker per Lab; already registered "
73
+ f"{self.worker.func.__name__}."
74
+ )
75
+ self.worker = WorkerSpec(func=func, params=normalize_spec(func, spec))
76
+
77
+ def add_viz(self, func, title, desc="", spec=None):
78
+ """
79
+ Register a visualization tab.
80
+
81
+ Parameters
82
+ ----------
83
+ func: callable
84
+ func(data, **kwargs) returning a matplotlib Figure or (fig, ax).
85
+ data is the worker's bare return, or a ScanResult after a scan.
86
+ title: str
87
+ Tab label.
88
+ desc: str
89
+ Short markdown note shown above the controls.
90
+ spec: dict
91
+ {kwarg: Param or shorthand str} for the kwargs after data.
92
+ """
93
+ params = normalize_spec(func, spec, skip_first=True, allow_scan=False)
94
+ self.vizzes.append(VizSpec(func=func, title=title, desc=desc, params=params))
95
+
96
+ def add_analysis(self, func, title, desc="", spec=None):
97
+ """
98
+ Register an analysis tab.
99
+
100
+ Parameters
101
+ ----------
102
+ func: callable
103
+ func(data, **kwargs) returning a dict (two-column table), a list of
104
+ dicts (table), a string (markdown), or a matplotlib figure.
105
+ title: str
106
+ Tab label.
107
+ desc: str
108
+ Short markdown note shown above the controls.
109
+ spec: dict
110
+ {kwarg: Param or shorthand str} for the kwargs after data.
111
+ """
112
+ params = normalize_spec(func, spec, skip_first=True, allow_scan=False)
113
+ self.analyses.append(AnalysisSpec(func=func, title=title, desc=desc, params=params))
114
+
115
+ def build_main(self, layout="pages", theme=DEFAULT_THEME):
116
+ """
117
+ Return the Flet main(page) entry point; also the headless test seam.
118
+
119
+ Parameters
120
+ ----------
121
+ layout: str
122
+ "pages" or "scroll", as in open().
123
+ theme: str
124
+ A key of labforge.themes(), as in open().
125
+
126
+ Returns
127
+ -------
128
+ A main(page) callable.
129
+ """
130
+ if self.worker is None:
131
+ raise ValueError("Register a worker with add_worker before opening the lab.")
132
+ # Imported here, not at module scope, so registration and validation
133
+ # never pay for Flet's UI layer.
134
+ from .shell import build_main
135
+
136
+ return build_main(self, layout=layout, theme=theme)
137
+
138
+ def open(self, view="app", layout="pages", port=0, theme=DEFAULT_THEME):
139
+ """
140
+ Launch the app.
141
+
142
+ Parameters
143
+ ----------
144
+ view: str
145
+ "app" opens a native desktop window; "browser" serves the app and
146
+ opens it in the default web browser — handy for iterating, since a
147
+ served app is reachable from any browser on a stable URL.
148
+ layout: str
149
+ "pages" navigates the four pages with a rail; "scroll" shows the
150
+ whole lab as one continuous scrolling page.
151
+ port: int
152
+ Port for the "browser" view; 0 lets the OS pick a free one. With a
153
+ fixed port the app stays at http://localhost:<port> across relaunches.
154
+ theme: str
155
+ The colour scheme: any key of labforge.themes(), which lists each
156
+ with a one-line note. Sets the chrome, the plot palette and the
157
+ equation ink together, so a figure drawn with labforge.palette()
158
+ follows it.
159
+ """
160
+ import flet as ft
161
+
162
+ views = {"app": ft.AppView.FLET_APP, "browser": ft.AppView.WEB_BROWSER}
163
+ if view not in views:
164
+ raise ValueError(f"view must be one of {sorted(views)}, got {view!r}.")
165
+ ft.run(self.build_main(layout=layout, theme=theme), view=views[view], port=port)
labforge/mathtext.py ADDED
@@ -0,0 +1,140 @@
1
+ """
2
+ Render LaTeX math to a base64 PNG: system LaTeX when present, matplotlib's
3
+ mathtext otherwise.
4
+
5
+ Flet has no equation widget, and an image renders identically on every platform.
6
+ """
7
+
8
+ import base64
9
+ import io
10
+ import os
11
+ import shutil
12
+
13
+ import matplotlib
14
+
15
+ matplotlib.use("Agg") # headless: render to a buffer, never open a window
16
+ import matplotlib.pyplot as plt
17
+
18
+ from . import theme
19
+
20
+ # Rendered at 2x, displayed at 1x, so equations stay crisp on retina screens.
21
+ RENDER_DPI = 220
22
+ DISPLAY_SCALE = 0.5
23
+
24
+ # amssymb supplies \mathbb; amsmath the spacing/alignment macros the prose uses;
25
+ # eulervm sets the math font to Euler (Zapf), the app's mathematical voice.
26
+ LATEX_PREAMBLE = r"\usepackage{amsmath}\usepackage{amssymb}\usepackage[euler-digits]{eulervm}"
27
+
28
+ # MacTeX installs its binaries here, but a GUI-launched process may not inherit it
29
+ # on PATH — add it so both the availability probe and matplotlib's subprocess find
30
+ # latex/dvipng.
31
+ TEX_BIN = "/Library/TeX/texbin"
32
+ if os.path.isdir(TEX_BIN) and TEX_BIN not in os.environ.get("PATH", "").split(os.pathsep):
33
+ os.environ["PATH"] = TEX_BIN + os.pathsep + os.environ.get("PATH", "")
34
+
35
+
36
+ def latex_available():
37
+ """True when a system LaTeX toolchain (latex + dvipng) is on PATH."""
38
+ return bool(shutil.which("latex") and shutil.which("dvipng"))
39
+
40
+
41
+ # Probed once at import. A render failure later (missing package, unsupported
42
+ # glyph) latches this off for the session, since that failure mode is systemic and
43
+ # retrying it pays a slow subprocess timeout on every equation.
44
+ USE_LATEX = latex_available()
45
+
46
+
47
+ def render_png(latex, fontsize, usetex):
48
+ """
49
+ Render a LaTeX math string to a base64 PNG and its display size.
50
+
51
+ Parameters
52
+ ----------
53
+ latex: str
54
+ Math content without surrounding dollar signs, e.g. r"f(x)=\\lambda".
55
+ fontsize: int
56
+ Point size of the rendered equation.
57
+ usetex: bool
58
+ Compile with the system LaTeX toolchain when True, else use mathtext.
59
+ The usetex path accepts the full LaTeX command set (\\mathbb, \\mathcal,
60
+ alignment environments) that mathtext only partly supports, and sets the
61
+ math font to Euler; mathtext's cost is a narrower vocabulary and
62
+ Computer-Modern glyphs.
63
+
64
+ Returns
65
+ -------
66
+ Tuple of (base64 src, width in inches, height in inches).
67
+ """
68
+ # rc_context keeps usetex local to this figure, so it never touches an
69
+ # author's own plots.
70
+ rc = {"text.usetex": usetex}
71
+ if usetex:
72
+ rc["text.latex.preamble"] = LATEX_PREAMBLE
73
+ with plt.rc_context(rc):
74
+ fig = plt.figure()
75
+ # Displayed equations get display style — full-size fractions, sum
76
+ # limits above and below. Only usetex knows \displaystyle; the mathtext
77
+ # fallback keeps text style, one more of its accepted costs.
78
+ body = f"\\displaystyle {latex}" if usetex else latex
79
+ # Ink follows the active theme, so equations sit on the app surface the
80
+ # transparent PNG is composited onto rather than fighting it.
81
+ text = fig.text(0, 0, f"${body}$", fontsize=fontsize, color=theme.active().ink)
82
+
83
+ # Measure the drawn text so we can crop the canvas tightly around it.
84
+ fig.canvas.draw()
85
+ bbox = text.get_window_extent()
86
+ pad = fontsize * 0.35
87
+ width_in = (bbox.width + 2 * pad) / fig.dpi
88
+ height_in = (bbox.height + 2 * pad) / fig.dpi
89
+ fig.set_size_inches(width_in, height_in)
90
+ text.set_position((pad / bbox.width if bbox.width else 0.1, 0.5))
91
+ text.set_verticalalignment("center")
92
+
93
+ buffer = io.BytesIO()
94
+ fig.savefig(
95
+ buffer,
96
+ format="png",
97
+ dpi=RENDER_DPI,
98
+ transparent=True,
99
+ bbox_inches="tight",
100
+ pad_inches=0.08,
101
+ )
102
+ plt.close(fig)
103
+
104
+ src = base64.b64encode(buffer.getvalue()).decode("ascii")
105
+ return src, width_in, height_in
106
+
107
+
108
+ def equation_image(latex, fontsize=18):
109
+ """
110
+ Render a LaTeX math string to a base64 PNG suitable for ft.Image.
111
+
112
+ Prefers the system LaTeX toolchain and falls back to mathtext when it is
113
+ absent or a render fails, so a page never blanks on an equation.
114
+
115
+ Parameters
116
+ ----------
117
+ latex: str
118
+ Math content without surrounding dollar signs, e.g. r"f(x)=\\lambda".
119
+ fontsize: int
120
+ Point size of the rendered equation.
121
+
122
+ Returns
123
+ -------
124
+ dict with base64 src plus display width and height in logical pixels.
125
+ """
126
+ global USE_LATEX
127
+ if USE_LATEX:
128
+ try:
129
+ src, width_in, height_in = render_png(latex, fontsize, usetex=True)
130
+ except Exception:
131
+ USE_LATEX = False
132
+ src, width_in, height_in = render_png(latex, fontsize, usetex=False)
133
+ else:
134
+ src, width_in, height_in = render_png(latex, fontsize, usetex=False)
135
+
136
+ return {
137
+ "src": src,
138
+ "width": width_in * RENDER_DPI * DISPLAY_SCALE,
139
+ "height": height_in * RENDER_DPI * DISPLAY_SCALE,
140
+ }
@@ -0,0 +1,6 @@
1
+ """
2
+ The stock page builders the shell navigates between.
3
+
4
+ Each exposes build(state, page) and is re-invoked on every visit
5
+ (rebuild-on-navigate), so a fresh build always reflects the latest LabState.
6
+ """