diff --git a/Examples/Markers/plot_fonts_and_labels.py b/Examples/Markers/plot_fonts_and_labels.py new file mode 100644 index 000000000..e10018e5d --- /dev/null +++ b/Examples/Markers/plot_fonts_and_labels.py @@ -0,0 +1,142 @@ +""" +Fonts and Labels +================ + +All text in anyplotlib — titles, axis labels, the colorbar label and +``texts`` markers — is drawn straight onto the canvas. Sizes are CSS +pixels, and strings may use a small TeX subset inside ``$...$``. + +This example walks through the options for text markers added with +:meth:`~anyplotlib.plot2d.Plot2D.add_texts`: size, weight, outline, +rotation, anchor alignment and math, plus the panel's own labels. +""" +import numpy as np +import anyplotlib as apl + +rng = np.random.default_rng(11) +# A busy, non-square image: 180 rows x 260 columns. +data = rng.standard_normal((180, 260)).cumsum(0).cumsum(1) +data = (data - data.min()) / np.ptp(data) +# Calibrated axes so the panel shows ticks and axis labels. Marker offsets +# below stay in image pixels (column, row). +x_nm = np.arange(260) * 0.05 +y_nm = np.arange(180) * 0.05 + + +def panel(title): + fig, ax = apl.subplots(1, 1, figsize=(560, 420)) + plot = ax.imshow(data, axes=[x_nm, y_nm], units="nm", cmap="gray") + plot.set_title(title, fontsize=14) + return fig, plot + + +# %% +# Panel labels +# ------------ +# Titles, axis labels and the colorbar label take an optional ``fontsize`` +# and the same ``$...$`` markup as text markers. + +fig, v = panel(r"Panel labels: $\lambda$ = 2.5 $\AA$") +v.set_xlabel(r"$x$ (nm)", fontsize=13) +v.set_ylabel(r"$y$ (nm)", fontsize=13) +v.set_colorbar_visible(True) +v.set_colorbar_label(r"Intensity ($\times 10^{3}$)", fontsize=11) +v.set_tick_label_size(11) +fig + +# %% +# Size and weight +# --------------- +# ``fontsize`` and ``fontweight`` apply to the whole group, so labels that +# differ in either go in separate groups. + +fig, v = panel("Size and weight") +for i, (size, weight) in enumerate([(12, "normal"), (16, "normal"), + (16, "bold"), (24, "bold")]): + v.add_texts([[12, 20 + 38 * i]], [f"{size} px {weight}"], + name=f"size{i}", color="#ffeb3b", + fontsize=size, fontweight=weight) +fig + +# %% +# Outline for legibility +# ---------------------- +# Over a busy image a label can vanish into the background. An +# ``outline_color`` strokes a halo under the fill; ``outline_width`` sets +# its thickness in pixels. + +fig, v = panel("Outline (halo)") +v.add_texts([[12, 40]], ["no outline"], name="plain", + color="#ffffff", fontsize=20, fontweight="bold") +v.add_texts([[12, 100]], ["outline_color='black'"], name="halo", + color="#ffffff", fontsize=20, fontweight="bold", + outline_color="#000000", outline_width=4) +fig + +# %% +# Rotation +# -------- +# ``rotation`` is in **radians** (matplotlib's ``TextCollection`` +# convention) and turns the label counter-clockwise about its anchor point. + +fig, v = panel("Rotation about the anchor") +anchor = [40, 165] +v.add_points([anchor], name="anchor", sizes=4, color="#ff1744", + facecolors="#ff1744") +for deg in (0, 45, 90): + # Leading spaces push the text off the anchor dot. + v.add_texts([anchor], [f" rotation = {deg}\N{DEGREE SIGN}"], + name=f"rot{deg}", color="#00e5ff", fontsize=16, + outline_color="#000000", rotation=np.deg2rad(deg), + verticalalignment="center") +fig + +# %% +# Alignment +# --------- +# ``horizontalalignment`` (``"left"``, ``"center"``, ``"right"``) and +# ``verticalalignment`` (``"top"``, ``"center"``, ``"bottom"``, +# ``"baseline"``) choose which point of the text sits on the anchor. The +# defaults, ``"left"`` and ``"top"``, hang the label below and to the right. + +fig, v = panel("horizontal / vertical alignment") +cols = {"left": 45, "center": 130, "right": 215} +rows = {"top": 40, "center": 90, "bottom": 140} +for ha, x in cols.items(): + for va, y in rows.items(): + v.add_points([[x, y]], name=f"pt-{ha}-{va}", sizes=3, + color="#ff1744", facecolors="#ff1744") + v.add_texts([[x, y]], [f"{ha}/{va}"], name=f"txt-{ha}-{va}", + color="#ffeb3b", fontsize=13, outline_color="#000000", + horizontalalignment=ha, verticalalignment=va) +fig + +# %% +# Math in labels +# -------------- +# Text with an even number of ``$`` signs is typeset: superscripts, +# subscripts, Greek letters and ``\mathrm{}`` for upright text. Weight and +# outline still apply. As in matplotlib, a string with a single (odd) +# ``$`` is plain text, so prices and shell prompts draw as written. + +fig, v = panel("TeX in text markers") +v.add_texts([[12, 25]], [r"$\mathrm{Fe} K_{\alpha}$ edge"], name="edge", + color="#ffeb3b", fontsize=18, outline_color="#000000") +v.add_texts([[12, 65]], [r"$\sigma$ = $10^{-3}$ nm$^{-1}$"], + name="sigma", color="#ffeb3b", fontsize=18, fontweight="bold", + outline_color="#000000") +v.add_texts([[12, 105]], [r"$\Delta E \approx$ 1.2 eV"], + name="delta", color="#00e5ff", fontsize=18, + outline_color="#000000") +v.add_texts([[12, 145]], ["Plain text: $5 each"], name="plain", + color="#ffffff", fontsize=16, outline_color="#000000") +fig + +# %% +# Live update +# ----------- +# Every option can change after the label is drawn with ``set(...)``. + +v.markers["texts"]["edge"].set(fontsize=22, fontweight="bold", + rotation=np.deg2rad(-8)) +fig diff --git a/anyplotlib/figure_esm.js b/anyplotlib/figure_esm.js index 9dd11b84b..932303295 100644 --- a/anyplotlib/figure_esm.js +++ b/anyplotlib/figure_esm.js @@ -137,19 +137,27 @@ function render({ model, el, onResize, onReadout }) { ctx.strokeStyle=ms.outline_color; ctx.lineWidth=ow; ctx.lineJoin='round'; return true; } + // matplotlib's cbook.is_math_text: a string is math only when it holds an + // even, non-zero number of unescaped dollars, so "Cost $5" stays literal. + function _isMathText(s) { + const n = (s.match(/\$/g) || []).length - (s.match(/\\\$/g) || []).length; + return n > 0 && n % 2 === 0; + } function _drawMarkerText(ctx, ms, s, x, y, outlined) { // Anchor at the offset point first, then rotate about it (matplotlib // semantics: a positive rotation reads counter-clockwise). Canvas's // y axis points down, so the sign flips. const rot = ms.rotation || 0; if (rot) { ctx.save(); ctx.translate(x, y); ctx.rotate(-rot); x = 0; y = 0; } - if (s.indexOf('$') === -1) { + if (!_isMathText(s)) { if(outlined) ctx.strokeText(s, x, y); ctx.fillText(s, x, y); } else { // $...$ markup typesets through the axis-label TeX engine instead - // of showing raw dollars. - _drawTex(ctx, s, x, y, ms.fontsize||12, {align: ms.halign||'left', outlined}); + // of showing raw dollars. _drawTex rebuilds ctx.font, so the + // weight has to travel with it. + _drawTex(ctx, s, x, y, ms.fontsize||12, + {align: ms.halign||'left', weight: ms.fontweight||'', outlined}); } if (rot) ctx.restore(); } diff --git a/anyplotlib/tests/test_markers/test_text_style.py b/anyplotlib/tests/test_markers/test_text_style.py index 3942220c3..048dcbe96 100644 --- a/anyplotlib/tests/test_markers/test_text_style.py +++ b/anyplotlib/tests/test_markers/test_text_style.py @@ -209,3 +209,28 @@ def test_outline_draws_a_halo_in_its_own_colour(self, take_screenshot, make): assert _count(halo, RED) > 100, "no outline pixels on the canvas" # The fill sits on top of the stroke: the glyph body keeps its colour. assert _count(halo, BLUE) > 0.5 * _count(base, BLUE) + + @pytest.mark.parametrize("make", [_plot2d, _plot1d]) + def test_bold_survives_tex_typesetting(self, take_screenshot, make): + # _drawTex rebuilds the canvas font, so the marker's weight must be + # handed to it or math labels silently drop back to normal. + ink = {} + for weight in ("normal", "bold"): + fig, plot = make() + plot.add_texts(texts=[r"$\mathrm{WWWW}$"], color="#ff0000", + fontsize=28, fontweight=weight, **_PLACE[make]) + ink[weight] = _count(take_screenshot(fig), RED) + assert ink["normal"] > 0, "the label did not render" + assert ink["bold"] > ink["normal"] * 1.15, ink + + @pytest.mark.parametrize("text", ["$$$", "Cost $5"]) + def test_odd_dollars_stay_literal(self, take_screenshot, text): + # Like matplotlib, an odd dollar count is plain text: the dollars + # are drawn, not swallowed as math delimiters. + ink = {} + for s in (text, text.replace("$", "")): + fig, plot = _plot2d() + plot.add_texts(texts=[s], color="#ff0000", fontsize=28, + **_PLACE[_plot2d]) + ink[s] = _count(take_screenshot(fig), RED) + assert ink[text] > ink[text.replace("$", "")] + 20, ink diff --git a/upcoming_changes/+fonts-labels-example.doc.rst b/upcoming_changes/+fonts-labels-example.doc.rst new file mode 100644 index 000000000..602cb0f6c --- /dev/null +++ b/upcoming_changes/+fonts-labels-example.doc.rst @@ -0,0 +1,3 @@ +Added a *Fonts and Labels* gallery example covering text-marker size, +weight, outline, rotation, alignment and ``$...$`` math, alongside the +panel's own title and axis labels.