Skip to content
Open
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
4ba5c0d
render math in job tab info
RobBuchananCompPhys Sep 25, 2025
11278bd
make fontsize slightly smaller
RobBuchananCompPhys Sep 26, 2025
cc7d891
use dict to hold boolean is expression
RobBuchananCompPhys Sep 26, 2025
50b4b84
ruff - sort import block
RobBuchananCompPhys Sep 26, 2025
de8a2b0
ruff - missing import
RobBuchananCompPhys Sep 26, 2025
ffebde2
ruff - dict as literal
RobBuchananCompPhys Sep 26, 2025
5bd7ea1
ruff - iterate without calling .keys()
RobBuchananCompPhys Sep 26, 2025
f8dbd42
ruff - use raw string instead of invalid esscape
RobBuchananCompPhys Sep 26, 2025
af726c4
ruff - var name
RobBuchananCompPhys Sep 26, 2025
3a50d59
ruff - combine if and and instead of nested if
RobBuchananCompPhys Sep 26, 2025
305ecf1
ruff - fix imports
RobBuchananCompPhys Sep 26, 2025
97ec9f3
type hint
RobBuchananCompPhys Sep 26, 2025
38f72e1
use tupe instead of dict to hold substrings in case of overwrite
RobBuchananCompPhys Sep 26, 2025
0fee823
refactor to catch block math as well as inline
RobBuchananCompPhys Sep 28, 2025
2275bd1
add break
RobBuchananCompPhys Sep 28, 2025
b87fd96
slightly bigger plotsize for expressions, refactor html
RobBuchananCompPhys Sep 28, 2025
b68bb08
ruff
RobBuchananCompPhys Sep 28, 2025
002cd71
dark mode font
RobBuchananCompPhys Sep 29, 2025
28e6123
method name builtin clash
RobBuchananCompPhys Oct 20, 2025
5ec4ce0
use regex search
RobBuchananCompPhys Oct 20, 2025
ad8c4a9
maximal use of regex
RobBuchananCompPhys Oct 30, 2025
af0efcf
ruff check
RobBuchananCompPhys Oct 30, 2025
1944362
doc strings
RobBuchananCompPhys Nov 3, 2025
7ad61cd
tidy ii
RobBuchananCompPhys Nov 3, 2025
db84f29
tidy iii - simplyify and tidy things up
RobBuchananCompPhys Nov 3, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions MDANSE_GUI/Src/MDANSE_GUI/Tabs/JobTab.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
from MDANSE_GUI.Tabs.Models.JobTree import JobTree
from MDANSE_GUI.Tabs.Views.ActionsTree import ActionsTree
from MDANSE_GUI.Tabs.Visualisers.Action import Action
from MDANSE_GUI.Tabs.Visualisers.TextInfo import TextInfo
from MDANSE_GUI.Tabs.Visualisers.TextInfo import MathInfo

