Forcing IPython to display multiple equations in single line

How I sacrificed few hours of my life for aethetics
IPython imports (click to expand)
In [1]:
from sympy import symbols, Derivative as D, Function as F, Eq, init_printing, latex
from IPython.display import Markdown
init_printing()

If you are working with numerical computations in an IPython notebook, you don't really have much choice but accompany your computations by Latex formulas. However, with symbolic computations, you can massively save on repeating the same formulas which are already encoded in your symbolic object. Here's a quick example how it typically looks in the notebooks I've seen on the internet:


In [2]:
x, t = symbols('x t')
U = F('U')(x, t)
heat_eq = Eq(D(U, t), D(U, x, 2))

Solving heat type PDE:

In [3]:
display(heat_eq)
$\displaystyle \frac{\partial}{\partial t} U{\left(x,t \right)} = \frac{\partial^{2}}{\partial x^{2}} U{\left(x,t \right)}$
In [4]:
bc_left  = Eq(U.subs(x, 0), 0)
bc_right = Eq(U.subs(x, 1), 0)

Subject to:

In [5]:
display(bc_left)
display(bc_right)
$\displaystyle U{\left(0,t \right)} = 0$
$\displaystyle U{\left(1,t \right)} = 0$

What I don't like about that style of presentation is that it's pretty scattered and reqires interleaving displayed formulas with random python cells. That's bad since it either pollutes your notebook and takes up vertical space, or discourages you from producing useful outputs in favor of notebook aethetics. Here are couple of examples featured on Sympy website:

What we want is to output whole bunch of things at once. Sadly, display doesn't support even outputting two formulas on a single line without hacking CSS, so this is the easiest way of doing it I came up with to so far:

In [6]:
display(Markdown("Solving heat type PDE ${}$ subject to: ${}$ and ${}$".format(latex(heat_eq), latex(bc_left), latex(bc_right))))

Solving heat type PDE $\frac{\partial}{\partial t} U{\left(x,t \right)} = \frac{\partial^{2}}{\partial x^{2}} U{\left(x,t \right)}$ subject to: $U{\left(0,t \right)} = 0$ and $U{\left(1,t \right)} = 0$

It's quite tedious to do that every time, so I extracted it in a little helper method: TODO inject function code here... perhaps templating engine??

In [7]:
def ldisplay_md(fmt, *args, **kwargs):
    display(Markdown(fmt.format(
        *(f'${latex(x)}$' for x in args),
        **{k: f'${latex(v)}$' for k, v in kwargs.items()})
    ))

So far so good, I just define ldisplay = ldisplay_md on top of my notebook and use ldisplay instead of display for singleline outputs.

However while trying to use it with EIN (emacs frontent for jupiter), I ran into an issue: EIN doesn't support Markdown outputs! Same thing happens if you run it in terminal: you're just gonna get <IPython.core.display.Markdown object>. Nevertheless, it is capable of outputing formulas as ASCII art, kinda like this:

                2
∂              ∂
──(U(x, t)) = ───(U(x, t))
∂t              2
              ∂x

, so we can achieve a similar effect by formatting manually via str.format.

Without further ado, here's the bit of code which does that:

In [8]:
def as_text(thing):
    from IPython.core.interactiveshell import InteractiveShell # type: ignore
    plain_formatter = InteractiveShell.instance().display_formatter.formatters['text/plain']
    pp = plain_formatter(thing)
    lines = pp.splitlines()
    return lines

def vcpad(lines, height):
    width = len(lines[0])
    missing = height - len(lines)
    above = missing // 2
    below = missing - above
    return [' ' * width for _ in range(above)] + lines + [' ' * width for _ in range(below)]

# terminal and emacs can't display markdown, so we have to use that as a workaround
def mdisplay_plain(fmt, *args, **kwargs):
    import re
    from itertools import chain
    fargs   = [as_text(a) for a in args]
    fkwargs = {k: as_text(v) for k, v in kwargs.items()}

    height = max(len(x) for x in chain(fargs, fkwargs.values()))

    pargs   = [vcpad(a, height) for a in fargs]
    pkwargs = {k: vcpad(v, height) for k, v in fkwargs.items()}

    textpos = height // 2

    lines = []
    for h in range(height):
        largs   = [a[h] for a in pargs]
        lkwargs = {k: v[h] for k, v in pkwargs.items()}
        if h == textpos:
            fstr = fmt
        else:
            # we want to keep the formatting specifiers (stuff in curly braces and empty everything else)
            fstr = ""
            for e in re.finditer(r'{.*?}', fmt):
                fstr = fstr + " " * (e.start() - len(fstr))
                fstr += e.group()
        lines.append(fstr.format(*largs, **lkwargs))
    for line in lines:
        print(line.rstrip())
In [9]:
mdisplay_plain("Solving heat type PDE  {} subject to: {bl} and {br}", heat_eq, bl=bc_left, br=bc_right)
                                       2
                       ∂              ∂
Solving heat type PDE  ──(U(x, t)) = ───(U(x, t)) subject to: U(0, t) = 0 and U(1, t) = 0
                       ∂t              2
                                     ∂x

Neat? I think so!

The sad thing is that figuring out the as_text bit took me about an hour of intense debugging, including setting explicit pdb breakpoints in IPython source code. I'm not sure how normal people are meant to figure that out. One could argue that desire to work with IPython notebooks in Emacs is not very normal either though. Hopefully that saves someone else a bit of time.