Source code for dmslogo.logo

"""
=======
logo
=======

Core logo-drawing functions of `dmslogo`.

Some of this code is borrowed and modified from
`pyseqlogo <https://github.com/saketkc/pyseqlogo>`_.
"""


import glob
import os
import warnings

import matplotlib.font_manager
import matplotlib.patheffects
import matplotlib.pyplot as plt
import matplotlib.ticker
import matplotlib.transforms

import numpy

import pandas as pd

import pkg_resources

import dmslogo.colorschemes
import dmslogo.utils


# default font
_DEFAULT_FONT = "DejaVuSansMonoBold_SeqLogo"

# add fonts to font manager
_FONT_PATH = pkg_resources.resource_filename("dmslogo", "ttf_fonts/")
if not os.path.isdir(_FONT_PATH):
    raise RuntimeError(f"Cannot find font directory {_FONT_PATH}")

for _fontfile in matplotlib.font_manager.findSystemFonts(_FONT_PATH):
    matplotlib.font_manager.fontManager.addfont(_fontfile)
for _fontfile in matplotlib.font_manager.findSystemFonts(None):
    try:
        matplotlib.font_manager.fontManager.addfont(_fontfile)
    except TypeError:
        warnings.warn(f"Cannot load font {_fontfile}", RuntimeWarning)
    except RuntimeError:
        # problem with loading emoji fonts; solution here is just to
        # skip any fonts that cause problems
        pass
del _fontfile

_fontlist = {f.name for f in matplotlib.font_manager.fontManager.ttflist}
if _DEFAULT_FONT not in _fontlist:
    raise RuntimeError(f"Could not find default font {_DEFAULT_FONT}")
for _fontfile in glob.glob(f"{_FONT_PATH}/*.ttf"):
    _font = os.path.splitext(os.path.basename(_fontfile))[0]
    if _font not in _fontlist:
        raise RuntimeError(f"Could not find font {_font} in file {_fontfile}")


[docs] class Scale(matplotlib.patheffects.RendererBase): """Scale letters using affine transformation. From here: https://www.python-forum.de/viewtopic.php?t=30856 """ def __init__(self, sx, sy=None): """See main class docstring.""" self._sx = sx self._sy = sy
[docs] def draw_path(self, renderer, gc, tpath, affine, rgbFace): """Draw the letters.""" affine = matplotlib.transforms.Affine2D().scale(self._sx, self._sy) + affine renderer.draw_path(gc, tpath, affine, rgbFace)
[docs] class Memoize: """Memoize function from https://stackoverflow.com/a/1988826""" def __init__(self, f): """See main class docstring.""" self.f = f self.memo = {} def __call__(self, *args): """Call class.""" if args not in self.memo: self.memo[args] = self.f(*args) # Warning: You may wish to do a deepcopy here if returning objects return self.memo[args]
@Memoize def _setup_font(fontfamily, fontsize): """Get `FontProperties` for `fontfamily` and `fontsize`.""" font = matplotlib.font_manager.FontProperties( family=fontfamily, size=fontsize, weight="bold" ) return font @Memoize def _frac_above_baseline(font): """Fraction of font height that is above baseline. Args: `font` (FontProperties) Font for which we are computing fraction. """ fig, ax = plt.subplots() ax.set_xlim(0, 1) ax.set_ylim(0, 1) txt_baseline = ax.text( 0, 0, "A", fontproperties=font, va="baseline", bbox={"pad": 0} ) txt_bottom = ax.text(0, 0, "A", fontproperties=font, va="bottom", bbox={"pad": 0}) fig.canvas.draw() bbox_baseline = txt_baseline.get_window_extent() bbox_bottom = txt_bottom.get_window_extent() height_baseline = bbox_baseline.y1 - bbox_baseline.y0 height_bottom = bbox_bottom.y1 - bbox_bottom.y0 assert numpy.allclose(height_baseline, height_bottom) frac = (bbox_baseline.y1 - bbox_bottom.y0) / height_bottom plt.close(fig) return frac def _draw_text_data_coord( height_matrix, ystarts, ax, fontfamily, fontaspect, letterpad, letterheightscale, xpad, ): """Draws logo letters. Args: `height_matrix` (list of lists) Gives letter heights. In the main list, there is a list for each site, with the entries being 3-tuples giving the letter, its height, its color, and 'pad_below' or 'pad_above' indicating where vertical padding is added. `ystarts` (list) Gives y position of bottom of first letter for each site. `ax` (matplotlib Axes) Axis on which we draw logo letters. `fontfamily` (str) Name of font to use. `fontaspect` (float) Value to use for font aspect ratio (height to width). `letterpad` (float) Add this much vertical padding between letters. `letterheightscale` (float) Scale height of letters by this much. `xpad` (float) x-axis is padded by this many data units on each side. """ fig = ax.get_figure() # get bbox in **inches** bbox = ax.get_window_extent().transformed(fig.dpi_scale_trans.inverted()) width = bbox.width * len(height_matrix) / (2 * xpad + len(height_matrix)) height = bbox.height max_stack_height = max(sum(abs(tup[1]) for tup in row) for row in height_matrix) if len(ystarts) != len(height_matrix): raise ValueError( "`ystarts` and `height_matrix` different lengths\n" f"ystarts={ystarts}\nheight_matrix={height_matrix}" ) ymin, ymax = ax.get_ylim() yextent = ymax - ymin if max_stack_height > yextent: raise ValueError("`max_stack_height` exceeds `yextent`") if ymin > 0: raise ValueError("`ymin` > 0") if min(ystarts) < ymin: raise ValueError("`ymin` exceeds smallest `ystarts`") letterpadheight = yextent * letterpad fontsize = 72 font = _setup_font(fontfamily, fontsize) frac_above_baseline = _frac_above_baseline(font) fontwidthscale = width / (fontaspect * len(height_matrix)) for xindex, (xcol, ystart) in enumerate(zip(height_matrix, ystarts)): ypos = ystart for letter, letterheight, lettercolor, pad_loc in xcol: adj_letterheight = letterheightscale * letterheight padding = min(letterheight / 2, letterpadheight) if pad_loc == "pad_below": ypad = padding + letterheight - adj_letterheight elif pad_loc == "pad_above": ypad = 0 else: raise ValueError(f"invalid `pad_loc` {pad_loc}") txt = ax.text( xindex, ypos + ypad, letter, fontsize=fontsize, color=lettercolor, ha="left", va="baseline", fontproperties=font, bbox={"pad": 0, "edgecolor": "none", "facecolor": "none"}, ) scaled_height = adj_letterheight / frac_above_baseline scaled_padding = padding / frac_above_baseline txt.set_path_effects( [ Scale( fontwidthscale, ((scaled_height - scaled_padding) * height / yextent), ) ] ) ypos += letterheight if __name__ == "__main__": import doctest doctest.testmod()