job_tab_label = """This is the list of <b>analysis tasks</b>
you can run using MDANSE.
Expand Down Expand Up @@ -182,7 +182,7 @@ def standard_instance(cls):
layout=partial(
MultiPanel,
left_panels=[
TextInfo(
MathInfo(
header="MDANSE Analysis",
footer="Look up our Read The Docs page:"
+ "https://mdanse.readthedocs.io/en/protos/",
Expand Down Expand Up @@ -220,7 +220,7 @@ def gui_instance(
layout=partial(
MultiPanel,
left_panels=[
TextInfo(
MathInfo(
header="MDANSE Analysis",
footer="Look up our "
+ '<a href="https://mdanse.readthedocs.io/en/protos/">Read The Docs</a>'
Expand Down Expand Up @@ -253,7 +253,7 @@ def gui_instance(
layout=partial(
MultiPanel,
left_panels=[
TextInfo(
MathInfo(
header="MDANSE Analysis",
footer="Look up our "
+ '<a href="https://mdanse.readthedocs.io/en/protos/">Read The Docs</a>'
Expand Down
143 changes: 143 additions & 0 deletions MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/MathRenderer.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
from __future__ import annotations

import base64
import io
import re

from matplotlib import pyplot as plt


class MathRenderer:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Really, this would be an ideal use-case of 3.14's t-strings

# Cache mapping the raw LaTex expression to its rendered image form
cache = {}

# Ignore the following expression
ignores = {r"\mathbf{q}": "q"}

def __init__(self, text: str) -> None:
self.raw_text = text

@staticmethod
def replace_ignored(text: str) -> str:
return MathRenderer.ignores[text]

@staticmethod
def ignore(text: str) -> bool:
return text in MathRenderer.ignores

@staticmethod
def containsMultiLineBlockExpression(text: str) -> bool:
return text in {".. math::", r"<br />.. math::<br />"}

@staticmethod
def containsBlockExpression(text: str) -> bool:
return text.startswith(".. math:")

@staticmethod
def containsInlineExpressions(text: str) -> bool:
pattern = r"(:math:`.*?`)"
substrings = re.split(pattern, text)
return len(substrings) > 1
Comment thread
RobBuchananCompPhys marked this conversation as resolved.
Outdated

@staticmethod
def processBlockExpression(text: str) -> list[tuple[str, bool]]:
return [(text[len(".. math:: ") :], True)]

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As above, we can use pattern matching:

Suggested change
@staticmethod
def processBlockExpression(text: str) -> list[tuple[str, bool]]:
return [(text[len(".. math:: ") :], True)]
def processBlockExpression(text: str) -> list[tuple[str, bool]]:
pattern = ".. math::\s*(.+)"
for match in re.finditer(pattern, text):
text.replace(match[0], self.render(match[1]))


@staticmethod
def processMultiLineBlockExpression(
strings: list[str], n: int
) -> tuple[list[tuple[str, bool]], int]:
substrings = strings[n + 1 :]
group = []
for s in substrings:
if (not s) or (s == r"<br /><br />"):
break
group.append(s)

group = [s.replace(r"<br />", "") for s in group]
total = "".join(group)
if total.startswith(r"<br />") and total.endswith(r"<br />"):
result = total.split(r"<br />")[1]
else:
result = total.split(r"<br /><br />")[0]

return [(result, True)], n + len(group) + 1

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Again, you can use pattern matching here.

Suggested change
@staticmethod
def processMultiLineBlockExpression(
strings: list[str], n: int
) -> tuple[list[tuple[str, bool]], int]:
substrings = strings[n + 1 :]
group = []
for s in substrings:
if (not s) or (s == r"<br /><br />"):
break
group.append(s)
group = [s.replace(r"<br />", "") for s in group]
total = "".join(group)
if total.startswith(r"<br />") and total.endswith(r"<br />"):
result = total.split(r"<br />")[1]
else:
result = total.split(r"<br /><br />")[0]
return [(result, True)], n + len(group) + 1
def processMultiLineBlockExpression(
self, strings: str
) -> None:
pattern = r".. math::\s*\n(?:\s*\n)?(\s+)(.+)\n(\1.+\n)*" # <- Should be class level variable
for match in re.finditer(pattern, text):
if match[3] is not None:
body = "".join(more_itertools.prepend(match[2], match[3].splitlines()))
else:
body = match[2]
text.replace(match[0], self.render(body))


@staticmethod
def processInlineExpressions(text: str) -> list[tuple[str, bool]]:
pattern = r"(:math:`.*?`)"
substrings = re.split(pattern, text)
scanned = []
for s in substrings:
if s.startswith(":math:"):
expr = s[len(":math:`") : -1]
scanned.append((expr, True))
else:
scanned.append((s, False))

return scanned

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a somewhat strange way to do this. The usual way would be to use Regular expression groups.

Suggested change
def processInlineExpressions(text: str) -> list[tuple[str, bool]]:
pattern = r"(:math:`.*?`)"
substrings = re.split(pattern, text)
scanned = []
for s in substrings:
if s.startswith(":math:"):
expr = s[len(":math:`") : -1]
scanned.append((expr, True))
else:
scanned.append((s, False))
return scanned
def processInlineExpressions(text: str) -> list[tuple[str, bool]]:
pattern = r"(:math:`(.*?)`)" # <-- Should really be class-level constant
last = 0
for match in re.finditer(pattern, text):
scanned.append((text[last:match.start], False))
scanned.append((match.group(1), True))
last = match.end
scanned.append((text[last:], False))
return scanned

Although I'm still not sure whether you really want to capture the non-converting bits or whether you just want to substitute the matches in-place for their image equivalents, which simplifies the logic massively.

Suggested change
def processInlineExpressions(text: str) -> list[tuple[str, bool]]:
pattern = r"(:math:`.*?`)"
substrings = re.split(pattern, text)
scanned = []
for s in substrings:
if s.startswith(":math:"):
expr = s[len(":math:`") : -1]
scanned.append((expr, True))
else:
scanned.append((s, False))
return scanned
def processInlineExpressions(self, text: str) -> None:
pattern = r"(:math:`(.*?)`)" # <-- Should really be class-level constant
for match in re.finditer(pattern, text):
text.replace(match[0], self.render(match[1]))


def scan(self) -> list[tuple[str, bool]]:
# Use regex matching to find expressions
pattern = r"(<br />.*?<br />)"
substrings = re.split(pattern, self.raw_text)

scanned = []
for index, s in enumerate(substrings):
if s:
if self.containsMultiLineBlockExpression(s):
# Html is a multiline block expression
group, end = self.processMultiLineBlockExpression(substrings, index)
scanned.extend(group)
substrings[index:end] = [""] * (end - index)
elif self.containsBlockExpression(s):
# Html substring contains a block expression
group = self.processBlockExpression(s)
scanned.extend(group)
elif self.containsInlineExpressions(s):
# Html substring contains inline math expressions
group = self.processInlineExpressions(s)
scanned.extend(group)
elif ":Example:" in s:
# We have reached the end of the section containing math expressions
rest = "".join(substrings[index:])
scanned.append((rest, False))
break
else:
# Html substring contains plain text only
scanned.append((s, False))
return scanned

@staticmethod
def mask(text: str) -> str:
return f"${text}$"

@staticmethod
def render(expression: str) -> None:
# Create a figure containing the rendered LaTex expression
fig, ax = plt.subplots(figsize=(0.01, 0.01))
ax.axis("off")
fig.text(0, 0, MathRenderer.mask(expression), fontsize=7)

# Save the image as bytes
buffer = io.BytesIO()
plt.savefig(buffer, format="png", bbox_inches="tight", pad_inches=0.1, dpi=150)
plt.close(fig)
buffer.seek(0)
image = base64.b64encode(buffer.read()).decode("utf-8")

# Cache rendered expression
MathRenderer.set_cache(expression, image)

@classmethod
def set_cache(cls, key, value) -> None:
cls.cache.update({key: value})

@classmethod
def cached(cls, key) -> bool:
result = key in cls.cache
return result

@classmethod
def from_cache(cls, key) -> str:
return cls.cache[key]
49 changes: 49 additions & 0 deletions MDANSE_GUI/Src/MDANSE_GUI/Tabs/Visualisers/TextInfo.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
from qtpy.QtCore import Signal, Slot
from qtpy.QtWidgets import QTextBrowser

from .MathRenderer import MathRenderer


class TextInfo(QTextBrowser):
error = Signal(str)
Expand Down Expand Up @@ -47,3 +49,50 @@ def filter(self, some_text: str, line_break="<br />"):
if self._footer:
new_text += line_break + self._footer
return new_text


class MathInfo(TextInfo):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

@staticmethod
def scan(text: str) -> list[tuple[str, bool]]:
# Instantiate renderer object
renderer = MathRenderer(text)

# Scan text for existence of raw LaTex expressions
scanned = renderer.scan()

# Iterate over scanned text, rendering LaTex substrings if image not already cached
for token, is_expression in scanned:
if is_expression and not MathRenderer.cached(token):
renderer.render(token)

return scanned

def filter(self, some_text: str, line_break="<br />"):
Comment thread
RobBuchananCompPhys marked this conversation as resolved.
Outdated
filtered = super().filter(some_text, line_break)
scanned = self.scan(filtered)

html_substrings = []
for token, is_expression in scanned:
if is_expression:
image = MathRenderer.from_cache(token)
if len(token) < 10:

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bear in mind that latex tokens may not correspond to large images:

\underbrace{\mathbf{x}} = \mathbf{3}

$\underbrace{\mathbf{x}} = \mathbf{3}$

# This is a small expression, inline rendered expression
html_substrings.append(
f'<span style="vertical-align:middle;"><img src="data:image/png;base64,{image}" style="height:1em; display:inline;"></span>'
)
else:
# This is a large expression, it gets its own line
html_substrings.append(
f'<div style="text-align:left; margin:2px 0; padding:0;"><img src="data:image/png;base64,{image}" style="vertical-align:middle;"></div>'
)
else:
# Format plain text
text = token.replace("\n", "<br>")
html_substrings.append(
f'<span style="margin:0; padding:0;">{text}</span>'
)

return "".join(html_substrings)
Loading