From e90eead2afbdab6c49ead5a5bcf91dc5124bd689 Mon Sep 17 00:00:00 2001 From: Lars Bogner Date: Mon, 24 Aug 2026 14:22:50 +0200 Subject: [PATCH] Escape LaTeX-special characters in plot titles/xlabels (gitea #81) shower_containment_depth_90/95's title contains a literal "%" (e.g. "...(90% of deposited energy)"), which usetex reads as a comment marker and aborts LaTeX compilation. Since render_all processes reduced JSON files in sorted filename order, this killed every plot id sorting after these two in the same run. Escape title/xlabel once, centrally, in render()'s dispatch (the one place every renderer kind draws them from before handing off to plotstyle/matplotlib) rather than at each catalog.py call site, so any future catalog title with a %, &, #, etc. is covered automatically. _plot_metadata keeps using the unescaped Reduced for the gallery YAML, since that's not LaTeX. Co-Authored-By: Claude Sonnet 5 --- giant/analysis/render.py | 32 ++++++++++++++++++++++++++++++-- tests/test_render.py | 27 +++++++++++++++++++++++++++ 2 files changed, 57 insertions(+), 2 deletions(-) diff --git a/giant/analysis/render.py b/giant/analysis/render.py index afb1ffd..d4ae5c4 100644 --- a/giant/analysis/render.py +++ b/giant/analysis/render.py @@ -21,6 +21,7 @@ cycle. from __future__ import annotations +import dataclasses import subprocess from pathlib import Path @@ -32,6 +33,27 @@ from giant.analysis.reduced import Reduced _REFERENCE_LABEL = "reference (Geant4)" +_TEX_ESCAPE_MAP = { + "\\": r"\textbackslash{}", + "%": r"\%", + "&": r"\&", + "#": r"\#", + "$": r"\$", + "_": r"\_", + "{": r"\{", + "}": r"\}", +} + + +def _tex_escape(text: str) -> str: + """Escape characters LaTeX treats specially in catalog-authored title/xlabel + text (e.g. a literal ``%`` in a "90% of deposited energy" title, which + ``usetex`` otherwise reads as a comment marker and aborts the whole figure — + see gitea #81). A single pass over the *original* characters, so the + backslashes an escape itself introduces (e.g. ``\textbackslash{}``) are + never re-escaped.""" + return "".join(_TEX_ESCAPE_MAP.get(c, c) for c in text) + def _ref_color() -> str: return ps.colors.INK["primary"] @@ -421,8 +443,14 @@ _RENDERERS = { def render(r: Reduced, run_meta: dict | None = None): - """Build the matplotlib figure for one reduced artifact (dispatch on kind).""" - return _RENDERERS[r.kind](r, _figure_params(run_meta or {})) + """Build the matplotlib figure for one reduced artifact (dispatch on kind). + + ``title``/``xlabel`` are LaTeX-escaped here, at the one point every kind's + renderer draws them from — ``_plot_metadata`` deliberately keeps using the + unescaped ``r`` for the gallery YAML, which isn't LaTeX. + """ + escaped = dataclasses.replace(r, title=_tex_escape(r.title), xlabel=_tex_escape(r.xlabel)) + return _RENDERERS[r.kind](escaped, _figure_params(run_meta or {})) def _plot_metadata(r: Reduced, run_meta: dict) -> dict: diff --git a/tests/test_render.py b/tests/test_render.py index f55be37..da580aa 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -461,3 +461,30 @@ def test_plot_metadata_omits_parameters_when_run_meta_empty(): meta = render_mod._plot_metadata(r, {}) assert "parameters" not in meta assert "note" not in meta + + +def test_tex_escape_handles_percent_and_other_special_chars(): + assert render_mod._tex_escape("90% of deposited energy") == r"90\% of deposited energy" + assert render_mod._tex_escape(r"a_b & c#d $e {f} \bar") == r"a\_b \& c\#d \$e \{f\} \textbackslash{}bar" + + +def test_render_survives_title_and_xlabel_with_literal_percent(tmp_path: Path): + # Regression test for gitea #81: a literal "%" in a catalog title (e.g. + # "Shower containment depth (90% of deposited energy)") crashed the whole + # LaTeX render, since usetex treats an unescaped "%" as a comment marker. + reduced = [ + Reduced( + "shower_containment_depth_90", + "shower", + "single_hist", + "Shower containment depth (90% of deposited energy)", + "depth containing 90% of deposited energy [mm]", + {"edges": [0, 1, 2], "series": {"flow": [5, 1]}}, + ), + ] + try: + pdfs = _try_render(reduced, tmp_path) + except RuntimeError as e: # LaTeX missing at render time + pytest.skip(f"LaTeX rendering unavailable: {e}") + assert len(pdfs) == 1 + assert pdfs[0].exists()