From a61990038d52eea66d1762988288cfb5aac32e72 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 17 Apr 2022 15:53:39 -0700 Subject: [PATCH 01/13] initial update of text_element, added functionality to account for element_text tracking, transfered show and save from base image, missing some tests & functionality, and needs fuller connection to preventative erroring as seen in cow.patch --- src/cowpatch/__init__.py | 2 +- src/cowpatch/base_elements.py | 6 +- src/cowpatch/text_elements.py | 504 +++++++++++++++++++++++++--------- tests/test_text_elements.py | 131 +++++++++ 4 files changed, 508 insertions(+), 135 deletions(-) create mode 100644 tests/test_text_elements.py diff --git a/src/cowpatch/__init__.py b/src/cowpatch/__init__.py index 6296d08..8a48fc7 100644 --- a/src/cowpatch/__init__.py +++ b/src/cowpatch/__init__.py @@ -4,6 +4,6 @@ from .layout_elements import layout, area from .base_elements import patch -#from .text_elements import text +from .text_elements import text #from .wrappers import wrapper_plotnine, wrapper_matplotlib, wrapper_seaborn from .config import rcParams diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index d41b53a..0e04616 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -230,6 +230,8 @@ def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): desired width of svg object in points height_pt : float desired height of svg object in points + sizes: TODO: write description + num_attempts : TODO: write description Returns ------- @@ -600,7 +602,7 @@ def save(self, filename, width=None, height=None, dpi=96, _format=None, io.BytesIO : object that acts like a reading in of bytes """ # updating width and height if necessary (some combine is none) - width, height = self._default_size(width=width,height=height) + width, height = self._default_size(width=width, height=height) # global default for verbose (if not provided by the user) if verbose is None: @@ -652,7 +654,7 @@ def show(self, width=None, height=None, dpi=96, verbose=None): representation. """ # updating width and height if necessary (some combine is none) - width, height = self._default_size(width=width,height=height) + width, height = self._default_size(width=width, height=height) # global default for verbose (if not provided by the user) if verbose is None: diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index 41ce428..40dc237 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -1,8 +1,23 @@ import plotnine as p9 import numpy as np +import copy +import matplotlib.pyplot as plt +import io +import svgutils.transform as sg +import xml.etree.ElementTree as ET +import re + +from contextlib import suppress # suppress kwargs that are incorrect + +import pdb + +from .utils import to_pt, from_pt, to_inches, from_inches, \ + _transform_size_to_pt +from .svg_utils import _show_image, _save_svg_wrapper +from .config import rcParams class text: - def __init__(self, label, element_text=None, theme=None): + def __init__(self, label, element_text=None): """ create a new text object @@ -11,30 +26,17 @@ def __init__(self, label, element_text=None, theme=None): label : string text label with desirable format (e.g. sympy, etc.) element_text : plotnine.themes.elements.element_text - element object from plotnine, can also be added later - theme : plotnine.theme object - theme object from plotnine, can be associated later - should only provide a theme or element_text (as a theme - contains an element_text) + element object from plotnine Notes ----- - This class leverages matplotlib to create the text (given that - matplotlib can create path-style objects for text if the individual - is worried that the svg text visually won't be preserved - this is also - annoying that this is the default). - - Note the function use the ``text`` attribute - NOT the - ``plot_title`` attribute if a theme is provided. + https://stackoverflow.com/questions/34387893/output-matplotlib-figure-to-svg-with-text-as-text-not-curves + ^ on text vs paths for text in mathplotlib """ self.label = label - - if element_text is not None and theme is not None: - raise ValueError("please provide only a theme or element_text, "+\ - "not both") + self._type = "text" self.element_text = None # prep initialization self._clean_element_text(element_text) - self.theme = theme def _clean_element_text(self, element_text): """ @@ -49,7 +51,7 @@ def _clean_element_text(self, element_text): Notes ----- - updates i place + updates in place """ if element_text is None: element_text = None @@ -66,98 +68,182 @@ def _clean_element_text(self, element_text): else: self.element_text = element_text - return None # just a reminder def __add__(self, other): """ - add element_text or theme to update format - - TODO: make this work w.r.t patchwork approaches (edge cases #8) + add element_text update format Arguments --------- - other : plotnine.themes.elements.element_text or theme - theme or element_text to define the attributes of the text. + other : plotnine.themes.elements.element_text + element_text to define the attributes of the text. + + Returns + ------- + updated text object Notes ----- - Note the function use the ``text`` attribute - NOT the ``plot_title`` - attribute if a theme is provided. + The way we allow for alterations when text objects are used in + `annotate` function only allows direct alterations of the text's style + with the addition of a p9.element_text object. """ - if not isinstance(other, p9.themes.themeable.element_text) and \ - not isinstance(other, p9.theme): + if isinstance(other, p9.theme): + # "a text object's information should not be updated with p9.theme + # directly due to uncertainity in which theme key parameter will + # actually be used. + raise ValueError("a text object's presentation information is not "+\ + "able to be directly updated with a p9.theme, " + + "use a p9.element_text object instead. See Notes "+\ + "for more information") + + if not isinstance(other, p9.themes.themeable.element_text): raise ValueError("text objects are only allowed to be combined "+\ - "with element_text objects.") - # need to update theme or element_text... - if isinstance(other, p9.themes.themeable.element_text): - self._clean_element_text(other) + "with p9.element_text objects.") - # update theme if it already exists - # (if not we'll update it when it's required) - if self.theme is not None: - self.theme += p9.theme(text = self.element_text.theme_element) + new_object = copy.deepcopy(self) - if isinstance(other, p9.theme): - if self.theme is None: - self.theme = other - else: - self.theme.add_theme(other) + new_object._clean_element_text(other) + + # # need to update theme or element_text... + # if isinstance(other, p9.themes.themeable.element_text): + # self._clean_element_text(other) - new_element_text = self.theme.themeables.get("text") - self._clean_element_text(new_element_text) - return self + # # update theme if it already exists + # # (if not we'll update it when it's required) + # if self.theme is not None: + # self.theme += p9.theme(text = self.element_text.theme_element) - def _provide_complete_theme(self): + # if isinstance(other, p9.theme): + # if self.theme is None: + # self.theme = other + # else: + # self.theme.add_theme(other) + + # new_element_text = self.theme.themeables.get("text") + # self._clean_element_text(new_element_text) + return new_object + + def _update_element_text_from_theme(self, theme, key=None): """ - It should be here that the current global theme is accessed, and thing are completed... + Internal function to update .element_text description from a theme + + Arguments + --------- + theme : plotnine.theme object + theme object from plotnine + key : str + string associated with which of theme's internal parameter keys + the element_text will be updated from. If key is None, then we + use the internal ._type value + + Notes + ----- + updates element_text inplace. """ - if self.theme is None: - current_theme = p9.theme_get() - # and update with our element_text - if self.element_text is not None: - # problem here... (need to update themeable.get("text") instead) - current_theme += p9.theme(text=self.element_text.theme_element) + if key is None: + key = self._type + + # 1. check that theme has desired key + if not key in theme.themeables.keys(): + raise ValueError("key parameter in _update_element_text_from_theme " +\ + "function call needs to be a key in the provided " +\ + "theme's themeables.") + + # update element_text with new element_text + new_et = theme.themeables.get(key) + if self.element_text is not None: + self.element_text.merge(new_et) else: - current_theme = self.theme + self.element_text = new_et - return current_theme + return None # just a reminder - def _inner_prep(self): + def _get_full_element_text(self): + """ + create a "full" element_text from base p9 theme """ - Internal function to create matplotlib figure with text and - provide a bounding box for the location in the plot + new_self = copy.deepcopy(self) + new_self._update_element_text_from_theme(p9.theme_get()) + + if self.element_text is not None: + new_self.element_text.merge(self.element_text) + + return new_self.element_text + + def _min_size(self): + """ + calculate minimum size of bounding box around self in pt(?) and + creates base image of text object + + + Arguments + --------- + close : boolean + if we should close the plot after we find minimum size. Returns ------- - fig : matplotlib.figure.Figure - figure with text at (0,0), no axes - bbox : matplotlib.transforms.Bbox - bbox location of text in figure + min_width_pt : float + minimum width for text (pt) + min_height_pt : float + minimum height for text (pt) + + Note + ---- + this code is simlar to that in _base_text_image """ + current_element_text = self._get_full_element_text() - # apply theme/element_text correctly ------ + properties = current_element_text.properties.copy() - # mirrors code in p9.ggplot.draw_title() and - # p9.themeable.plot_title.apply_figure() - # code references: - # - https://github.com/has2k1/plotnine/blob/9fbb5f77c8a8fb8c522eb88d4274cd2fa4f8dd88/plotnine/ggplot.py#L545 - # - https://github.com/has2k1/plotnine/blob/6c82cdc20d6f81c96772da73fc07a672a0a0a6ef/plotnine/themes/themeable.py#L361 - # + # create text and apply ---------- + # https://stackoverflow.com/questions/24581194/matplotlib-text-bounding-box-dimensions - # collect desirable theme and properties -------- - theme = self._provide_complete_theme() + fig, ax = plt.subplots() + fig.set_dpi(96) # used for bbox in pixel sizes... + txt = fig.text(x=0.000, y=0.000, s=self.label) + with suppress(KeyError): + del properties['margin'] + with suppress(KeyError): + txt.set(**properties) + + bbox = txt.get_window_extent(fig.canvas.get_renderer()) + min_width_pt = to_pt(bbox.width, 'px') + min_height_pt = to_pt(bbox.height, 'px') + + margin_dict = current_element_text.properties.get("margin") + if margin_dict is not None: + margin_dict_pt = {"t": to_pt(margin_dict["t"], margin_dict["units"]), + "b": to_pt(margin_dict["b"], margin_dict["units"]), + "l": to_pt(margin_dict["l"], margin_dict["units"]), + "r": to_pt(margin_dict["r"], margin_dict["units"])} + else: + margin_dict_pt = {"t":0, "b":0, "l":0, "r":0} - text_themeable = theme.themeables.get('text') - properties = text_themeable.properties.copy() + min_width_pt += (margin_dict_pt["l"] + margin_dict_pt["r"]) + min_height_pt += (margin_dict_pt["t"] + margin_dict_pt["b"]) + plt.close() + + return min_width_pt, min_height_pt + + def _base_text_image(self, close=True): + """ + Note + ---- + this code is simlar to that in _min_size + """ + current_element_text = self._get_full_element_text() + properties = current_element_text.properties.copy() # create text and apply ---------- # https://stackoverflow.com/questions/24581194/matplotlib-text-bounding-box-dimensions fig, ax = plt.subplots() - fig.set_dpi(96) + fig.set_dpi(96) # used for bbox in pixel sizes... txt = fig.text(x=0.000, y=0.000, s=self.label) with suppress(KeyError): del properties['margin'] @@ -171,35 +257,92 @@ def _inner_prep(self): fig.axes.pop() plt.axis('off') - # bbox aids in cutting all but the desired image + if close: + plt.close() return fig, bbox + def _default_size(self, width, height): + """ + (Internal) obtain default recommended size of overall text object if + width or height is None - def _create_svg_object(self, width=None, height=None): + Arguments + --------- + width : float + width of output image in inches (this should actually be associated + with the svg...) + height : float + height of svg in inches (this should actually be associated + with the svg...) + + Returns + ------- + width : float + returns default width for given object if not provided (else just + returns provided value). If only height is provided then width + proposed is the minimum width (including margins). + height : float + returns default height for given object if not provided (else just + returns provided value). If only width is provided then height + proposed is the minimum height (including margins). + """ + if width is not None and height is not None: + return width, height + + min_width_pt, min_height_pt = self._min_size() + + if width is None: + width = from_pt(min_width_pt, "inches") + if height is None: + height = from_pt(min_height_pt, "inches") + + return width, height + + def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): """ Internal to create svg object (with text in correct location and correct image size) Arguments --------- - width : float - width of desired output (in inches) - height : float - height of desired output (in inches) + width_pt : float + width of desired output (in pt) + height_t : float + height of desired output (in pt) + sizes: TODO: write description & code up + num_attempts : TODO: write description & code up Returns ------- svg_obj : svgutils.transform.SVGFigure svg representation of text with correct format and image size + + See also + -------- + patch._svg : similar functionality for a patch object + svgutils.transforms : pythonic svg object """ - fig, bbox = self._inner_prep() + fig, bbox = self._base_text_image(close=False) + min_width_pt, min_height_pt = self._min_size() + + # TODO: update to the "correction proposal approach" + if width_pt is not None: + if width_pt < min_width_pt - 1e-10: # eps needed + raise ValueError("requested width of text object isn't "+\ + "large enough for text") + else: #if width is None + width_pt = min_width_pt + + + if height_pt is not None: + if height_pt < min_height_pt - 1e-10: # eps needed + raise ValueError("requested height of text object isn't "+\ + "large enough for text") + else: #if height is None + height_pt = min_height_pt - if width is not None: - width = to_pt(width, "in") - if height is not None: - height = to_pt(height, "in") # get original matplotlib image ------------ @@ -208,37 +351,35 @@ def _create_svg_object(self, width=None, height=None): fid.seek(0) image_string = fid.read() img = sg.fromstring(image_string) - img_size = transform_size((img.width, img.height)) - # location correction for alignment and margins ------- - - current_theme = self._provide_complete_theme() - ha_str = current_theme.themeables.get("text").properties.get("ha") - va_str = current_theme.themeables.get("text").properties.get("va") - margin_dict = current_theme.themeables.get("text").properties.get("margin") - margin_dict_pt = {"t": to_pt(margin_dict["t"], margin_dict["units"]), - "b": to_pt(margin_dict["b"], margin_dict["units"]), - "l": to_pt(margin_dict["l"], margin_dict["units"]), - "r": to_pt(margin_dict["r"], margin_dict["units"])} - + img_size = _transform_size_to_pt((img.width, img.height)) + # ^this won't be width or min_width_pt related - if width is None: - width = to_pt(bbox.width, 'px') + margin_dict_pt["l"] + margin_dict_pt["r"] + # location correction for alignment and margins ------- + current_element_text = self._get_full_element_text() # + ha_str = current_element_text.properties.get("ha") + va_str = current_element_text.properties.get("va") + margin_dict = current_element_text.properties.get("margin") + + if margin_dict is not None: + margin_dict_pt = {"t": to_pt(margin_dict["t"], margin_dict["units"]), + "b": to_pt(margin_dict["b"], margin_dict["units"]), + "l": to_pt(margin_dict["l"], margin_dict["units"]), + "r": to_pt(margin_dict["r"], margin_dict["units"])} + else: + margin_dict_pt = {"t":0, "b":0, "l":0, "r":0} - if height is None: - height = to_pt(bbox.height, 'px') + margin_dict_pt["t"] + margin_dict_pt["b"] if ha_str == "center": - x_shift = width/2 - to_pt(bbox.width, "px") /2 + x_shift = width_pt/2 - to_pt(bbox.width, "px")/2 elif ha_str == "right": - x_shift = width - to_pt(bbox.width, "px") - margin_dict_pt["r"] + x_shift = width_pt - to_pt(bbox.width, "px") - margin_dict_pt["r"] else: # ha_str == "left" x_shift = margin_dict_pt["l"] - if va_str == "center": - y_shift = height/2 - to_pt(bbox.height, "px") /2 + y_shift = height_pt/2 - to_pt(bbox.height, "px")/2 elif va_str == "bottom": - y_shift = height - to_pt(bbox.height, "px") - margin_dict_pt["b"] + y_shift = height_pt - to_pt(bbox.height, "px") - margin_dict_pt["b"] else: # va_str = "top" y_shift = margin_dict_pt["t"] @@ -251,13 +392,11 @@ def _create_svg_object(self, width=None, height=None): img_size[1] - to_pt(bbox.y1, units = "px")) # height - y_max - new_image_size = (str(width)+"pt", - str(height)+"pt") + new_image_size_string_val = (str(width_pt), str(height_pt)) + new_image_size = (new_image_size_string_val[0] + "pt", + new_image_size_string_val[1] + "pt") # need to declare the viewBox for some reason... - new_image_size_string_val = [re.sub("pt", "", val) - for val in new_image_size] - new_viewBox = "0 0 %s %s" % (new_image_size_string_val[0], new_image_size_string_val[1]) @@ -266,21 +405,13 @@ def _create_svg_object(self, width=None, height=None): y = -1*svg_bb_top_left_corner[1] + y_shift) img_root_str = img_root.tostr() - new_image = sg.SVGFigure() new_image.set_size(new_image_size) new_image.root.set("viewBox", new_viewBox) - - # remove patch (lxml.etree) ---------- - # img_root2_lxml = etree.fromstring(img_root_str) - # parent = img_root2_lxml.findall(".//{http://www.w3.org/2000/svg}g[@id=\"patch_1\"]")[0] - # to_remove = parent.getchildren()[0] - # parent.remove(to_remove) - # img_root2_str = etree.tostring(img_root2_lxml) - # img2 = sg.fromstring(img_root2_str.decode("utf-8")) - - # remove path (xml.etree.ElementTree) --------- + ### remove path (xml.etree.ElementTree) --------- + # removing the background behind the text object (allows text to be + # above other objects) img_root2_xml = ET.fromstring(img_root_str) parent = img_root2_xml.findall(".//{http://www.w3.org/2000/svg}g[@id=\"patch_1\"]")[0] to_remove = img_root2_xml.findall(".//{http://www.w3.org/2000/svg}path")[0] @@ -290,27 +421,136 @@ def _create_svg_object(self, width=None, height=None): new_image.append(img2) - # closing plot plt.close() - return new_image + return new_image, (width_pt, height_pt) - - def save(self, filename, width=None, height=None): + def save(self, filename, width=None, height=None, dpi=96, _format=None, + verbose=None): """ - save text object as image in minimal size object + save text object image to file Arguments --------- filename : str + local string to save the file to (this can also be at a + ``io.BytesIO``) width : float - in inches (if None, then tight (w.r.t. to margins)) + width of output image in inches (this should actually be associated + with the svg...) height : float - in inches (if None, then tight (w.r.t. to margins)) + height of svg in inches (this should actually be associated + with the svg...) + dpi : int or float + dots per square inch, default is 96 (standard) + _format : str + string of format (error tells options). If provided this is the + format used, if None, then we'll try to use the ``filename`` + extension. + verbose : bool + If ``True``, print the saving information. The package default + is defined by cowpatch's own rcParams (the base default is + ``True``), which is used if verbose is ``None``. See Notes. + + Returns + ------- + None + saves to a file + + Notes + ----- + If width and/or height is None, the approach will attempt to define + acceptable width and height. + + The ``verbose`` parameter can be changed either directly with defining + ``verbose`` input parameter or changing + ``cow.rcParams["save_verbose"]``. + + See also + -------- + io.BytesIO : object that acts like a reading in of bytes + cow.patch.save : same function but for a cow.patch object """ + # updating width and height if necessary (some combine is none) + width, height = self._default_size(width=width, height=height) + + # global default for verbose (if not provided by the user) + if verbose is None: + verbose = rcParams["save_verbose"] + + svg_obj, (actual_width_pt, actual_height_pt) = \ + self._svg(width_pt = from_inches(width, "pt", dpi=dpi), + height_pt = from_inches(height, "pt", dpi=dpi)) + + _save_svg_wrapper(svg_obj, + filename=filename, + width=to_inches(actual_width_pt, "pt", dpi=dpi), + height=to_inches(actual_height_pt, "pt", dpi=dpi), + dpi=dpi, + _format=_format, + verbose=verbose) + + def show(self, width=None, height=None, dpi=96, verbose=None): + """ + display text object from the command line or in a jupyter notebook - svg_obj = self._create_svg_object(width=width, height=height) + Arguments + --------- + width : float + width of output image in inches (this should actually be associated + with the svg...) + height : float + height of svg in inches (this should actually be associated + with the svg...) + dpi : int or float + dots per square inch, default is 96 (standard) + verbose : bool + If ``True``, print the saving information. The package default + is defined by cowpatch's own rcParams (the base default is + ``True``), which is used if verbose is ``None``. See Notes. - svg_obj.save(filename) - plt.close() # do we need this? + Notes + ----- + If width and/or height is None, the approach will attempt to define + acceptable width and height. + + The ``verbose`` parameter can be changed either directly with defining + ``verbose`` input parameter or changing + ``cow.rcParams["show_verbose"]``. + + If run from the command line, this approach leverage matplotlib's + plot render to show a static png version of the image. If run inside + a jupyter notebook, this approache presents the actual svg + representation. + + See also + -------- + cow.patch.show : same function but for a cow.patch object + """ + # updating width and height if necessary (some combine is none) + width, height = self._default_size(width=width, height=height) + + # global default for verbose (if not provided by the user) + if verbose is None: + verbose = rcParams["show_verbose"] + + svg_obj, (actual_width_pt, actual_height_pt) = \ + self._svg(width_pt=from_inches(width, "pt", dpi=dpi), + height_pt=from_inches(height, "pt", dpi=dpi)) + + _show_image(svg_obj, + width=to_inches(actual_width_pt, "pt", dpi=dpi), + height=to_inches(actual_height_pt, "pt", dpi=dpi), + dpi=dpi, + verbose=verbose) + + def __str__(self): + self.show() + return "" % self.__hash__() + + def __repr__(self): + out = "\n_type: " + self._type +\ + "\nlabel:\n" + " |" + re.sub("\n", "\n |", self.label) +\ + "\nelement_text:\n |" + self.element_text.__repr__() + return "" % self.__hash__() + "\n" + out diff --git a/tests/test_text_elements.py b/tests/test_text_elements.py new file mode 100644 index 0000000..5062e06 --- /dev/null +++ b/tests/test_text_elements.py @@ -0,0 +1,131 @@ +import cowpatch as cow +import plotnine as p9 + +import pytest +import copy +import numpy as np + +def test__clean_element_text(): + """ + test that element_text stored inside is always a p9.themes.themeable (static) + + Both through initialization and addition + """ + + et_simple = p9.element_text(size = 12) + + + # initialization + mytitle2 = cow.text("title", element_text = et_simple) + + assert isinstance(mytitle2.element_text, p9.themes.themeable.text), \ + "internal saving of element_text is expected to be a themable.text "+\ + "(not a element_text) - through initalization" + + # addition + mytitle = cow.text("title") + + mytitle_simple = mytitle + et_simple + + assert isinstance(mytitle_simple.element_text, p9.themes.themeable.text), \ + "internal saving of element_text is expected to be a themable.text "+\ + "(not a element_text) - updated with addition" + +def test__add__(): + """ + test addition (static) + + checks + 1. theme cannot be added directly + 2. does not update self, but updates a new creation + """ + et_simple = p9.element_text(size = 12) + mytitle = cow.text("title", element_text = et_simple) + + # theme cannot be added + with pytest.raises(Exception) as e_info: + mytitle + p9.theme_bw() + # cannot add theme to text object directly + # would need to use internal _update_element_text_from_theme + + # addition doesn't update old object + + mytitle2 = mytitle + p9.element_text(size = 9) + + assert mytitle.element_text.properties.get("size") == 12 and \ + mytitle2.element_text.properties.get("size") == 9, \ + "expected that the __add__ updates and creates a new object and " +\ + "doesn't update the previous text object directly" + +def test__update_element_text_from_theme(): + """ + test of _update_element_text_from_theme (static test) + """ + mytitle = cow.text("title") + mytitle2 = mytitle + p9.element_text(size = 13) + mytitle_c = copy.deepcopy(mytitle) + mytitle2_c = copy.deepcopy(mytitle2) + + # test error with key + with pytest.raises(Exception) as e_info: + mytitle._update_element_text_from_theme(p9.theme_bw(), + key = "new_name") + with pytest.raises(Exception) as e_info: + mytitle2._update_element_text_from_theme(p9.theme_bw(), + key = "new_name") + + # test update + mytheme = p9.theme_bw() + p9.theme(text = p9.element_text(size = 9)) + + mytitle2._update_element_text_from_theme(mytheme, "text") + mytitle._update_element_text_from_theme(mytheme, "text") + + assert mytitle.element_text.properties.get('size') == 9 and \ + mytitle_c.element_text is None,\ + "expected theme to update element_text of text object "+\ + "(with element_text originally being none)" + + + assert mytitle2.element_text.properties.get('size') == 9 and \ + mytitle2_c.element_text.properties.get('size') == 13,\ + "expected theme to update element_text of text object "+\ + "(with element_text default)" + +def test__get_full_element_text(): + """ + test _get_full_element_text (static test) + """ + mytitle = cow.text("title") + + et_full_base = mytitle._get_full_element_text() + + mytitle_size9 = mytitle + p9.element_text(size = 9) + et_full_size9 = mytitle_size9._get_full_element_text() + + mytitle_size13 = mytitle + p9.element_text(size = 13) + et_full_size13 = mytitle_size13._get_full_element_text() + + assert et_full_size9.properties.get("size") == 9 and \ + et_full_size13.properties.get("size") == 13, \ + "expected specified element_text attributes to be perserved (size)" + + for key in et_full_base.properties: + if key != "size": + assert (et_full_base.properties.get(key) == \ + et_full_size9.properties.get(key)) and \ + (et_full_base.properties.get(key) == \ + et_full_size13.properties.get(key)), \ + (("expected all properties but key=size (key = %s) to match "+\ + "if all inherits properties from global theme") % key) + +def test__min_size(): + """ + test _min_size (static) + + This function only checks relative sizes reported from _min_size + """ + + + + + From f63334d7497b5860559717573b499080bbe89266 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sat, 20 Aug 2022 14:10:40 -0700 Subject: [PATCH 02/13] partial update for annotation --- src/cowpatch/__init__.py | 1 + src/cowpatch/annotation_elements.py | 654 ++++++++++++++++++++++++++++ src/cowpatch/base_elements.py | 288 ++++++++---- src/cowpatch/config.py | 23 +- src/cowpatch/text_elements.py | 210 ++++++++- src/cowpatch/utils.py | 73 ++++ tests/test_annotation_elements.py | 424 ++++++++++++++++++ tests/test_base_elements.py | 303 +++++++++---- tests/test_text_elements.py | 190 +++++++- 9 files changed, 1971 insertions(+), 195 deletions(-) create mode 100644 src/cowpatch/annotation_elements.py create mode 100644 tests/test_annotation_elements.py diff --git a/src/cowpatch/__init__.py b/src/cowpatch/__init__.py index 8a48fc7..7c75fb4 100644 --- a/src/cowpatch/__init__.py +++ b/src/cowpatch/__init__.py @@ -5,5 +5,6 @@ from .layout_elements import layout, area from .base_elements import patch from .text_elements import text +from .annotation_elements import annotation #from .wrappers import wrapper_plotnine, wrapper_matplotlib, wrapper_seaborn from .config import rcParams diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py new file mode 100644 index 0000000..ba13d3e --- /dev/null +++ b/src/cowpatch/annotation_elements.py @@ -0,0 +1,654 @@ +import copy +from .text_elements import text +from .utils import inherits, _to_alphabet_representation, _to_roman_numerials,\ + _string_tag_format +import re +import numpy as np +import copy + +import pdb + +# TODO: potentially allow addition of theme to pass info standard formats... + +# annotation +# +# internal functions: +# 1. pass tags farther down (to another annotation) +# 2. create tag of requested size +# 3. + +class annotation: + def __init__(self, title=None, subtitle=None, caption=None, + tags=None, tags_format=None, tags_order="auto", + tags_loc=None, tags_inherit="fix"): + """ + annotation tools for adding titles and tags to cow.patch + arangement of plots. + + Arguments + --------- + title : str, cow.text, or dictionary + This contains information for the top level patch's titles, which + can appear on the top (dictionary key: "top"/"t"), left + ("left"/"l"), right ("right"/"r"), or bottom ("bottom"/"b"). One + can have multiple titles (e.g. one on top and one on the left). + subtitle : str, cow.text, or dictionary + This contains information for the top level patch's subtitles + (and appear below the titles). Like `title`, these can appear on + the top (dictionary key: "top"/"t"), left ("left"/"l"), + right ("right"/"r"), or bottom ("bottom"/"b"). One can have + multiple subtitles (e.g. one on top and one on the left). + caption : str or cow.text + Each patch can have 1 caption which is located below all other + elements when the patch is configured (i.e. below all plots, + titles, subtitles and tags). + tags : list or tuple + list or tuple. If one wants to define each labels name by hand, + use a list. If you would like to define labels in an automated + manner relative to the the hierachical storage structure, use a + tuple. The length of the tuple, at max should be the max depth of + the arangement of plots. Each value in the tuple can either be + a string specifying the general auto-index structure ("1" or "0", + "a", "A", "i", "I") OR it can be a list that acts like the auto + ordering for the given level. + tags_format : str/text objects or tuple of str/text objects + Format of strings relative to levels of inserts. For example, if + we were using the automated labels and had a depth of 2, one might + see something like "Fig {0}.{1}" for this paramter. We could then + see the output of "Fig 2.1" if both tags liked like ("1","1") + or ("0","0"). The default, if tags on auto-constructed, is + ("{0}", "{0}.{1}", "{0}.{1}.{2}", "{0}.{1}.{2}.{3}", ...) + tags_order : str ["auto", "input", "yokogaki"] + How we the tags. If auto, the default is by "input" if you provide + your own labels and "yokogaki" if you don't. "Input" means that the + tags ordering will be assoicated with the grobs ordering. + "Yokogaki" means that the tags will be associated with the + top-to-bottom, left-to-right ordering of the grobs. + tags_loc : str ["top", "left", "right", "bottom"] + Location of the tags relative to grob they are tagging. + tags_inherit : str ["fix", "override"] + Indicates how tagging should behave relative to plotential plot + hierachy. If "fix", then the associated cowpatch object and inner + objects won't inherits tags from parents. If "override", + parent tagging structure will pass through *and* override tagging + desires for this level. + + Notes + ----- + TODO: + Due to "tags_inherit", we need to allow updates. Potential solution + to removing values would be to have additions say "np.nan" instead of + None to clean something out. Annoyingly this would force updates of + of a dictionary - which will also need this functionality. + + Note this function doesn't track if the text object is the correct + class... maybe it should override it if it isn't the correct class? + """ + + # prep definition + self.title = dict() + self.subtitle = dict() + self.caption = None + self.tags = None + self.tags_order = None + self.tags_loc = None + self.tags_inherit = None + self.tags_format = None + self.tags_depth = -1 + + self._update_all_attributes(title=title, + subtitle=subtitle, + caption=caption, + tags=tags, + tags_format=tags_format, + tags_order=tags_order, + tags_loc=tags_loc, + tags_inherit=tags_inherit) + + def _clean_up_attributes(self): + """ + Examines all attributes and those that are np.nan are converted to None + """ + attributes = copy.deepcopy(self.__dict__) + + for key, value in attributes.items(): + if value is np.nan: + if key in ["title", "subtitle"]: + self.__dict__[key] = {} + else: + self.__dict__[key] = None + + + + + return None + + def _update_all_attributes(self, title=None, subtitle=None, caption=None, + tags=None, tags_format=None, tags_order="auto", + tags_loc=None, tags_inherit="fix"): + """ + Updates all attributes of self related to input values + + Arguments + --------- + title : str, cow.text, or dictionary + This contains information for the top level patch's titles, which + can appear on the top (dictionary key: "top"/"t"), left + ("left"/"l"), right ("right"/"r"), or bottom ("bottom"/"b"). One + can have multiple titles (e.g. one on top and one on the left). + subtitle : str, cow.text, or dictionary + This contains information for the top level patch's subtitles + (and appear below the titles). Like `title`, these can appear on + the top (dictionary key: "top"/"t"), left ("left"/"l"), + right ("right"/"r"), or bottom ("bottom"/"b"). One can have + multiple subtitles (e.g. one on top and one on the left). + caption : str or cow.text + Each patch can have 1 caption which is located below all other + elements when the patch is configured (i.e. below all plots, + titles, subtitles and tags). + tags : list or tuple + list or tuple. If one wants to define each labels name by hand, + use a list. If you would like to define labels in an automated + manner relative to the the hierachical storage structure, use a + tuple. The length of the tuple, at max should be the max depth of + the arangement of plots. Each value in the tuple can either be + a string specifying the general auto-index structure ("1" or "0", + "a", "A", "i", "I") OR it can be a list that acts like the auto + ordering for the given level. + tags_format : str or text + Format of strings relative to levels of inserts. For example, if + we were using the automated labels and had a depth of 2, one might + see something like "Fig {0}.{1}" for this paramter. We could then + see the output of "Fig 2.1" if both tags liked like ("1","1") + or ("0","0"). The default, if tags on auto-constructed, is + ("{0}", "{0}.{1}", "{0}.{1}.{2}", "{0}.{1}.{2}.{3}", ...) + tags_order : str ["auto", "input", "yokogaki"] + How we the tags. If auto, the default is by "input" if you provide + your own labels and "yokogaki" if you don't. "Input" means that the + tags ordering will be assoicated with the grobs ordering. + "Yokogaki" means that the tags will be associated with the + top-to-bottom, left-to-right ordering of the grobs. + tags_loc : str ["top", "left", "right", "bottom"] + Location of the tags relative to grob they are tagging. + tags_inherit : str ["fix", "override"] + Indicates how tagging should behave relative to plotential plot + hierachy. If "fix", then the associated cowpatch object and inner + objects won't inherits tags from parents. If "override", + parent tagging structure will pass through *and* override tagging + desires for this level. + + Returns + ------- + None - internally updates self + + Note + ---- + Value of np.nan removes current value and none doesn't update + """ + + # process title ------------- + self.title = self._update_tdict_info(t = title, + current_t = self.title, + _type = "title") + + # process subtitle ------------ + self.subtitle = self._update_tdict_info(t = subtitle, + current_t = self.subtitle, + _type = "subtitle") + + # process caption ---------- + # basic update + if caption is np.nan: + self.caption = np.nan + elif caption is not None: + if inherits(caption, str): + caption = text(label = caption) + caption._define_type(_type = "cow_caption") + + self.caption = caption + + # process tag information ----------- + ## tags + if tags is np.nan: + self.tags = np.nan + elif not inherits(tags, list) and not inherits(tags, tuple) and tags is not None: + raise ValueError("tags should be either a list or tuple") + elif inherits(tags, list): + self.tags = (tags) + elif tags is not None: + self.tags = tags + + ## tags_order + if tags_order is np.nan: + self.tags_order = "auto" + elif tags_order not in ["auto", "input", "yokogaki"]: + raise ValueError("tags_order can only take values None, \"auto\", "+\ + "\"yokogaki\", or \"input\" (or np.nan if you "+\ + "wish to revert to None with an update)") + else: + self.tags_order = tags_order + + if self.tags_order is None: + self.tags_order = "auto" + + ## tags_loc + if tags_loc is np.nan: + self.tags_loc = np.nan + elif tags_loc is not None: + self.tags_loc = tags_loc + + ## tags_inherit + if tags_inherit is np.nan: + self.tags_inherit = np.nan + elif tags_inherit is not None and (tags_inherit not in ["fix", "override"]): + raise ValueError("tags_inherit can only take values None, \"fix\" "+\ + "or \"override\" (or np.nan if you wish to revert "+\ + "to None with an update)") + elif tags_inherit is not None: + self.tags_inherit = tags_inherit + + if self.tags_inherit is None: # default value is fix + self.tags_inherit = "fix" + + # tags_format processing --------------------- + ## default tags_format + if tags_format is not None: # then we will be overriding... + if tags_format is np.nan: + if self.tags is None: + tags_format = np.nan + elif inherits(self.tags, list): + tags_format = ("{0}",) + else: + tags_format = tuple(_string_tag_format(x) + for x in np.arange(len(self.tags))) + elif (inherits(tags_format, str) or \ + inherits(tags_format, text)): + tags_format = (tags_format, ) + + ## making tags_format text objects + if tags_format is not None and tags_format is not np.nan: # if None/np.nan, then tags is also None + new_tags_format = [] + for e in tags_format: + if inherits(e, text): + e2 = copy.deepcopy(e) + e2._define_type(_type = "cow_tag") + new_tags_format.append(e2) + else: #e is string + e = text(label = e) + e._define_type(_type = "cow_tag") + new_tags_format.append(e) + new_tags_format = tuple(new_tags_format) + else: + new_tags_format = tags_format + + self.tags_format = new_tags_format + + # tags_depth definition -------------------- + if inherits(self.tags, list): + self.tags_depth = 0 + elif self.tags_format is None or self.tags_format is np.nan: + self.tags_depth = -1 + else: + self.tags_depth = len(self.tags_format) - 1 + + def _get_tag(self, index=0): + """ + Create text of tag for given level and index + + Arguments + --------- + index : tuple + tuple of integers that contain the relative level indices of the + desired tag. + + Returns + ------- + cow.text object for tag + + Notes + ----- + this should return objects relative to correct rotation... + """ + if inherits(index, int): + index = (index,) + + if len(self.tags_format) < len(index): + raise ValueError("tags_format tuple has less indices than _get_tag index suggests") + + indices_used = [int(re.findall("[0-9]+", x)[0]) + for x in re.findall("\{[0-9]+\}", + self.tags_format[len(index)-1].label)] + if np.max(indices_used) > len(index)-1: + raise ValueError("tags_format has more indices than the tag hierarchy has.") + + et = copy.deepcopy(self.tags_format[len(index)-1]) + et.label = et.label.format( + *[self._get_index_value(i,x) for i,x in enumerate(index)]) + + + + return et + + def _get_index_value(self, level=0, index=0): + """ + provide index level of a tag + + Arguments + --------- + level : int + level of depth of tag (0 is top level, 1 is first level below, etc.) + index : tuple + tuple of integers that contain the relative level indices of the + desired tag. + + """ + if len(self.tags) < level: + return "" + if inherits(self.tags[level], list): + if len(self.tags[level]) < index: + return "" + else: + return self.tags[level][index] + else: + return self._get_auto_index_value(index=index, + _type = self.tags[level]) + + def _get_auto_index_value(self, index=0, _type = ["0","1", "a", "A", "i","I"][0]): + """ + (Internal) get the index of a particular type of auto-index + + Arguments + --------- + index : int + 0-based index + _type : str + type of indexing + + Returns + ------- + out : str + index related to requested style + """ + + if _type == "0": + return str(index) + elif _type == "1": + return str(index+1) + elif _type == "a": + return _to_alphabet_representation(index+1) + elif _type == "A": + return _to_alphabet_representation(index+1, caps=True) + elif _type == "i": + return _to_roman_numerials(index+1) + elif _type == "I": + return _to_roman_numerials(index+1, caps=True) + else: + raise ValueError('type of auto-tags must be in '+\ + '["0","1", "a", "A", "i", "I"]') + + def _calculate_tag_margin_sizes(self, index=(0,), + fundamental=False, + to_inches=False): + """ + calculate tag's margin sizes + + Arguments + --------- + index : int or tuple + tuple of indices relative to the hierarchical ordering of the tag + fundamental : boolean + if the associated object being "tagged" is a fundamental object, + if not, a tag is only made if the tags_depth is at the final level. + to_inches : boolean + if the output should be converted to inches before returned + + Returns + ------- + min_desired_widths : float + extra_desired_widths : float + min_desired_heights : float + extra_desired_heights : float + + # TODO: needs to deal with rotation... + """ + + # clean-ups... + if not inherits(index, tuple): + index = (index, ) + + # if we shouldn't actually make the tag + if self.tags_depth != len(index) and not fundamental: + return [0],[0],[0],[0] + + # getting tag ------------------- + tag = self._get_tag(index = index) + tag_sizes = tag._min_size(to_inches=to_inches) + + min_desired_widths = [tag_sizes[0] * \ + (self.tags_loc in ["top", "bottom", "t", "b"])] + min_desired_heights = [tag_sizes[1] * \ + (self.tags_loc in ["left", "right", "l", "r"])] + + if True:#self.tags_type == 0: #outside of image box + extra_desired_widths = [tag_sizes[0] * \ + (self.tags_loc in ["left", "right", "l", "r"])] + extra_desired_heights = [tag_sizes[1] * \ + (self.tags_loc in ["top", "bottom", "t", "b"])] + # else: # inside image box + # extra_desired_widths, extra_desired_heights = [0],[0] + # min_desired_widths.append(tag_sizes[0] * \ + # (self.tags_loc in ["left", "right", "l", "r"])) + # min_desired_heights.append(tag_sizes[1] * \ + # (self.tags_loc in ["top", "bottom", "t", "b"])) + + return min_desired_widths, extra_desired_widths, \ + min_desired_heights, extra_desired_heights + + def _calculate_margin_sizes(self, to_inches=False): + """ + calculates marginal sizes needed to be displayed for titles + + Arguments + --------- + to_inches : boolean + if the output should be converted to inches before returned + + Returns + ------- + min_desired_widths : float + extra_desired_widths : float + min_desired_heights : float + extra_desired_heights : float + + TODO: need to make sure left/right objects are correctly rotated... + """ + min_desired_widths = \ + [t._min_size(to_inches=to_inches)[0] for t in [self.title.get("top"), + self.title.get("bottom"), + self.subtitle.get("top"), + self.subtitle.get("bottom"), + self.caption] + if t is not None] + extra_desired_widths = \ + [t._min_size(to_inches=to_inches)[0] for t in [self.title.get("left"), + self.title.get("right"), + self.subtitle.get("left"), + self.subtitle.get("right")] + if t is not None] + min_desired_heights = \ + [t._min_size(to_inches=to_inches)[1] for t in [self.title.get("left"), + self.title.get("right"), + self.subtitle.get("left"), + self.subtitle.get("right")] + if t is not None] + extra_desired_heights = \ + [t._min_size(to_inches=to_inches)[1] for t in [self.title.get("top"), + self.title.get("bottom"), + self.subtitle.get("top"), + self.subtitle.get("bottom"), + self.caption] + if t is not None] + + return min_desired_widths, extra_desired_widths, \ + min_desired_heights, extra_desired_heights + + + def _update_tdict_info(self, t, current_t = dict(), _type = "title"): + """ + Update or define title/subtitle + + Arguments + --------- + t : str, cow.text, or dictionary + This contains information for the top level patch's titles or subtitles, + which can appear on the top (dictionary key: "top"/"t"), left + ("left"/"l"), right ("right"/"r"), or bottom ("bottom"/"b"). One can + have multiple titles (e.g. one on top and one on the left). + current_t : dictionary + current title/subtitle dictionary to be updated. + _type : str ["title", "subtitle"] + string indicates if the t is a title or subtitle. + + Returns + ------- + an updated version of `current_t`. + + Notes: + ------ + A value of "np.nan" with remove any existing values, and a value of "None" + with do nothing to the current value. If one is trying to remove the "top" + title, but not all titles, they must use the dictionary structure (i.e. + "t = {top: np.nan}". + """ + + current_t = copy.deepcopy(current_t) + + if inherits(t, str): + t = {"top": copy.deepcopy(t)} + + if inherits(t, text): + t = {"top": copy.deepcopy(t)} + + if t is np.nan: + current_t = np.nan + t = None + + if t is not None: + if not inherits(t, dict): + raise ValueError(_type + " type isn't one of the expected types") + else: + text_keys = {"t":"top", "l":"left", "r":"right", "b":"bottom"} + for key, el in t.items(): + if key in text_keys.keys(): + key = text_keys[key] + if key not in text_keys.values(): + raise ValueError(_type +\ + (" dictionary key is not as expected (%s)" % key)) + + if el is np.nan: # remove current element if new element value is np.nan + current_val = current_t.get(key) + if current_val is not None: + current_t.pop(key) + elif el is not None: + if inherits(el, str): + el = text(label = el) + el._define_type(_type = "cow_" + _type) + + current_t[key] = el + + + return current_t + + + def _step_down_tags_info(self, parent_index): + """ + Create an updated version of tags_info for children + + Arguments + --------- + parent_index : int + integer associated with the child's tag index + + Returns + ------- + annotation with all tag attributes to update for children's + annotation. + """ + #TODO: likely can remove some of the other index stuff due to passing + + if len(self.tags) <= 1 or len(self.tags_format) <= 1: + return annotation() + + tags = self.tags[1:] + + # updating tags_format + old_tags_format = self.tags_format[1:] + + new_tags_format = [] + new_fillers = ["{"+str(i)+"}" for i in range(len(tags))] + + for t in old_tags_format: + new_t = copy.deepcopy(t) + current_index = self._get_index_value(0, parent_index) + new_t.label = new_t.label.format(current_index, *new_fillers) + + new_tags_format.append(new_t) + + tags_format = tuple(new_tags_format) + + inner_annotation = annotation(tags = tags, + tags_format = tags_format, + tags_order = self.tags_order, + tags_loc = self.tags_loc) + return inner_annotation + + + def __add__(self, other): + """ + update annotations through addition + + Arguments + --------- + other : annotation object + object to update current annotation object with + + Returns + ------- + updated self (annotation object) + """ + if not inherits(other, annotation): + raise ValueError("annotation can be only added with "+\ + "another annotation") + + self._update_all_attributes(title = other.title, + subtitle = other.subtitle, + caption = other.caption, + tags=other.tags, + tags_format=other.tags_format, + tags_order=other.tags_order, + tags_loc=other.tags_loc, + tags_inherit=other.tags_inherit) + + return self + + def __eq__(self, other): + """ + checks if two annotation objects are equal + + Arguments + --------- + other : annotation object + object to update current annotation object with + + Returns + ------- + boolean for equality (if second object is not an annotation object, + will return False) + """ + if not inherits(other, annotation): + return False + + return self.__dict__ == other.__dict__ + + + diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index 0e04616..373e6c3 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -2,13 +2,16 @@ import plotnine as p9 import matplotlib.pyplot as plt import svgutils.transform as sg +import copy from .svg_utils import gg_to_svg, _save_svg_wrapper, _show_image, \ _raw_gg_to_svg, _select_correcting_size_svg from .utils import to_inches, from_inches, inherits_plotnine, inherits, \ _flatten_nested_list from .layout_elements import layout +from .annotation_elements import annotation from .config import rcParams +from .text_elements import text import pdb @@ -115,6 +118,7 @@ def __init__(self, *args, grobs=None): self.grobs = grobs self.__layout = "patch" # this is different than None... + self.annotation = None @property def layout(self): @@ -210,7 +214,14 @@ def __add__(self, other): # combine with layout ------------- self.__layout = other elif inherits(other, annotation): - raise ValueError("currently not implimented addition with annotation") + if self.annotation is None: + other_copy = copy.deepcopy(other) + other_copy._clean_up_attributes() + self.annotation = other_copy + else: + final_copy = copy.deepcopy(self.annotation + other) + final_copy._clean_up_attributes() + self.annotation = final_copy return self @@ -220,6 +231,39 @@ def __mul__(self, other): def __and__(self, other): raise ValueError("currently not implimented &") + def _get_grob_tag_ordering(self): + """ + get ordering of tags related to grob index + + Returns + ------- + numpy array of tag order for each grob + + Note + ---- + This function leverages the patch's layout and annotation objects + """ + self._check_layout() + + if self.annotation is None or self.annotation.tags is None: + return np.array([]) + + tags_order = self.annotation.tags_order + if tags_order == "auto": + if inherits(self.annotation.tags,list): + tags_order = "input" + else: + tags_order = "yokogaki" + + + if tags_order == "yokogaki": + return self.layout._yokogaki_ordering(num_grobs = len(self.grobs)) + elif tags_order == "input": + return np.arange(len(self.grobs)) + else: + raise ValueError("patch's annotation's tags_order is not an expected option") + + def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): """ Internal function to create an svg representation of the patch @@ -244,29 +288,11 @@ def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): self._check_layout() - if num_attempts is None: - num_attempts = rcParams["num_attempts"] - # examine if sizing is possible and update or error if not # -------------------------------------------------------- if sizes is None: # top layer - #pdb.set_trace() - while num_attempts > 0: - sizes, logics = self._svg_get_sizes(width_pt=width_pt, - height_pt=height_pt) - out_info = self._process_sizes(sizes, logics) + sizes = self._svg_get_sizes(width_pt, height_pt) - if type(out_info) is list: - num_attempts = -412 # strictly less than 0 - else: # out_info is a scaling - width_pt = width_pt*out_info - height_pt = height_pt*out_info - - num_attempts -= 1 - - if num_attempts == 0: - raise StopIteration("Attempts to find the correct sizing of inner"+\ - "plots failed with provided parameters") layout = self.layout @@ -281,6 +307,7 @@ def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): base_image.root.set("viewBox", "0 0 %s %s" % (str(width_pt), str(height_pt))) # TODO: way to make decisions about the base image... + # TODO: this should only be on the base layer... base_image.append( sg.fromstring("")) @@ -311,86 +338,154 @@ def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): return base_image, (width_pt, height_pt) - def _size_dive(self, parents_areas=None): + def _estimate_default_min_desired_size(self, parent_index=()): """ - (Internal) calculate a suggested overall size that ensures a minimum - width and height of the smallest inner plot + (Internal) Estimate default height and width of full image so that + inner objects (plots, text, etc.) have a minimum desirable size + and all titles are fully seen. Arguments --------- - parents_areas : list - list of parent's/parents' areas. If value is None it means element - has no parents + parent_index : int + parent level index information Returns ------- - suggested_width : float or list - proposed width for overall size if parents_areas=None, else this - is a list of relative proportion of widths of images (relative to - the global width taking a value of 1) - suggested_height : float or - proposed height for overall size if parents_areas=None, else this - is a list of relative proportion of height of images (relative to - the global height taking a value of 1) - depth : int or list - maximum depth of structure if parents_areas=None, else this - is a list of inner elements. - + suggested_width : float + proposed width for overall size (inches) + suggested_height : float + proposed height for overall size (inches) Notes ----- - The default rcParams are: + ***Function's logic***: + In order to propose a minimum sizing to an arrangement we take two + things into account. Specially, + + 1. we assure that each plot's height & width are greater then or equal + to a minimum plot height and width (For text objects treated as plots, + we just assure they are not truncated, with no minimize sizing is + enforced). + 2. and for titles (,subtitles, captions,) and tags, we ensure that + they will not be truncated based on the sizing of the plot/arrangement + they are associated with. + + To accomplish this second goal, we track different dimensions of these + text labeling objects. For clarity, define (1) a text object’s + *typographic length* as the maximum width of all lines of and with + each line’s text’s baseline being horizontal and a text object’s + *typographic overall height* as the distance between the top “line” of + text’s ascender line and the bottom "line" of text's descender line + `reference `_, + and (2) a *bounding box* for any text object is the minimum sized box + that can contain the text object. This bounding box has a *height* and + *width* which is naturally impacted by the orientation of baseline of + the text. + + + For titles (,subtitles, captions,) and tags of tag_type = 0 (aka the + tag is drawn outside the underlying plot/arrangement's space), we do + the following: + + A. we make sure the associated plot/arangement’s requested height and + weight ensures that the text object's *typographic length* is not + truncated. To do so we assure that the plot-specific height and width + being larger than max(image_min_height_from_objective_1, select_bbox_heights)) + and max(image_min_width_from_objective_1,select_bbox_widths)). A text + element contributes to the select_bbox_heights if it has left or right + alignment and to select_bbox_widths if it has top or bottom alignment. + B. we make sure the "global" location for said inner plot/arrangement + and text greater than or equal to the the plot’s minimum sizing + defined in (A) plus a selected_cumulative_extra_bbox_height and _width. + A text element contributes to the selected_cumulative_extra_bbox_height + if it has top or bottom alignment and to + selected_cumulative_extra_bbox_width if it has left or right + alignment. Note that different types of titles and tags contribute to + this valve in an addition sense. + + If a tag has tag_type=1, then all it's bounding box’s attributes + contribute to the dimensions described in (A). + + ***Additional info:*** + The default rcParams are (in inches): base_height = 3.71, base_aspect_ratio = 1.618 # the golden ratio - This follows ideas proposed in cowplot: `wilkelab.org/cowplot/reference/save_plot.html `_. + The idea of a minimum size for a subplot follows ideas proposed in + cowplot: `wilkelab.org/cowplot/reference/save_plot.html `_. + """ - # basically following: https://wilkelab.org/cowplot/reference/save_plot.html + min_image_height = rcParams["base_height"] min_image_width = rcParams["base_aspect_ratio"] * min_image_height + ## dealing with titles + if self.annotation is None: + min_desired_widths = [] + extra_desired_widths = [] + min_desired_heights = [] + extra_desired_heights = [] + else: + min_desired_widths, extra_desired_widths, \ + min_desired_heights, extra_desired_heights = \ + self.annotation._calculate_margin_sizes(to_inches=True) - image_rel_widths = [] - image_rel_heights = [] - depth = [] - + # inner objects areas = self.layout._element_locations(width_pt=1, height_pt=1, num_grobs = len(self.grobs)) - if parents_areas is None: - parents_areas = [] - rel_width_to_parents = 1 - rel_height_to_parents = 1 + if (self.annotation is not None) and \ + (self.annotation.order_type() == "yokogaki"): + grob_tag_ordering = self.layout._yokogaki_ordering( + num_grobs=len(self.grobs)) else: - rel_width_to_parents = np.prod([a.width for a in parents_areas]) - rel_height_to_parents = np.prod([a.height for a in parents_areas]) + grob_tag_ordering = np.arange(len(self.grobs)) - parent_depth = len(parents_areas) for g_idx in np.arange(len(self.grobs)): inner_area = areas[g_idx] + current_index = tuple(list(parent_index) + \ + [grob_tag_ordering[g_idx]]) + ### tag sizing ----------- + if self.annotation is not None: + tag_min_desired_widths, tag_extra_desired_widths, \ + tag_min_desired_heights, tag_extra_desired_heights = \ + self.annotation._calculate_tag_margin_sizes( + index = current_index, + fundamental = inherits(self.grobs[g_idx], patch), + to_inches=True) + else: + tag_min_desired_widths, tag_extra_desired_widths, \ + tag_min_desired_heights, tag_extra_desired_heights = [0],[0],[0],[0] + + ### object sizing ------------- if inherits_plotnine(self.grobs[g_idx]): - inner_rw = [inner_area.width * rel_width_to_parents] - inner_rh = [inner_area.height * rel_height_to_parents] - inner_d = [parent_depth + 1] - else: - inner_rw, inner_rh, inner_d =\ - self.grobs[g_idx]._size_dive(parents_areas=parents_areas+\ - [inner_area]) - image_rel_widths += inner_rw - image_rel_heights += inner_rh - depth += inner_d - - if len(parents_areas) == 0: - return (min_image_width/np.min(image_rel_widths), - min_image_height/np.min(image_rel_heights), - np.max(depth)) - else: - return (image_rel_widths, - image_rel_heights, - depth) + inner_width = min_image_width + inner_height = min_image_height + elif inherits(self.grobs[g_idx], text): + inner_width, inner_height = self.grobs[g_idx]._min_size(to_inches=True) + else: # patch object... + inner_width, inner_height = \ + self.grobs[g_idx]._estimate_default_min_desired_size(parent_index=current_index) + + + min_desired_widths.append((np.max([inner_width]+tag_min_desired_widths) +\ + tag_extra_desired_widths)*\ + 1/inner_area.width) + min_desired_heights.append((np.max([inner_height]+tag_min_desired_heights) +\ + tag_extra_desired_heights) *\ + 1/inner_area.height) + + + # wrap it all up (zeros added to allow for empty sets) + min_desired_width = np.max(min_desired_widths+[0]) +\ + np.sum(extra_desired_widths+[0]) + min_desired_height = np.max(min_desired_heights+[0]) +\ + np.sum(extra_desired_heights+[0]) + + return min_desired_width, min_desired_height def _default_size(self, width, height): """ @@ -419,7 +514,7 @@ def _default_size(self, width, height): """ both_none = False if width is None or height is None: - _width, _height, _ = self._size_dive() + _width, _height = self._estimate_default_min_desired_size()#self._size_dive() if width is None and height is None: both_none = True width = _width @@ -430,7 +525,7 @@ def _default_size(self, width, height): width = _width / _height * height return width, height - def _svg_get_sizes(self, width_pt, height_pt): + def _inner_svg_get_sizes(self, width_pt, height_pt, tag_parent_index=()): """ (Internal) Calculates required sizes for plot objects to meet required sizes and logics if the requested sizing was possible @@ -441,6 +536,9 @@ def _svg_get_sizes(self, width_pt, height_pt): overall width of the image in points height_pt : float overall height of the image in points + tags_parent_index : tuple + tuple of integers that contain the relative level indices of the + desired tag for parent. Returns ------- @@ -452,11 +550,14 @@ def _svg_get_sizes(self, width_pt, height_pt): the requested width (or height) w.r.t. the returned width (or height) from saving the plotnine object. The later option occurs when when the ggplot's sizing didn't converge to the - desired size. + desired size. These sizing do not contain information about + annotations (but implicitly reflect them existing) logics : nested list For each element in the patch (with nesting structure in the list), this contains a boolean value if the ggplot object was able to be correctly size. + sizes_text : nested list + logics_text : nested list Notes ----- @@ -472,17 +573,17 @@ def _svg_get_sizes(self, width_pt, height_pt): sizes = [] logics = [] + sizes_annotation = [] + logics_annotation = [] for p_idx in np.arange(len(self.grobs)): inner_area = areas[p_idx] - inner_width_pt = inner_area.width inner_height_pt = inner_area.height - # TODO: how to deal with ggplot objects vs patch objects if inherits(self.grobs[p_idx], patch): inner_sizes_list, logic_list = \ - self.grobs[p_idx]._svg_get_sizes(width_pt = inner_width_pt, + self.grobs[p_idx]._inner_svg_get_sizes(width_pt = inner_width_pt, height_pt = inner_height_pt) sizes.append(inner_sizes_list) logics.append(logic_list) @@ -502,12 +603,42 @@ def _svg_get_sizes(self, width_pt, height_pt): throw_error=False) sizes.append((inner_w,inner_h)) logics.append(inner_logic) + elif inherits(self.grobs[p_idx], text): + raise ValueError("TODO!") else: raise ValueError("grob idx %i is not a patch object nor"+\ "a ggplot object" % p_idx) return sizes, logics + def _svg_get_sizes(self, width_pt, height_pt): + """ + captures backend process of making sure the sizes requested can be + provided with the actual images... + """ + + if num_attempts is None: + num_attempts = rcParams["num_attempts"] + + while num_attempts > 0: + sizes, logics = self._svg_get_sizes(width_pt=width_pt, + height_pt=height_pt) + out_info = self._process_sizes(sizes, logics) + + if type(out_info) is list: + num_attempts = -412 # strictly less than 0 + else: # out_info is a scaling + width_pt = width_pt*out_info + height_pt = height_pt*out_info + + num_attempts -= 1 + + if num_attempts == 0: + raise StopIteration("Attempts to find the correct sizing of inner"+\ + "plots failed with provided parameters") + + return sizes + def _process_sizes(self, sizes, logics): """ (Internal) draw conclusions about the output of _svg_get_sizes. @@ -676,6 +807,7 @@ def __str__(self): def __repr__(self): out = "num_grobs: " + str(len(self.grobs)) +\ - "\n---\nlayout:\n" + self.layout.__repr__() + "\n---\nlayout:\n" + self.layout.__repr__()+\ + "\n---\nannotation:\n" + self.annotation.__repr__() return "" % self.__hash__() + "\n" + out diff --git a/src/cowpatch/config.py b/src/cowpatch/config.py index 68e98f5..3dd958f 100644 --- a/src/cowpatch/config.py +++ b/src/cowpatch/config.py @@ -1,3 +1,5 @@ +import plotnine as p9 + rcParams = dict(maxIter=20, min_size_px=10, eps=1e-2, @@ -8,7 +10,26 @@ num_attempts=2, base_height=3.71, - base_aspect_ratio=1.618 # the golden ratio + base_aspect_ratio=1.618, # the golden ratio + + cow_text=p9.element_text( + family=p9.options.get_option("base_family"), + style='normal', + color='black', + size=1.2*11, + linespacing=0.9, + ha='center', + va='center', + rotation=0, + margin={}), + cow_tag=p9.element_text(size=1.2*11, + ha="left"), + cow_title=p9.element_text(size=1.2*11, + ha="left"), + cow_subtitle=p9.element_text(size=11, + ha="left"), + cow_caption=p9.element_text(size=.8*11, + ha="right"), ) """ diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index 40dc237..ec18563 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -1,4 +1,6 @@ import plotnine as p9 +from plotnine.themes.themeable import themeable +from plotnine.themes.theme import theme import numpy as np import copy import matplotlib.pyplot as plt @@ -12,12 +14,12 @@ import pdb from .utils import to_pt, from_pt, to_inches, from_inches, \ - _transform_size_to_pt + _transform_size_to_pt, inherits from .svg_utils import _show_image, _save_svg_wrapper from .config import rcParams class text: - def __init__(self, label, element_text=None): + def __init__(self, label, element_text=None, _type = "cow_text"): """ create a new text object @@ -34,10 +36,14 @@ def __init__(self, label, element_text=None): ^ on text vs paths for text in mathplotlib """ self.label = label - self._type = "text" + self._type = _type self.element_text = None # prep initialization + self._theme = p9.theme() self._clean_element_text(element_text) + def _define_type(self, _type): + self._type = _type + def _clean_element_text(self, element_text): """ cleans element_text to make sure any element_text() is a @@ -68,6 +74,12 @@ def _clean_element_text(self, element_text): else: self.element_text = element_text + # keeping theme update tracking + if self.element_text is not None: + self._theme += p9.theme( + **{self._type: self.element_text.theme_element}) + + return None # just a reminder def __add__(self, other): @@ -126,6 +138,25 @@ def __add__(self, other): # self._clean_element_text(new_element_text) return new_object + def __eq__(self, other): + """ + Check if current object is equal to another + + Arguments + --------- + other : other object, assumed to be text object + + Returns + ------- + boolean if equivalent + """ + + if not inherits(other, text): + return False + + return self.__dict__ == other.__dict__ + + def _update_element_text_from_theme(self, theme, key=None): """ Internal function to update .element_text description from a theme @@ -159,37 +190,53 @@ def _update_element_text_from_theme(self, theme, key=None): else: self.element_text = new_et + self._theme += theme + self._clean_element_text(self.element_text) + return None # just a reminder def _get_full_element_text(self): """ create a "full" element_text from base p9 theme + + Notes + ----- + This function will update the element_text *only with* attributes + that aren't currently defined (using the cow.theme_get() function) """ new_self = copy.deepcopy(self) - new_self._update_element_text_from_theme(p9.theme_get()) - + new_self._update_element_text_from_theme(theme_get() + self._theme, # don't want to actually update self.element_text or self._theme + key=self._type) if self.element_text is not None: new_self.element_text.merge(self.element_text) - return new_self.element_text + # full set of structure inherited from cow_text - TODO: smarter approach mirroring p9 functionality? + if self._type != "cow_text": # assume inherits properties + cow_text_et = new_self._theme.themeables.get("cow_text") + cow_text_et.merge(new_self.element_text) + return cow_text_et + else: + return new_self.element_text + - def _min_size(self): + + def _min_size(self, to_inches=False): """ - calculate minimum size of bounding box around self in pt(?) and + calculate minimum size of bounding box around self in pt and creates base image of text object Arguments --------- - close : boolean - if we should close the plot after we find minimum size. + to_inches : boolean + if the output should be converted to inches before returned Returns ------- - min_width_pt : float - minimum width for text (pt) - min_height_pt : float - minimum height for text (pt) + min_width : float + minimum width for text in pt (or inches if `to_inches` is True) + min_height: float + minimum height for text in pt (or inches if `to_inches` is True) Note ---- @@ -229,7 +276,15 @@ def _min_size(self): plt.close() - return min_width_pt, min_height_pt + if to_inches: + min_width = to_inches(min_width_pt, units = "pt") + min_height = to_inches(min_height_pt, units = "pt") + else: + min_width = min_width_pt + min_height = min_height_pt + + + return min_width, min_height def _base_text_image(self, close=True): """ @@ -308,7 +363,7 @@ def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): --------- width_pt : float width of desired output (in pt) - height_t : float + height_pt : float height of desired output (in pt) sizes: TODO: write description & code up num_attempts : TODO: write description & code up @@ -355,7 +410,7 @@ def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): # ^this won't be width or min_width_pt related # location correction for alignment and margins ------- - current_element_text = self._get_full_element_text() # + current_element_text = self._get_full_element_text() ha_str = current_element_text.properties.get("ha") va_str = current_element_text.properties.get("va") margin_dict = current_element_text.properties.get("margin") @@ -550,7 +605,122 @@ def __str__(self): return "" % self.__hash__() def __repr__(self): - out = "\n_type: " + self._type +\ - "\nlabel:\n" + " |" + re.sub("\n", "\n |", self.label) +\ - "\nelement_text:\n |" + self.element_text.__repr__() + out = "label:\n" + " |" + re.sub("\n", "\n |", self.label) +\ + "\nelement_text:\n |" + self.element_text.__repr__() +\ + "\n_type:\n |\"" + self._type +"\"" + return "" % self.__hash__() + "\n" + out + + def __hash__(self): + return hash(tuple(self.__dict__)) + +class cow_title(themeable): + """ + text class for cow.patch titles + + Parameters + ---------- + theme_element : element_text + """ + pass + +class cow_subtitle(themeable): + """ + text class for cow.patch subtitles + + Parameters + ---------- + theme_element : element_text + """ + pass + +class cow_caption(themeable): + """ + text class for cow.patch captions + + Parameters + ---------- + theme_element : element_text + """ + pass + +class cow_tag(themeable): + """ + text class for cow.patch tags + + Parameters + ---------- + theme_element : element_text + """ + pass + +class cow_text(cow_title, cow_subtitle, cow_caption, cow_tag): + """ + default text for cow.text object + + Parameters + ---------- + theme_element : element_text + """ + + @property + def rcParams(self): + rcParams = super().rcParams + + family = self.properties.get('family') + style = self.properties.get('style') + weight = self.properties.get('weight') + size = self.properties.get('size') + color = self.properties.get('color') + + if family: + rcParams['font.family'] = family + if style: + rcParams['font.style'] = style + if weight: + rcParams['font.weight'] = weight + if size: + rcParams['font.size'] = size + rcParams['xtick.labelsize'] = size + rcParams['ytick.labelsize'] = size + rcParams['legend.fontsize'] = size + if color: + rcParams['text.color'] = color + + return rcParams + + + +def theme_get(): + """ + Creates a theme that contains defaults for cow related themeables + added to a p9.theme_get if needed. + + From plotnine: + + The default theme is the one set (using :func:`theme_set`) by + the user. If none has been set, then :class:`theme_gray` is + the default. + """ + return theme_complete(p9.theme_get()) + + +def theme_complete(other): + """ + a object that can be right added to another p9.theme and + will complete this p9.theme (relative to cow's default rcParms) + + """ + base = copy.deepcopy(other) + for cow_key in ["cow_text", "cow_title", "cow_subtitle", + "cow_caption", "cow_tag"]: + if cow_key not in base.themeables.keys() and \ + cow_key in rcParams.keys(): + base += theme(**{cow_key: rcParams[cow_key]}) + else: + current_et = base.themables[cow_key] + updated_et = rcParams[cow_key].merge(current_et) + base += theme(**{cow_key: updated_et}) + return base + + diff --git a/src/cowpatch/utils.py b/src/cowpatch/utils.py index ce74457..347d339 100644 --- a/src/cowpatch/utils.py +++ b/src/cowpatch/utils.py @@ -169,6 +169,7 @@ def inherits_plotnine(other): return "plotnine" == parent + def _transform_size_to_pt(size_string_tuple): """ takes string with unit and converts it to a float w.r.t pt @@ -238,3 +239,75 @@ def _flatten_nested_list(x): else: out.append(xi) return out + + +_arabic_roman_map = [(1000, 'M'), (900, 'CM'), (500, 'D'), (400, 'CD'), (100, 'C'), (90, 'XC'), + (50, 'L'), (40, 'XL'), (10, 'X'), (9, 'IX'), (5, 'V'), (4, 'IV'), (1, 'I')] +def _to_roman_numerials(x, caps = False): + """ + Arguments + --------- + x : int + arabic numeral + caps : boolean + if output should be capitalized + + Returns + ------- + out : str + roman numeral + """ + # from https://stackoverflow.com/a/40274588 + out = '' + + if x > 3999: + raise ValueError("current implimentation only works for input <= 3999") + + while x > 0: + for i, r in _arabic_roman_map: + while x >= i: + out += r + x -= i + + if caps: + return out + else: + return out.lower() + +_alphabet = "abcdefghijklmnopqrstuvwxyz" +def _to_alphabet_representation(x, caps = False): + """ + Arguments + --------- + x : int + arabic numeral + caps : boolean + if output should be capitalized + + Returns + ------- + out : str + alphabet numeric representation + """ + out = '' + while x > 0: + r = x % 26 + out = _alphabet[r-1] + out + x = x // 26 + + if caps: + return out.upper() + else: + return out + +def _string_tag_format(max_level=0): + out = '{0}' + level = 1 + while level <= max_level: + out += ".{"+str(level)+"}" + level +=1 + + return out + + + diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py new file mode 100644 index 0000000..1488614 --- /dev/null +++ b/tests/test_annotation_elements.py @@ -0,0 +1,424 @@ +import cowpatch as cow +import numpy as np +import pandas as pd +import copy +import plotnine as p9 +import plotnine.data as p9_data + +import pytest + +def test__update_tdict_info(): + """ + tests annotation._update_tdict_info function + """ + random_an = cow.annotation() + # without a current value -------------------------------------------------- + ## title + + out_dict = random_an._update_tdict_info(t = "banana", + _type = "title") + + el_expected = cow.text("banana") + el_expected._define_type("cow_title") + + top_title = out_dict.pop("top") + + assert len(out_dict) == 0 and \ + top_title == el_expected, \ + "addition of just a top title does not return a title dictionary as "+\ + "expected (no current value)" + + ## subtitle + out_dict = random_an._update_tdict_info(t = "banana", + _type = "subtitle") + + el_expected = cow.text("banana") + el_expected._define_type("cow_subtitle") + + top_subtitle = out_dict.pop("top") + + assert len(out_dict) == 0 and \ + top_subtitle == el_expected, \ + "addition of just a top subtitle does not return a title dictionary "+\ + "as expected (no current value)" + + + # updating with np.nan vs None ------------------------- + current_t = {"top": "banana"} + bottom_title_start = cow.text("apple") + + ## title ----------- + ### base + bottom_title_start._define_type("cow_title") + current_t2 = {"top": "banana", "bottom": bottom_title_start, "left": "left", + "right": "right"} + + out_dict = random_an._update_tdict_info(t = "pear", + current_t = current_t, + _type = "title") + out_dict2 = random_an._update_tdict_info(t = {"bottom":"pear"}, + current_t = current_t2, + _type = "title") + el_expected = cow.text("pear") + el_expected._define_type("cow_title") + + top_title = out_dict.pop("top") + + assert len(out_dict) == 0 and \ + top_title == el_expected, \ + "addition of just a top title does not return a title dictionary "+\ + "as expected (override current value)" + + bottom_title = out_dict2.pop("bottom") + + assert len(out_dict2) == 3 and \ + bottom_title == el_expected, \ + "addition of just a bottom title does not return a title dictionary "+\ + "as expected (override current value, other dimensions exist)" + + + ### None + out_dict = random_an._update_tdict_info(t = None, + current_t = current_t, + _type = "title") + + assert out_dict == current_t, \ + "if t is None, then we should expect no change to current_t" + + out_dict2 = random_an._update_tdict_info(t = {"top": None, + "bottom":"pear"}, + current_t = current_t2, + _type = "title") + current_t2_copy = current_t2.copy() + bottom_title = out_dict2.pop("bottom") + top_title = out_dict2.pop("top") + el_top_expected = current_t2_copy.pop("top") + + assert bottom_title == el_expected and \ + top_title == el_top_expected, \ + "if a title in dictionary is None, then we should expect no change "+\ + "to relative to current_t" + + ### np.nan + out_dict = random_an._update_tdict_info(t = np.nan, + current_t = current_t, + _type = "title") + + assert out_dict is np.nan, \ + "if title is np.nan, we expect the title to be stored as np.nan" + + + out_dict2 = random_an._update_tdict_info(t = {"top": np.nan, + "bottom":"pear"}, + current_t = current_t2, + _type = "title") + bottom_title = out_dict2.pop("bottom") + top_title = out_dict2.get("top") + + assert top_title is None and \ + bottom_title == el_expected and \ + len(out_dict2) == 2,\ + "if title in dictionary is np.nan, we expect the title to be erased "+\ + "(other updates should still happen)" + + ## subtitle ----- + ### base + bottom_title_start._define_type("cow_subtitle") + current_t2 = {"top": "banana", "bottom": bottom_title_start, "left": "left", + "right": "right"} + + out_dict = random_an._update_tdict_info(t = "pear", + current_t = current_t, + _type = "subtitle") + out_dict2 = random_an._update_tdict_info(t = {"bottom":"pear"}, + current_t = current_t2, + _type = "subtitle") + el_expected = cow.text("pear") + el_expected._define_type("cow_subtitle") + + top_title = out_dict.pop("top") + + assert len(out_dict) == 0 and \ + top_title == el_expected, \ + "addition of just a top subtitle does not return a subtitle dictionary "+\ + "as expected (override current value)" + + bottom_title = out_dict2.pop("bottom") + + assert len(out_dict2) == 3 and \ + bottom_title == el_expected, \ + "addition of just a bottom subtitle does not return a subtitle dictionary "+\ + "as expected (override current value, other dimensions exist)" + + + ### None + out_dict = random_an._update_tdict_info(t = None, + current_t = current_t, + _type = "subtitle") + + assert out_dict == current_t, \ + "if t is None, then we should expect no change to current_t" + + out_dict2 = random_an._update_tdict_info(t = {"top": None, + "bottom":"pear"}, + current_t = current_t2, + _type = "subtitle") + current_t2_copy = current_t2.copy() + bottom_title = out_dict2.pop("bottom") + top_title = out_dict2.pop("top") + el_top_expected = current_t2_copy.pop("top") + + assert bottom_title == el_expected and \ + top_title == el_top_expected, \ + "if a subtitle in dictionary is None, then we should expect no change "+\ + "to relative to current_t" + + ### np.nan + out_dict = random_an._update_tdict_info(t = np.nan, + current_t = current_t, + _type = "subtitle") + assert out_dict is np.nan, \ + "if subtitle is np.nan, we expect the subtitle to be np.nan" + + + out_dict2 = random_an._update_tdict_info(t = {"top": np.nan, + "bottom":"pear"}, + current_t = current_t2, + _type = "subtitle") + bottom_title = out_dict2.pop("bottom") + top_title = out_dict2.get("top") + + assert top_title is None and \ + bottom_title == el_expected and \ + len(out_dict2) == 2,\ + "if subtitle in dictionary is np.nan, we expect the subtitle to be erased "+\ + "(other updates should still happen)" + +def test__add__(): + """ + test addition update for annotation objects + """ + + a1_input_full = dict(title = "banana", + subtitle = {"bottom":cow.text("apple")}, + caption = "peeler", + tags = ["core", "slice"], + tags_format = ("Fig {0}"), + tags_order = "auto", + tags_loc = "top", + tags_inherit="fix") + + a_update_options = dict(title = {"top":"alpha"}, + subtitle = {"left":cow.text("beta")}, + caption = "gamma", + tags = ["delta", "epsilon"], + tags_format = ("Figure {0}"), + tags_order = "input", + tags_loc = "bottom", + tags_inherit="fix") + + a1_full = cow.annotation(**a1_input_full) + + for inner_key in a_update_options.keys(): + if inner_key != "subtitle": + a2_input = a1_input_full.copy() + a2_input[inner_key] = a_update_options[inner_key] + else: + a2_input = a1_input_full.copy() + a2_input[inner_key] = {"bottom":cow.text("apple"), + "left":cow.text("beta")} + + update_dict = {inner_key: a_update_options[inner_key]} + + a1_inner = copy.deepcopy(a1_full) + + a2_expected = cow.annotation(**a2_input) + + a1_updated = a1_inner + cow.annotation(**update_dict) + + assert a2_expected == a1_updated, \ + ("updating with addition failed to perform as expected (attribute %s)" % inner_key) + +def test__get_tag(): + mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), + tags_loc = "top") + + assert mya._get_tag(0) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), tuple" + + assert mya._get_tag((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 1)" + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2,3)) + # can't obtain a tag when we don't have formats that far down + +def test__get_tag_rotations(): + """ + test that ._get_tag works correctly with rotation informatin + + """ + + mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), + tags_loc = "left") + + assert mya._get_tag(0) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), tuple" + + assert mya._get_tag((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 1)" + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2,3)) + # can't obtain a tag when we don't have formats that far down + +def test__step_down_tags_info(): + junk_dict = dict(title = {"top":cow.text("banana", _type = "cow_title")}, + caption = "minion") + tag_dict = dict(tags = (["apple", "pear"], "i", "a"), + tags_format = ("", "{0}: {1}", "{0}: {1}.{2}"), + tags_order="auto", tags_loc="top", + tags_inherit="override") + + partial_a = cow.annotation(**tag_dict) + full_a = cow.annotation(**junk_dict) + partial_a + + stepdown_ann1 = full_a._step_down_tags_info(parent_index = 1) + stepdown_ann2 = partial_a._step_down_tags_info(parent_index = 1) + + assert stepdown_ann1 == stepdown_ann2, \ + "stepdown should pass only tag information" + + stepdown_tag_dict = dict(tags = ("i", "a"), + tags_format = ("pear: {0}", "pear: {0}.{1}"), + tags_order = "auto", + tags_loc = "top") + + stepdown_tag_expected = cow.annotation(**stepdown_tag_dict) + + assert stepdown_tag_expected == stepdown_ann2, \ + "stepdown with list for first layer incorrect" + + stepdown_ann_second = stepdown_ann2._step_down_tags_info(parent_index=5) + + stepdown_tag_dict_second = dict(tags = ("a",), + tags_format = ("pear: vi.{0}",), + tags_order = "auto", + tags_loc = "top") + + stepdown_tag_second_expected = cow.annotation(**stepdown_tag_dict_second) + + assert stepdown_tag_second_expected == stepdown_ann_second, \ + "stepdown with roman numeral iteration for second layer incorrect" + +def test_annotation_passing(): + """ + tests that annotation tag's pass annotations correctly + + these are stastic test-driven development tests + """ + g0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.labs(title = 'Plot 0') + + g1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 1') + + g2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 2') + + g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", + "suv", + "pickup"])]) +\ + p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ + p9.facet_wrap("class") + + # vis 0 + + vis_inner0 = cow.patch([g2, g3]) +\ + cow.layout(nrow = 1) +\ + cow.annotation(tags = ["banana", "apple"]) + + vis0 = cow.patch([g0,g1, vis_inner0]) +\ + cow.layout(design = np.array([[0,1], + [2,2]])) +\ + cow.annotation(tags = ("Fig {0}", "Fig {0}.{1}"), + tags_format = ("1", "a"), + title = "no passing through") + # see docs for expectation... + + # vis 1 + + vis_inner1 = vis_inner0 + cow.annotation(tags_inherit = "override") + + vis1 = cow.patch([g0,g1, vis_inner1]) +\ + cow.layout(design = np.array([[0,1], + [2,2]])) +\ + cow.annotation(tags = ("Fig {0}", "Fig {0}.{1}"), + tags_format = ("1", "a"), + title = "passing through") + # see docs for expectation... + + # vis 2 + + vis_inner2 = vis_inner0 + cow.annotation(tags_inherit = "fix", + tags = ("Fig 3.{0}",), + tags_format = ("a",)) + + vis2 = cow.patch([g0,g1, vis_inner2]) +\ + cow.layout(design = np.array([[0,1], + [2,2]])) +\ + cow.annotation(tags = ["Fig 1", "Fig 2"], + tags_format = ("1", "a"), + title = "no pass through, top defined, bottom generated") + # see docs for expectation... + +def test__clean_up_attributes(): + """ + test _clean_up_attributes + """ + + + a = cow.annotation(tags = np.nan, tags_order = "input") + b = cow.annotation(tags = None, tags_order = "input") + + a._clean_up_attributes() + + assert a == b, \ + "we expect that np.nans are cleaned up after running the "+\ + "_clean_up_attributes function" + + for key in a.__dict__.keys(): + if key not in ["tags_inherit", "tags_order", "tags_depth"]: + a2 = cow.annotation(**{key: np.nan}) + b2 = cow.annotation(**{key: None}) + + assert a2 != b2 and a2.__dict__[key] != b2.__dict__[key], \ + "before running _clean_up_attributes function, we expect "+\ + "the difference in specific key value to be not equal... " +\ + "(key = %s)" % key + + a2._clean_up_attributes() + + assert a2 == b2, \ + "we expect that np.nans are cleaned up after running the "+\ + "_clean_up_attributes function " +\ + "(key = %s)" % key + + + + diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index d1e8b9c..ca8fdd8 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -63,7 +63,7 @@ def test_patch__init__(): assert len(mypatch_args2.grobs) == 3, \ "grobs can be passed through the grobs parameter indirectly" -def test_patch__size_dive(): +def test_patch__estimate_default_min_desired_size_NoAnnotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ p9.labs(title = 'Plot 0') @@ -89,8 +89,8 @@ def test_patch__size_dive(): rel_heights = [1,2]) - sug_width, sug_height, max_depth = \ - vis1._size_dive() + sug_width, sug_height = \ + vis1._estimate_default_min_desired_size() assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images @@ -103,56 +103,6 @@ def test_patch__size_dive(): cow.rcParams["base_height"])), \ "suggested height incorrectly sizes the smallest height of the images (v1)" - assert max_depth == 1, \ - "expected depth for basic cow.patch (of depth 1) is incorrect (v1)" - - - # of note: the internal uses "pt", but they're actually defined relatively... - image_rel_widths, image_rel_heights, depths = \ - vis1._size_dive(parents_areas=[cow.area(width=1/6, - height=1/6, - x_left=0, - y_top=0, - _type="pt")]) - - assert np.allclose(image_rel_widths, [.5*1/6]*3), \ - "expected widths if input into a smaller image incorrect "+\ - "(v1.1, rel width to top 1/6)" - - assert np.allclose(image_rel_heights, [1/6, 1/6*1/3, 1/6*2/3]), \ - "expected heights if input into a smaller image incorrect "+\ - "(v1.1, rel heights to top 1/6)" - - assert np.allclose(depths, [1+1]*3), \ - "expected depths in basic cow.patch (all of depth 1) input into a "+\ - "1 level deep smaller image is incorrect (v1.1)" - - - image_rel_widths2, image_rel_heights2, depths2 = \ - vis1._size_dive(parents_areas=[cow.area(width=1/3, - height=1/2, - x_left=0, - y_top=0, - _type="pt"), - cow.area(width=1/2, - height=1/3, - x_left=1/2, - y_top=0, - _type="pt")]) - - assert np.allclose(image_rel_widths2, [.5*1/6]*3), \ - "expected widths if input into a smaller image incorrect "+\ - "(v1.2, rel width to top 1/6)" - - assert np.allclose(image_rel_heights2, [1/6, 1/6*1/3, 1/6*2/3]), \ - "expected heights if input into a smaller image incorrect "+\ - "(v1.2, rel heights to top 1/6)" - - assert np.allclose(depths2, [1+2]*3), \ - "expected depths in basic cow.patch (all of depth 1) input into a "+\ - "2 levels deep smaller image is incorrect (v1.2)" - - # nested option -------- vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ @@ -160,8 +110,8 @@ def test_patch__size_dive(): cow.layout(nrow=1) - sug_width_n, sug_height_n, max_depth_n = \ - vis_nested._size_dive() + sug_width_n, sug_height_n = \ + vis_nested._estimate_default_min_desired_size() assert np.allclose(sug_width_n, (2 * # 1/ rel width of smallest width of images @@ -176,56 +126,69 @@ def test_patch__size_dive(): "suggested height incorrectly sizes the smallest height of the images "+\ "(v2 - nested)" - assert max_depth_n == 2, \ - "expected depth for nested cow.patch (of depth 1) is incorrect "+\ - "(v2 - nested)" +def test_patch__estimate_default_min_desired_size_Annotation(): + g0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.labs(title = 'Plot 0') + + g1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 1') + + g2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 2') + + g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", + "suv", + "pickup"])]) +\ + p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ + p9.facet_wrap("class") + # basic option ---------- + vis1 = cow.patch(g0,g1,g2) +\ + cow.layout(design = np.array([[0,1], + [0,2]]), + rel_heights = [1,2]) +\ + cow.annotation(title = "My title") - # of note: the internal uses "pt", but they're actually defined relatively... - image_rel_widths_n, image_rel_heights_n, depths_n = \ - vis_nested._size_dive(parents_areas=[cow.area(width=1/6, - height=1/6, - x_left=0, - y_top=0, - _type="pt")]) + sug_width, sug_height = \ + vis1._estimate_default_min_desired_size() - assert np.allclose(image_rel_widths_n, [.5*1/6]*3), \ - "expected widths if input into a smaller image incorrect "+\ - "(v2.1 - nested, rel width to top 1/6)" + assert np.allclose(sug_width, + (2 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"] * + cow.rcParams["base_aspect_ratio"])), \ + "suggested width incorrectly sizes the smallest width of the images (v1)" - assert np.allclose(image_rel_heights_n, [1/6, 1/6*1/3, 1/6*2/3]), \ - "expected heights if input into a smaller image incorrect "+\ - "(v2.1 - nested, rel heights to top 1/6)" + assert np.allclose(sug_height, + (3 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"])), \ + "suggested height incorrectly sizes the smallest height of the images (v1)" - assert np.allclose(depths_n, list(np.array([1,2,2])+1)), \ - "expected depths in nested cow.patch (all of depth 1) input into a "+\ - "1 level deep smaller image is incorrect (v2.1 - nested)" + # nested option -------- + vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ + cow.layout(ncol=1, rel_heights = [1,2])) +\ + cow.layout(nrow=1) - image_rel_widths2_n, image_rel_heights2_n, depths2_n = \ - vis_nested._size_dive(parents_areas=[cow.area(width=1/3, - height=1/2, - x_left=0, - y_top=0, - _type="pt"), - cow.area(width=1/2, - height=1/3, - x_left=1/2, - y_top=0, - _type="pt")]) - assert np.allclose(image_rel_widths2_n, [.5*1/6]*3), \ - "expected widths if input into a smaller image incorrect "+\ - "(v2.2 - nested, rel width to top 1/6)" + sug_width_n, sug_height_n = \ + vis_nested._estimate_default_min_desired_size() - assert np.allclose(image_rel_heights2_n, [1/6, 1/6*1/3, 1/6*2/3]), \ - "expected heights if input into a smaller image incorrect "+\ - "(v2.2 - nested, rel heights to top 1/6)" + assert np.allclose(sug_width_n, + (2 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"] * + cow.rcParams["base_aspect_ratio"])), \ + "suggested width incorrectly sizes the smallest width of the images "+\ + "(v2 - nested)" - assert np.allclose(depths2_n, list(np.array([1,2,2])+2)), \ - "expected depths in nested cow.patch (all of depth 1) input into a "+\ - "1 levels deep smaller image is incorrect (v2.2 - nested)" + assert np.allclose(sug_height_n, + (3 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"])), \ + "suggested height incorrectly sizes the smallest height of the images "+\ + "(v2 - nested)" def test_patch__default_size__both_none(): """ @@ -597,8 +560,158 @@ def test_patch__process_sizes(w1,h1,w2,h2,w3,h3): "expected max_scaling should be the max of 1/width_scale and "+\ "1/height_scale assoicated with failed plot(s) (v2.2 - 2 plot failed)" -# global savings and showing and creating ------ +def test_patch__get_grob_tag_ordering(): + """ + static testing of _get_grob_tag_ordering + """ + g0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.labs(title = 'Plot 0') + + g1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 1') + + g2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 2') + + g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", + "suv", + "pickup"])]) +\ + p9.geom_histogram(p9.aes(x="hwy"), bins=10) +\ + p9.facet_wrap("class") + + my_patch = cow.patch(g0,g1,g2,g3) + my_layout = cow.layout(design = np.array([[0,0,0,3,3,3], + [0,0,0,2,2,2], + [1,1,1,2,2,2]])) + + my_annotation1 = cow.annotation(tags = ("Fig {0}",), tags_format = ("a",), + tags_order = "auto") + my_annotation2 = cow.annotation(tags = ["Fig 1", "Fig 2", "Fig 3"], # notice this isn't complete... + tags_order = "auto") + # yokogaki ---- + my_a_y1 = my_annotation1 + cow.annotation(tags_order = "yokogaki") + vis_y1 = my_patch + my_layout + my_a_y1 + + out_y1 = vis_y1._get_grob_tag_ordering() + + assert np.all(out_y1 == np.array([0,3,2,1])), \ + "yokogaki ordering incorrect (static example 1, tuple tags)" + + my_a_y1_auto = my_annotation1 + vis_y1_auto = my_patch + my_layout + my_a_y1_auto + + out_y1_auto = vis_y1_auto._get_grob_tag_ordering() + + assert np.all(out_y1_auto == np.array([0,3,2,1])), \ + "yokogaki ordering incorrect - auto tags_order (static example "+\ + "1_auto, tuple tags)" + + my_a_y2 = my_annotation2 + cow.annotation(tags_order = "yokogaki") + vis_y2 = my_patch + my_layout + my_a_y2 + + out_y2 = vis_y2._get_grob_tag_ordering() + + assert np.all(out_y2 == np.array([0,3,2,1])), \ + "yokogaki ordering incorrect (static example 2, list tags)" + + # input ---- + my_a_i1 = my_annotation1 + cow.annotation(tags_order = "input") + vis_i1 = my_patch + my_layout + my_a_i1 + out_i1 = vis_i1._get_grob_tag_ordering() + + assert np.all(out_i1 == np.array([0,1,2,3])), \ + "input ordering incorrect (static example 1, tuple tags)" + + my_a_i2 = my_annotation2 + cow.annotation(tags_order = "input") + vis_i2 = my_patch + my_layout + my_a_i2 + + out_i2 = vis_i2._get_grob_tag_ordering() + + assert np.all(out_i2 == np.array([0,1,2,3])), \ + "input ordering incorrect (static example 2, list tags)" + + my_a_i2_auto = my_annotation2 + cow.annotation(tags_order = "auto") + vis_i2_auto = my_patch + my_layout + my_a_i2_auto + + out_i2_auto = vis_i2_auto._get_grob_tag_ordering() + + assert np.all(out_i2_auto == np.array([0,1,2,3])), \ + "input ordering incorrect - auto tag ordering (static example 2, list tags)" + + # no tags (different ways) ----- + + my_n1_a = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "auto") + vis_n1_a = my_patch + my_layout + my_n1_a + out_n1_a = vis_n1_a._get_grob_tag_ordering() + + assert np.all(out_n1_a == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, auto)" + + my_n1_y = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "yokogaki") + vis_n1_y = my_patch + my_layout + my_n1_y + out_n1_y = vis_n1_y._get_grob_tag_ordering() + + assert np.all(out_n1_y == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, yokogaki)" + + my_n1_i = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "input") + vis_n1_i = my_patch + my_layout + my_n1_i + out_n1_i = vis_n1_i._get_grob_tag_ordering() + + assert np.all(out_n1_i == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, input)" + + + + + my_n2_a = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "auto") + vis_n2_a = my_patch + my_layout + my_n2_a + out_n2_a = vis_n2_a._get_grob_tag_ordering() + + assert np.all(out_n2_a == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, auto)" + + my_n2_y = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "yokogaki") + vis_n2_y = my_patch + my_layout + my_n2_y + out_n2_y = vis_n2_y._get_grob_tag_ordering() + + assert np.all(out_n2_y == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, yokogaki)" + + my_n2_i = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "input") + vis_n2_i = my_patch + my_layout + my_n2_i + out_n2_i = vis_n2_i._get_grob_tag_ordering() + + assert np.all(out_n2_i == np.array([])), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, input)" + + + vis_n_n = my_patch + my_layout + out_n_a = vis_n_n._get_grob_tag_ordering() + + + assert np.all(out_n_a == np.array([])), \ + "if annotation objet itelf is missing, we expect the "+\ + "_get_grob_tag_ordering to return an empty array" + + +# global savings and showing and creating ------ def _layouts_and_patches_patch_plus_layout(idx): # creation of some some ggplot objects g0 = p9.ggplot(p9_data.mpg) +\ diff --git a/tests/test_text_elements.py b/tests/test_text_elements.py index 5062e06..8c11dc9 100644 --- a/tests/test_text_elements.py +++ b/tests/test_text_elements.py @@ -91,11 +91,146 @@ def test__update_element_text_from_theme(): "expected theme to update element_text of text object "+\ "(with element_text default)" +def test__update_element_text_from_theme2(): + """ + test _update_element_text_from_theme + + """ + + my_ets = [p9.element_text(size = 8), + p9.element_text(angle = 45, size = 12)] + my_themes = [# just text size + p9.theme(cow_tag = my_ets[0], + cow_caption = my_ets[0], + cow_text = my_ets[0], + cow_title = my_ets[0], + cow_subtitle = my_ets[0]), + # new angle + p9.theme(cow_tag = my_ets[1], + cow_caption = my_ets[1], + cow_text = my_ets[1], + cow_title = my_ets[1], + cow_subtitle = my_ets[1]), + p9.theme_bw() # no update information for cow stuff + ] + + + _mytheme = my_themes[0] + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + # test if object already has element_text + # test that override does the correct connection to _type + + a = cow.text("Fig 1", _type = _type) + a = a + p9.element_text(angle = 100) + a._update_element_text_from_theme(_mytheme) + a_et_expected = dict(rotation = 100, size = 8, visible = True) + + assert a.element_text.properties == a_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (a, theme 0)" + + b = cow.text("Fig 1", _type = _type) + b = b + p9.element_text(size = 8) + b._update_element_text_from_theme(_mytheme) + b_et_expected = dict(size = 8, visible = True) + + assert b.element_text.properties == b_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (b, theme 0)" + + c = cow.text("Fig 1", _type = _type) + c._update_element_text_from_theme(_mytheme) + c_et_expected = dict(size = 8, visible = True) + + assert c.element_text.properties == c_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (c, theme 0)" + + _mytheme = my_themes[1] + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + # test if object already has element_text + # test that override does the correct connection to _type + + a = cow.text("Fig 1", _type = _type) + a = a + p9.element_text(angle = 90) + a._update_element_text_from_theme(_mytheme) + a_et_expected = dict(rotation = 45, size = 12, visible = True) + + assert a.element_text.properties == a_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (a, theme 1)" + + b = cow.text("Fig 1", _type = _type) + b = b + p9.element_text(size = 8) + b._update_element_text_from_theme(_mytheme) + b_et_expected = dict(rotation = 45, size = 12, visible = True) + + assert b.element_text.properties == b_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (b, theme 1)" + + c = cow.text("Fig 1", _type = _type) + c._update_element_text_from_theme(_mytheme) + c_et_expected = dict(rotation = 45, size = 12, visible = True) + + assert c.element_text.properties == c_et_expected, \ + "error updating element_text from theme preserving other "+\ + "attributes (c, theme 1)" + + + _mytheme = my_themes[2] + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + # test if object already has element_text + # test that override does the correct connection to _type + + a = cow.text("Fig 1", _type = _type) + a = a + p9.element_text(angle = 90) + + with pytest.raises(Exception) as e_info: + a._update_element_text_from_theme(_mytheme) + assert e_info.typename == "ValueError" and \ + e_info.value.args[0] == "key parameter in "+\ + "_update_element_text_from_theme function call needs to "+\ + "be a key in the provided theme's themeables.", \ + "if theme doesn't have cow_... attributes cannot be used to update"+\ + "text object, so we expect an error (a, theme 2)" + + + + + b = cow.text("Fig 1", _type = _type) + b = b + p9.element_text(size = 8) + + with pytest.raises(Exception) as e_info: + b._update_element_text_from_theme(_mytheme) + assert e_info.typename == "ValueError" and \ + e_info.value.args[0] == "key parameter in "+\ + "_update_element_text_from_theme function call needs to "+\ + "be a key in the provided theme's themeables.", \ + "if theme doesn't have cow_... attributes cannot be used to update"+\ + "text object, so we expect an error (b, theme 2)" + + + c = cow.text("Fig 1", _type = _type) + with pytest.raises(Exception) as e_info: + c._update_element_text_from_theme(_mytheme) + assert e_info.typename == "ValueError" and \ + e_info.value.args[0] == "key parameter in "+\ + "_update_element_text_from_theme function call needs to "+\ + "be a key in the provided theme's themeables.", \ + "if theme doesn't have cow_... attributes cannot be used to update"+\ + "text object, so we expect an error (c, theme 2)" + + def test__get_full_element_text(): """ - test _get_full_element_text (static test) + test _get_full_element_text (static test - using _type text not cow_text, etc.) """ mytitle = cow.text("title") + mytitle._define_type("text") et_full_base = mytitle._get_full_element_text() @@ -118,14 +253,67 @@ def test__get_full_element_text(): (("expected all properties but key=size (key = %s) to match "+\ "if all inherits properties from global theme") % key) +def test__get_full_element_text2(): + """ + test _get_full_element_text, static + + tests that nothing is override but additional attributes are provided + relative to the _type parameter and default theme structure + + """ + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + # test if object already has element_text + # test that override does the correct connection to _type + + a = cow.text("Fig 1", _type = _type) + a = a + p9.element_text(angle = 90) + + b = cow.text("Fig 1", _type = _type) + b = b + p9.element_text(size = 8) + + c = cow.text("Fig 1", _type = _type) + + a_et = a._get_full_element_text() + a_et_properties_expected = \ + {'visible': True, + 'rotation': 90, + 'size': cow.rcParams[_type].properties["size"]} + + b_et = b._get_full_element_text() + b_et_properties_expected = \ + {'visible': True, + 'size': 8} + + c_et = c._get_full_element_text() + c_et_properties_expected = cow.rcParams[_type].properties + + for key, v_expected in a_et_properties_expected.items(): + assert a_et.properties[key] == v_expected, \ + "text element with rotation should preserve rotation " +\ + "but update size relative to default (_type :%s, key: %s)" % (_type, key) + + for key, v_expected in b_et_properties_expected.items(): + assert b_et.properties[key] == v_expected, \ + "text element with size should not be updated with base "+\ + "size information (_type :%s, key: %s)" % (_type, key) + + for key, v_expected in c_et_properties_expected.items(): + assert c_et.properties[key] == v_expected, \ + "text object without element_text attribute should fully update "+\ + "with default size information (_type :%s, key: %s)" % (_type, key) + + def test__min_size(): """ test _min_size (static) This function only checks relative sizes reported from _min_size """ + pass +# printing ---------- From 4169f11f038db64ff82a29c389ee93edb8981ea5 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Tue, 8 Nov 2022 19:52:25 -0800 Subject: [PATCH 03/13] current work on annotations and smarting formating --- src/cowpatch/annotation_elements.py | 417 +++++++-- src/cowpatch/base_elements.py | 1211 ++++++++++++++++++--------- src/cowpatch/layout_elements.py | 2 +- src/cowpatch/svg_utils.py | 23 + src/cowpatch/text_elements.py | 1 - src/cowpatch/utils.py | 108 +++ tests/test_annotation_elements.py | 284 ++++++- tests/test_base_elements.py | 500 +++++++---- 8 files changed, 1908 insertions(+), 638 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index ba13d3e..2214c3c 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -75,7 +75,7 @@ def __init__(self, title=None, subtitle=None, caption=None, Notes ----- - TODO: + TODO (update docstring): Due to "tags_inherit", we need to allow updates. Potential solution to removing values would be to have additions say "np.nan" instead of None to clean something out. Annoyingly this would force updates of @@ -93,7 +93,7 @@ def __init__(self, title=None, subtitle=None, caption=None, self.tags_order = None self.tags_loc = None self.tags_inherit = None - self.tags_format = None + self._tags_format = None self.tags_depth = -1 self._update_all_attributes(title=title, @@ -105,6 +105,37 @@ def __init__(self, title=None, subtitle=None, caption=None, tags_loc=tags_loc, tags_inherit=tags_inherit) + @property + def tags_format(self): + if self._tags_format is not None and self._tags_format is not np.nan: + _tags_format = self._tags_format + elif self.tags is None or self.tags is np.nan: # both tags_format and tags are None + _tags_format = self._tags_format + elif inherits(self.tags, list): + _tags_format = ("{0}", ) + else: + _tags_format = tuple(_string_tag_format(x) + for x in np.arange(len(self.tags))) + + ## making tags_format text (same code in _update_all_attributes...) + if _tags_format is not None and _tags_format is not np.nan: # if None/np.nan, then tags is also None + new_tags_format = [] + for e in _tags_format: + if inherits(e, text): + e2 = copy.deepcopy(e) + e2._define_type(_type = "cow_tag") + new_tags_format.append(e2) + else: #e is string + e = text(label = e, _type = "cow_tag") + new_tags_format.append(e) + new_tags_format = tuple(new_tags_format) + else: + new_tags_format = _tags_format + + return new_tags_format + + + def _clean_up_attributes(self): """ Examines all attributes and those that are np.nan are converted to None @@ -281,17 +312,20 @@ def _update_all_attributes(self, title=None, subtitle=None, caption=None, else: new_tags_format = tags_format - self.tags_format = new_tags_format + self._tags_format = new_tags_format # tags_depth definition -------------------- if inherits(self.tags, list): - self.tags_depth = 0 - elif self.tags_format is None or self.tags_format is np.nan: + self.tags_depth = 1 + elif (self.tags_format is None or self.tags_format is np.nan) and \ + (self.tags is None or self.tags is np.nan): self.tags_depth = -1 + elif self.tags_format is None or self.tags_format is np.nan: + self.tags_depth = len(self.tags) else: - self.tags_depth = len(self.tags_format) - 1 + self.tags_depth = len(self.tags_format) - def _get_tag(self, index=0): + def _get_tag(self, index=(0,)): """ Create text of tag for given level and index @@ -309,6 +343,7 @@ def _get_tag(self, index=0): ----- this should return objects relative to correct rotation... """ + pdb.set_trace() if inherits(index, int): index = (index,) @@ -390,7 +425,7 @@ def _calculate_tag_margin_sizes(self, index=(0,), fundamental=False, to_inches=False): """ - calculate tag's margin sizes + (Internal) calculate tag's margin sizes Arguments --------- @@ -404,49 +439,126 @@ def _calculate_tag_margin_sizes(self, index=(0,), Returns ------- - min_desired_widths : float - extra_desired_widths : float - min_desired_heights : float - extra_desired_heights : float + dictatiory with following keys/ objects + min_inner_width : float + minimum width required for title & subtitles on top or bottom + min_full_width : float + minium width required for caption (spans all of width). This + will always be zero for the tag structure + extra_used_width : float + extra width required for title & subtitles on left or right + min_inner_height : float + minimum height required for title & subtitles on left or right + extra_used_height : float + extra height required for title & subtitles on top or bottom + top_left_loc : tuple + tuple of top left corner of inner image relative to title text - # TODO: needs to deal with rotation... """ - # clean-ups... + # clean-up if not inherits(index, tuple): index = (index, ) + # if we shouldn't actually make the tag if self.tags_depth != len(index) and not fundamental: - return [0],[0],[0],[0] + return {"min_inner_width": 0, + "min_full_width": 0, # not able to be nonzero for tag + "extra_used_width": 0, + "min_inner_height": 0, + "extra_used_height": 0, + "top_left_loc": (0,0) + } # getting tag ------------------- - tag = self._get_tag(index = index) + tag = self._get_tag(index=index) tag_sizes = tag._min_size(to_inches=to_inches) - min_desired_widths = [tag_sizes[0] * \ - (self.tags_loc in ["top", "bottom", "t", "b"])] - min_desired_heights = [tag_sizes[1] * \ - (self.tags_loc in ["left", "right", "l", "r"])] - - if True:#self.tags_type == 0: #outside of image box - extra_desired_widths = [tag_sizes[0] * \ - (self.tags_loc in ["left", "right", "l", "r"])] - extra_desired_heights = [tag_sizes[1] * \ - (self.tags_loc in ["top", "bottom", "t", "b"])] - # else: # inside image box - # extra_desired_widths, extra_desired_heights = [0],[0] - # min_desired_widths.append(tag_sizes[0] * \ - # (self.tags_loc in ["left", "right", "l", "r"])) - # min_desired_heights.append(tag_sizes[1] * \ - # (self.tags_loc in ["top", "bottom", "t", "b"])) - - return min_desired_widths, extra_desired_widths, \ - min_desired_heights, extra_desired_heights + min_inner_width = tag_sizes[0] * \ + (self.tags_loc in ["top", "bottom", "t", "b"]) + min_inner_height = tag_sizes[1] * \ + (self.tags_loc in ["left", "right", "l", "r"]) + + + extra_used_width = tag_sizes[0] * \ + (self.tags_loc in ["left", "right", "l", "r"]) + extra_used_height = tag_sizes[1] * \ + (self.tags_loc in ["top", "bottom", "t", "b"]) + + top_left_loc = ( + tag_sizes[0] * (self.tags_loc in ["left", "l"]), + tag_sizes[1] * (self.tags_loc in ["top", "t"]) + ) + + + return {"min_inner_width": min_inner_width, + "min_full_width": 0, # not able to be nonzero for tag + "extra_used_width": extra_used_width, + "min_inner_height": min_inner_height, + "extra_used_height": extra_used_height, + "top_left_loc": top_left_loc + } + + def _get_tag_and_location(self, width, height, + index = (0,), + fundamental=False): + """ + + Return + ------ + tag_loc : tuple + upper left corner location for tag + image_loc : tuple + upper left corner location for image (assoicated with tag) + tag_image : + tag text svg object + """ + # clean-up + if not inherits(index, tuple): + index = (index, ) + + # if we shouldn't actually make the tag + if self.tags_depth != len(index) and not fundamental: + return None, None, None + + tag_image = self.get_tag(index = index) + + if self.tags_loc in ["top", "bottom"]: + inner_width_pt = width + inner_height_pt = None + else: + inner_width_pt = None + inner_height_pt = height + + tag_image, size_pt = \ + inner_tag._svg(width_pt=inner_width_pt, + height_pt=inner_height_pt) + + if self.tags_loc == "top": + tag_loc = (0,0) + image_loc = (0, size_pt[1]) + elif self.tags_loc == "left": + tag_loc = (0,0) + image_loc = (size_pt[0], 0) + elif self.tags_loc == "bottom": + tag_loc = (0, height - size_pt[0]) + image_loc = (0,0) + else: # self.tags_loc == "right": + tag_loc = (width - size_pt[1],0) + image_loc = (0,0) + + return tag_loc, image_loc, tag_image + + + + + + def _calculate_margin_sizes(self, to_inches=False): """ - calculates marginal sizes needed to be displayed for titles + (Internal) calculates marginal sizes needed to be displayed for titles Arguments --------- @@ -455,42 +567,229 @@ def _calculate_margin_sizes(self, to_inches=False): Returns ------- - min_desired_widths : float - extra_desired_widths : float - min_desired_heights : float - extra_desired_heights : float - - TODO: need to make sure left/right objects are correctly rotated... + dictatiory with following keys/ objects + min_inner_width : float + minimum width required for title & subtitles on top or bottom + min_full_width : float + minium width required for caption (spans all of width) + extra_used_width : float + extra width required for title & subtitles on left or right + min_inner_height : float + minimum height required for title & subtitles on left or right + extra_used_height : float + extra height required for title & subtitles on top or bottom + top_left_loc : tuple + tuple of top left corner of inner image relative to title text + + TODO: need to make sure left/right objects are correctly rotated... [9/22 I think this is done] """ - min_desired_widths = \ - [t._min_size(to_inches=to_inches)[0] for t in [self.title.get("top"), + min_inner_width = \ + np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.title.get("top"), self.title.get("bottom"), self.subtitle.get("top"), - self.subtitle.get("bottom"), - self.caption] - if t is not None] - extra_desired_widths = \ - [t._min_size(to_inches=to_inches)[0] for t in [self.title.get("left"), + self.subtitle.get("bottom")] + if t is not None] + [0]) + + min_full_width = \ + np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.caption] + if t is not None] + [0]) + + extra_used_width = \ + np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.title.get("left"), self.title.get("right"), self.subtitle.get("left"), self.subtitle.get("right")] - if t is not None] - min_desired_heights = \ - [t._min_size(to_inches=to_inches)[1] for t in [self.title.get("left"), + if t is not None]+ [0]) + + min_inner_height = \ + np.sum([t._min_size(to_inches=to_inches)[1] for t in [self.title.get("left"), self.title.get("right"), self.subtitle.get("left"), self.subtitle.get("right")] - if t is not None] - extra_desired_heights = \ - [t._min_size(to_inches=to_inches)[1] for t in [self.title.get("top"), + if t is not None] +[0]) + + extra_used_height = \ + np.sum([t._min_size(to_inches=to_inches)[1] for t in [self.title.get("top"), self.title.get("bottom"), self.subtitle.get("top"), self.subtitle.get("bottom"), self.caption] - if t is not None] + if t is not None] + [0]) + + top_left_loc = ( + np.sum([t._min_size(to_inches=to_inches)[0] + for t in [self.title.get("left"), self.subtitle.get("left")] + if t is not None] + [0]), + np.sum([t._min_size(to_inches=to_inches)[1] + for t in [self.title.get("top"), self.subtitle.get("top")] + if t is not None] + [0]) + + ) + + return {"min_inner_width": min_inner_width, + "min_full_width": min_full_width, + "extra_used_width": extra_used_width, + "min_inner_height": min_inner_height, + "extra_used_height": extra_used_height, + "top_left_loc": top_left_loc + } + + + def _get_titles_and_locations(self, width, height): + """ + (Internal) Create title objects and locations to be placed in image + + Arguments + --------- + width : float + width of overall image (in inches?) + height : float + height of overall image (in inches?) + + Returns + ------- + out_list : list + list of tuples of the location to place the title (top left corner) + and the image of the title itself + + Notes + ----- + Here's a visual diagram that helped define this function + + + ~~~~ width ~~~~~ + =====b'===== (width - b) + =b= (width of 7 and 8) + =a= (width of 1 and 2) + a1 (width of 1) + + b_1 (width of 7) + ~/*' | 33333 | + ~/* | 44444 | + ~/ ---+-------+--- + ~/ 1 2| |7 8 + ~/ 1 2| |7 8 + ~/ 1 2| |7 8 + ~/ ---+-------+--- + ~ %$ | 55555 | + ~ % | 66666 | + ~ & 999999999999999 + + ~ : height + / : ii' (height - ii - iii_1) + * : i (height of 3 and 4) + ' : i_1 (height of 3) + % : ii (height of 5 and 6) + $ : ii_1 (height of 5) + & : iii_1 (height of 9) + + + + where + 1 : title, left + 2 : subtitle, left + 3 : title, top + 4 : subtitle, top + 5 : title, bottom + 6 : subtitle, bottom + 7 : title, right + 8 : subtitle, right + 9 : caption + """ + # TODO: testing (likely just do a complex image) + # TODO: make sure the pt vs inch question is settled + + # minimum size of each object + title_min_size_dict = {key : [t.min_size() if t is not None else (0,0) + for t in [self.title.get(key), + self.subtitle.get(key)]] + for key in ["top", "bottom", "left", "right"]} + + if self.caption is not None: + title_min_size_dict["caption"] = self.caption.min_size() + else: + title_min_size_dict["caption"] = (0,0) + + + # shifts for top left positioning + shift_horizonal = { + # same value + ("title", "top") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), + ("subtitle", "top") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), + ("title", "bottom") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), + ("subtitle", "bottom") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), + + "caption" : 0, + + ("title", "left") : 0, + ("subtitle", "left") : title_min_size_dict["left"][0][0], + ("title", "right") : width - np.sum([tu[0] for tu in title_min_size_dict["right"]]), + ("subtitle", "right") : width - title_min_size_dict["right"][1][0] + } + + shift_horizonal = { + # same value + ("title", "left") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), + ("subtitle", "left") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), + ("title", "right") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), + ("subtitle", "right") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), + + "caption" : height - title_min_size_dict["caption"][1], + + ("title", "top") : 0, + ("subtitle", "top") : title_min_size_dict["top"][0][1], + ("title", "bottom") : width - np.sum([tu[1] for tu in title_min_size_dict["right"]]) -\ + title_min_size_dict["caption"][1], + ("subtitle", "bottom") : width - title_min_size_dict["bottom"][1][1] -\ + title_min_size_dict["caption"][1] + } + + # sizes to create each title element with + inner_width = np.sum([tu[0] for tu in title_min_size_dict["left"] +\ + title_min_size_dict["right"]]) + inner_height = np.sum([tu[1] for tu in title_min_size_dict["top"] +\ + title_min_size_dict["bottom"]]) + + size_request = { + ("title","top") : (inner_width, None), + ("title","bottom") : (inner_width, None), + ("title","left") : (None, inner_height), + ("title","right") : (None, inner_height), + ("subtitle","top") : (inner_width, None), + ("subtitle","bottom") : (inner_width, None), + ("subtitle","left") : (None, inner_height), + ("subtitle","right") : (None, inner_height), + "caption" : (width, None) + } + + + out_list = [] + out_list += [ ( (shift_horizontal[("title",key)], \ + shift_vertical[("title",key)]), \ + self.title.get(key)._svg(width_pt = size_request[("title", key)][0], + height_pt = size_request[("title", key)][1]) + ) for key in ["top", "bottom", "left", "right"] + if self.title.get(key) is not None] + + out_list += [ ( (shift_horizontal[("subtitle",key)], \ + shift_vertical[("subtitle",key)]), \ + self.subtitle.get(key)._svg(width_pt = size_request[("subtitle", key)][0], + height_pt = size_request[("subtitle", key)][1]) + ) for key in ["top", "bottom", "left", "right"] + if self.subtitle.get(key) is not None] + + out_list += [((shift_horizontal[key], shift_vertical[key]), \ + self.caption._svg(width_pt = size_request[key][0], \ + height_pt = size_request[key][1]) + ) for key in ["caption"] if + self.caption is not None] + + + return out_list + + + - return min_desired_widths, extra_desired_widths, \ - min_desired_heights, extra_desired_heights def _update_tdict_info(self, t, current_t = dict(), _type = "title"): @@ -624,7 +923,7 @@ def __add__(self, other): subtitle = other.subtitle, caption = other.caption, tags=other.tags, - tags_format=other.tags_format, + tags_format=other._tags_format, tags_order=other.tags_order, tags_loc=other.tags_loc, tags_inherit=other.tags_inherit) diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index 373e6c3..daba6c8 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -5,7 +5,8 @@ import copy from .svg_utils import gg_to_svg, _save_svg_wrapper, _show_image, \ - _raw_gg_to_svg, _select_correcting_size_svg + _raw_gg_to_svg, _select_correcting_size_svg, \ + _add_to_base_image from .utils import to_inches, from_inches, inherits_plotnine, inherits, \ _flatten_nested_list from .layout_elements import layout @@ -118,7 +119,7 @@ def __init__(self, *args, grobs=None): self.grobs = grobs self.__layout = "patch" # this is different than None... - self.annotation = None + self.__annotation = None @property def layout(self): @@ -139,6 +140,19 @@ def layout(self): else: return self.__layout + @property + def annotation(self): + """ + defines ``annotation`` that either returns the most up-to-date + ``cow.annotation`` object or the default ``annotation`` if no + annotation has been explicitly defined + """ + if self.__annotation is None: + return annotation() + else: + return self.__annotation + + def _check_layout(self): """ checks layout if design matrix is fulled defined @@ -212,18 +226,23 @@ def __add__(self, other): return patch(grobs=[self.grobs]+[other]) elif inherits(other, layout): # combine with layout ------------- - self.__layout = other + object_copy = copy.deepcopy(self) + object_copy.__layout = other + + return object_copy elif inherits(other, annotation): + object_copy = copy.deepcopy(self) + if self.annotation is None: other_copy = copy.deepcopy(other) other_copy._clean_up_attributes() - self.annotation = other_copy + object_copy.__annotation = other_copy else: final_copy = copy.deepcopy(self.annotation + other) final_copy._clean_up_attributes() - self.annotation = final_copy + object_copy.__annotation = final_copy - return self + return object_copy def __mul__(self, other): raise ValueError("currently not implimented *") @@ -245,8 +264,8 @@ def _get_grob_tag_ordering(self): """ self._check_layout() - if self.annotation is None or self.annotation.tags is None: - return np.array([]) + if self.annotation.tags is None: + return np.array([None]*len(self.grobs)) tags_order = self.annotation.tags_order if tags_order == "auto": @@ -257,435 +276,783 @@ def _get_grob_tag_ordering(self): if tags_order == "yokogaki": - return self.layout._yokogaki_ordering(num_grobs = len(self.grobs)) + out_array = self.layout._yokogaki_ordering(num_grobs = len(self.grobs)) elif tags_order == "input": - return np.arange(len(self.grobs)) + out_array = np.arange(len(self.grobs)) else: raise ValueError("patch's annotation's tags_order is not an expected option") - - def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): + # list correction + if inherits(self.annotation.tags, list) and \ + len(self.annotation.tags) < len(self.grobs): + out_array = np.array(out_array, dtype = object) # to allow for None + out_array[out_array > len(self.annotation.tags)-1] = None + + return out_array + + + # def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): + # """ + # Internal function to create an svg representation of the patch + + # Arguments + # --------- + # width_pt : float + # desired width of svg object in points + # height_pt : float + # desired height of svg object in points + # sizes: TODO: write description + # num_attempts : TODO: write description + + # Returns + # ------- + # svg_object : ``svgutils.transforms`` object + + # See also + # -------- + # svgutils.transforms : pythonic svg object + # """ + + # self._check_layout() + + # # examine if sizing is possible and update or error if not + # # -------------------------------------------------------- + # if sizes is None: # top layer + # sizes = self._svg_get_sizes(width_pt, height_pt) + + + # layout = self.layout + + # areas = layout._element_locations(width_pt=width_pt, + # height_pt=height_pt, + # num_grobs=len(self.grobs)) + + # base_image = sg.SVGFigure() + # base_image.set_size((str(width_pt)+"pt", str(height_pt)+"pt")) + # # add a view box... (set_size doesn't correctly update this...) + # # maybe should have used px instead of px.... + # base_image.root.set("viewBox", "0 0 %s %s" % (str(width_pt), str(height_pt))) + + # # TODO: way to make decisions about the base image... + # # TODO: this should only be on the base layer... + # base_image.append( + # sg.fromstring("")) + + # for p_idx in np.arange(len(self.grobs)): + # inner_area = areas[p_idx] + # # TODO: how to deal with ggplot objects vs patch objects + # if inherits(self.grobs[p_idx], patch): + # inner_width_pt, inner_height_pt = inner_area.width, inner_area.height + # inner_svg, _ = self.grobs[p_idx]._svg(width_pt = inner_width_pt, + # height_pt = inner_height_pt, + # sizes = sizes[p_idx]) + # elif inherits_plotnine(self.grobs[p_idx]): + # inner_gg_width_in, inner_gg_height_in = sizes[p_idx] + # inner_svg = _raw_gg_to_svg(self.grobs[p_idx], + # width = inner_gg_width_in, + # height = inner_gg_height_in, + # dpi = 96) + # else: + # raise ValueError("grob idx %i is not a patch object nor"+\ + # "a ggplot object within patch with hash %i" % p_idx, self.__hash__) + + + # inner_root = inner_svg.getroot() + + # inner_root.moveto(x=inner_area.x_left, + # y=inner_area.y_top) + # base_image.append(inner_root) + + # return base_image, (width_pt, height_pt) + + # def _estimate_default_min_desired_size(self, parent_index=()): + # """ + # (Internal) Estimate default height and width of full image so that + # inner objects (plots, text, etc.) have a minimum desirable size + # and all titles are fully seen. + + # Arguments + # --------- + # parent_index : int + # parent level index information + + # Returns + # ------- + # suggested_width : float + # proposed width for overall size (inches) + # suggested_height : float + # proposed height for overall size (inches) + + # Notes + # ----- + # ***Function's logic***: + # In order to propose a minimum sizing to an arrangement we take two + # things into account. Specially, + + # 1. we assure that each plot's height & width are greater then or equal + # to a minimum plot height and width (For text objects treated as plots, + # we just assure they are not truncated, with no minimize sizing is + # enforced). + # 2. and for titles (,subtitles, captions,) and tags, we ensure that + # they will not be truncated based on the sizing of the plot/arrangement + # they are associated with. + + # To accomplish this second goal, we track different dimensions of these + # text labeling objects. For clarity, define (1) a text object’s + # *typographic length* as the maximum width of all lines of and with + # each line’s text’s baseline being horizontal and a text object’s + # *typographic overall height* as the distance between the top “line” of + # text’s ascender line and the bottom "line" of text's descender line + # `reference `_, + # and (2) a *bounding box* for any text object is the minimum sized box + # that can contain the text object. This bounding box has a *height* and + # *width* which is naturally impacted by the orientation of baseline of + # the text. + + + # For titles (,subtitles, captions,) and tags of tag_type = 0 (aka the + # tag is drawn outside the underlying plot/arrangement's space), we do + # the following: + + # A. we make sure the associated plot/arangement’s requested height and + # weight ensures that the text object's *typographic length* is not + # truncated. To do so we assure that the plot-specific height and width + # being larger than max(image_min_height_from_objective_1, select_bbox_heights)) + # and max(image_min_width_from_objective_1,select_bbox_widths)). A text + # element contributes to the select_bbox_heights if it has left or right + # alignment and to select_bbox_widths if it has top or bottom alignment. + # B. we make sure the "global" location for said inner plot/arrangement + # and text greater than or equal to the the plot’s minimum sizing + # defined in (A) plus a selected_cumulative_extra_bbox_height and _width. + # A text element contributes to the selected_cumulative_extra_bbox_height + # if it has top or bottom alignment and to + # selected_cumulative_extra_bbox_width if it has left or right + # alignment. Note that different types of titles and tags contribute to + # this valve in an addition sense. + + # If a tag has tag_type=1, then all it's bounding box’s attributes + # contribute to the dimensions described in (A). + + # ***Additional info:*** + # The default rcParams are (in inches): + # base_height = 3.71, + # base_aspect_ratio = 1.618 # the golden ratio + + # The idea of a minimum size for a subplot follows ideas proposed in + # cowplot: `wilkelab.org/cowplot/reference/save_plot.html `_. + + # """ + + # min_image_height = rcParams["base_height"] + # min_image_width = rcParams["base_aspect_ratio"] * min_image_height + + # raise ValueError("TODO: correct update to _calculate_margin_sizes") + # ## dealing with titles + # if self.annotation is None: + # min_desired_widths = [] + # extra_desired_widths = [] + # min_desired_heights = [] + # extra_desired_heights = [] + # else: + # min_desired_widths, extra_desired_widths, \ + # min_desired_heights, extra_desired_heights = \ + # self.annotation._calculate_margin_sizes(to_inches=True) + + # # inner objects + # areas = self.layout._element_locations(width_pt=1, + # height_pt=1, + # num_grobs = len(self.grobs)) + + # if (self.annotation is not None) and \ + # (self.annotation.order_type() == "yokogaki"): + # grob_tag_ordering = self.layout._yokogaki_ordering( + # num_grobs=len(self.grobs)) + # else: + # grob_tag_ordering = np.arange(len(self.grobs)) + + + # for g_idx in np.arange(len(self.grobs)): + # inner_area = areas[g_idx] + # current_index = tuple(list(parent_index) + \ + # [grob_tag_ordering[g_idx]]) + # ### tag sizing ----------- + # if self.annotation is not None: + # tag_min_desired_widths, tag_extra_desired_widths, \ + # tag_min_desired_heights, tag_extra_desired_heights = \ + # self.annotation._calculate_tag_margin_sizes( + # index = current_index, + # fundamental = inherits(self.grobs[g_idx], patch), + # to_inches=True) + # else: + # tag_min_desired_widths, tag_extra_desired_widths, \ + # tag_min_desired_heights, tag_extra_desired_heights = [0],[0],[0],[0] + + + # ### object sizing ------------- + # if inherits_plotnine(self.grobs[g_idx]): + # inner_width = min_image_width + # inner_height = min_image_height + # elif inherits(self.grobs[g_idx], text): + # inner_width, inner_height = self.grobs[g_idx]._min_size(to_inches=True) + # else: # patch object... + # inner_width, inner_height = \ + # self.grobs[g_idx]._estimate_default_min_desired_size(parent_index=current_index) + + + # min_desired_widths.append((np.max([inner_width]+tag_min_desired_widths) +\ + # tag_extra_desired_widths)*\ + # 1/inner_area.width) + # min_desired_heights.append((np.max([inner_height]+tag_min_desired_heights) +\ + # tag_extra_desired_heights) *\ + # 1/inner_area.height) + + + # # wrap it all up (zeros added to allow for empty sets) + # min_desired_width = np.max(min_desired_widths+[0]) +\ + # np.sum(extra_desired_widths+[0]) + # min_desired_height = np.max(min_desired_heights+[0]) +\ + # np.sum(extra_desired_heights+[0]) + + # return min_desired_width, min_desired_height + + # def _default_size(self, width, height): + # """ + # (Internal) obtain default recommended size of overall image if width or + # height is None + + # Arguments + # --------- + # width : float + # width of output image in inches (this should actually be associated + # with the svg...) + # height : float + # height of svg in inches (this should actually be associated + # with the svg...) + + # Returns + # ------- + # width : float + # returns default width for given object if not provided (else just + # returns provided value). If only height is provided then width + # proposed is relative to a default aspect ratio for the object. + # height : float + # returns default height for given object if not provided (else just + # returns provided value). If only width is provided then height + # proposed is relative to a default aspect ratio for the object. + # """ + # both_none = False + # if width is None or height is None: + # _width, _height = self._estimate_default_min_desired_size()#self._size_dive() + # if width is None and height is None: + # both_none = True + # width = _width + # height = _height + # elif width is not None: + # height = _height / _width * width + # else: + # width = _width / _height * height + # return width, height + + # def _inner_svg_get_sizes(self, width_pt, height_pt, tag_parent_index=()): + # """ + # (Internal) Calculates required sizes for plot objects to meet required + # sizes and logics if the requested sizing was possible + + # Arguments + # --------- + # width_pt : float + # overall width of the image in points + # height_pt : float + # overall height of the image in points + # tags_parent_index : tuple + # tuple of integers that contain the relative level indices of the + # desired tag for parent. + + # Returns + # ------- + # sizes : nested list + # For each element in the patch (with nesting structure in the list), + # this contains width, height tuples that either capture the size + # to request the ggplot object to be to return the actual desired + # size (see gg_to_svg notes), OR it contains the fraction defined by + # the requested width (or height) w.r.t. the returned width + # (or height) from saving the plotnine object. The later option + # occurs when when the ggplot's sizing didn't converge to the + # desired size. These sizing do not contain information about + # annotations (but implicitly reflect them existing) + # logics : nested list + # For each element in the patch (with nesting structure in the list), + # this contains a boolean value if the ggplot object was able to + # be correctly size. + # sizes_text : nested list + # logics_text : nested list + + # Notes + # ----- + # Internally this function uses rcParams's ``eps``, ``mini_size_px`` and + # ``maxIter`` to determine the parameters to be put in + # .svg_utils._select_correcting_size_svg. + # """ + # layout = self.layout + + # areas = layout._element_locations(width_pt=width_pt, + # height_pt=height_pt, + # num_grobs=len(self.grobs)) + + # sizes = [] + # logics = [] + # sizes_annotation = [] + # logics_annotation = [] + + # for p_idx in np.arange(len(self.grobs)): + # inner_area = areas[p_idx] + # inner_width_pt = inner_area.width + # inner_height_pt = inner_area.height + + # if inherits(self.grobs[p_idx], patch): + # inner_sizes_list, logic_list = \ + # self.grobs[p_idx]._inner_svg_get_sizes(width_pt = inner_width_pt, + # height_pt = inner_height_pt) + # sizes.append(inner_sizes_list) + # logics.append(logic_list) + # elif inherits_plotnine(self.grobs[p_idx]): + # inner_w, inner_h, inner_logic = \ + # _select_correcting_size_svg(self.grobs[p_idx], + # width=to_inches(inner_width_pt, + # units="pt", + # dpi=96), + # height=to_inches(inner_height_pt, + # units="pt", + # dpi=96), + # dpi=96, + # eps=rcParams["eps"], + # min_size_px=rcParams["min_size_px"], + # maxIter=rcParams["maxIter"], + # throw_error=False) + # sizes.append((inner_w,inner_h)) + # logics.append(inner_logic) + # elif inherits(self.grobs[p_idx], text): + # raise ValueError("TODO!") + # else: + # raise ValueError("grob idx %i is not a patch object nor"+\ + # "a ggplot object" % p_idx) + + # return sizes, logics + + # def _svg_get_sizes(self, width_pt, height_pt): + # """ + # captures backend process of making sure the sizes requested can be + # provided with the actual images... + # """ + + # if num_attempts is None: + # num_attempts = rcParams["num_attempts"] + + # while num_attempts > 0: + # sizes, logics = self._svg_get_sizes(width_pt=width_pt, + # height_pt=height_pt) + # out_info = self._process_sizes(sizes, logics) + + # if type(out_info) is list: + # num_attempts = -412 # strictly less than 0 + # else: # out_info is a scaling + # width_pt = width_pt*out_info + # height_pt = height_pt*out_info + + # num_attempts -= 1 + + # if num_attempts == 0: + # raise StopIteration("Attempts to find the correct sizing of inner"+\ + # "plots failed with provided parameters") + + # return sizes + + # def _process_sizes(self, sizes, logics): + # """ + # (Internal) draw conclusions about the output of _svg_get_sizes. + + # This function assesses if any internal ggplot object failed to + # produce an image of the requested size (w.r.t. to given interation + # parameters) + + # Arguments + # --------- + # sizes : nested list + # sizes output of _svg_get_sizes. This is a nested list with + # (width,height) or (requested_width/actual_width, + # requested_height/actual_width), with the different being if the + # ggplot object was able to be presented in the requested size + # logics : nested list + # logics output of _svg_get_sizes. This is a nested list of boolean + # values w.r.t. the previous different. + + # Returns + # ------- + # sizes : nested list + # Returned if no logics values are False. This object is the sizes + # nested list that was input. + # max_scaling : float + # Returned if at least one of the logics values are False. This is + # the scaling of the original ``width_pt`` and ``height_pt`` that + # defined the sizes and logics that could make the requested sizes + # for all ggplots that failed to be correctly sized be at least as + # large as the returned size from a basic ggplot saving. + # """ + # flatten_logics = _flatten_nested_list(logics) + # if np.all(flatten_logics): + # return sizes + + # # else suggest a rescale: + # flatten_sizes = _flatten_nested_list(sizes) + # bad_sizing_info = [flatten_sizes[i] for i in range(len(flatten_sizes)) + # if not flatten_logics[i]] + + # width_scaling = 1/np.min([sizing[0] for sizing in bad_sizing_info]) + # height_scaling = 1/np.min([sizing[1] for sizing in bad_sizing_info]) + + # max_scaling = np.max([width_scaling, height_scaling]) + # # ^ keeping aspect ratio with a single scaling + + # return max_scaling + + + def _hierarchical_general_process(self, + width=None, height=None, data_dict=None, + approach=["size", "create", "default-size"][0]): """ - Internal function to create an svg representation of the patch + (Internal) Process hierarchical patchwork structure to (1) save/show, + (2) estimate needed size input, (3) and define default size Arguments --------- - width_pt : float - desired width of svg object in points - height_pt : float - desired height of svg object in points - sizes: TODO: write description - num_attempts : TODO: write description - - Returns - ------- - svg_object : ``svgutils.transforms`` object - - See also - -------- - svgutils.transforms : pythonic svg object + (width, height): tuple of integers + probably inches based - not 100% sure + data_dict : dictionary + dictionary of data to pass to children patches + approach : str + string of which approach should be used """ - self._check_layout() - - # examine if sizing is possible and update or error if not - # -------------------------------------------------------- - if sizes is None: # top layer - sizes = self._svg_get_sizes(width_pt, height_pt) - - - layout = self.layout - - areas = layout._element_locations(width_pt=width_pt, - height_pt=height_pt, - num_grobs=len(self.grobs)) - - base_image = sg.SVGFigure() - base_image.set_size((str(width_pt)+"pt", str(height_pt)+"pt")) - # add a view box... (set_size doesn't correctly update this...) - # maybe should have used px instead of px.... - base_image.root.set("viewBox", "0 0 %s %s" % (str(width_pt), str(height_pt))) - - # TODO: way to make decisions about the base image... - # TODO: this should only be on the base layer... - base_image.append( - sg.fromstring("")) - - for p_idx in np.arange(len(self.grobs)): - inner_area = areas[p_idx] - # TODO: how to deal with ggplot objects vs patch objects - if inherits(self.grobs[p_idx], patch): - inner_width_pt, inner_height_pt = inner_area.width, inner_area.height - inner_svg, _ = self.grobs[p_idx]._svg(width_pt = inner_width_pt, - height_pt = inner_height_pt, - sizes = sizes[p_idx]) - elif inherits_plotnine(self.grobs[p_idx]): - inner_gg_width_in, inner_gg_height_in = sizes[p_idx] - inner_svg = _raw_gg_to_svg(self.grobs[p_idx], - width = inner_gg_width_in, - height = inner_gg_height_in, - dpi = 96) - else: - raise ValueError("grob idx %i is not a patch object nor"+\ - "a ggplot object within patch with hash %i" % p_idx, self.__hash__) - - - inner_root = inner_svg.getroot() - - inner_root.moveto(x=inner_area.x_left, - y=inner_area.y_top) - base_image.append(inner_root) - - return base_image, (width_pt, height_pt) - - def _estimate_default_min_desired_size(self, parent_index=()): - """ - (Internal) Estimate default height and width of full image so that - inner objects (plots, text, etc.) have a minimum desirable size - and all titles are fully seen. - - Arguments - --------- - parent_index : int - parent level index information - - Returns - ------- - suggested_width : float - proposed width for overall size (inches) - suggested_height : float - proposed height for overall size (inches) - - Notes - ----- - ***Function's logic***: - In order to propose a minimum sizing to an arrangement we take two - things into account. Specially, - - 1. we assure that each plot's height & width are greater then or equal - to a minimum plot height and width (For text objects treated as plots, - we just assure they are not truncated, with no minimize sizing is - enforced). - 2. and for titles (,subtitles, captions,) and tags, we ensure that - they will not be truncated based on the sizing of the plot/arrangement - they are associated with. - - To accomplish this second goal, we track different dimensions of these - text labeling objects. For clarity, define (1) a text object’s - *typographic length* as the maximum width of all lines of and with - each line’s text’s baseline being horizontal and a text object’s - *typographic overall height* as the distance between the top “line” of - text’s ascender line and the bottom "line" of text's descender line - `reference `_, - and (2) a *bounding box* for any text object is the minimum sized box - that can contain the text object. This bounding box has a *height* and - *width* which is naturally impacted by the orientation of baseline of - the text. - - - For titles (,subtitles, captions,) and tags of tag_type = 0 (aka the - tag is drawn outside the underlying plot/arrangement's space), we do - the following: - - A. we make sure the associated plot/arangement’s requested height and - weight ensures that the text object's *typographic length* is not - truncated. To do so we assure that the plot-specific height and width - being larger than max(image_min_height_from_objective_1, select_bbox_heights)) - and max(image_min_width_from_objective_1,select_bbox_widths)). A text - element contributes to the select_bbox_heights if it has left or right - alignment and to select_bbox_widths if it has top or bottom alignment. - B. we make sure the "global" location for said inner plot/arrangement - and text greater than or equal to the the plot’s minimum sizing - defined in (A) plus a selected_cumulative_extra_bbox_height and _width. - A text element contributes to the selected_cumulative_extra_bbox_height - if it has top or bottom alignment and to - selected_cumulative_extra_bbox_width if it has left or right - alignment. Note that different types of titles and tags contribute to - this valve in an addition sense. - - If a tag has tag_type=1, then all it's bounding box’s attributes - contribute to the dimensions described in (A). - - ***Additional info:*** - The default rcParams are (in inches): - base_height = 3.71, - base_aspect_ratio = 1.618 # the golden ratio - - The idea of a minimum size for a subplot follows ideas proposed in - cowplot: `wilkelab.org/cowplot/reference/save_plot.html `_. - - """ - - min_image_height = rcParams["base_height"] - min_image_width = rcParams["base_aspect_ratio"] * min_image_height - - ## dealing with titles - if self.annotation is None: - min_desired_widths = [] - extra_desired_widths = [] - min_desired_heights = [] - extra_desired_heights = [] - else: - min_desired_widths, extra_desired_widths, \ - min_desired_heights, extra_desired_heights = \ - self.annotation._calculate_margin_sizes(to_inches=True) - - # inner objects - areas = self.layout._element_locations(width_pt=1, - height_pt=1, - num_grobs = len(self.grobs)) - - if (self.annotation is not None) and \ - (self.annotation.order_type() == "yokogaki"): - grob_tag_ordering = self.layout._yokogaki_ordering( - num_grobs=len(self.grobs)) - else: - grob_tag_ordering = np.arange(len(self.grobs)) - - - for g_idx in np.arange(len(self.grobs)): - inner_area = areas[g_idx] - current_index = tuple(list(parent_index) + \ - [grob_tag_ordering[g_idx]]) - ### tag sizing ----------- - if self.annotation is not None: - tag_min_desired_widths, tag_extra_desired_widths, \ - tag_min_desired_heights, tag_extra_desired_heights = \ - self.annotation._calculate_tag_margin_sizes( - index = current_index, - fundamental = inherits(self.grobs[g_idx], patch), - to_inches=True) - else: - tag_min_desired_widths, tag_extra_desired_widths, \ - tag_min_desired_heights, tag_extra_desired_heights = [0],[0],[0],[0] - - - ### object sizing ------------- - if inherits_plotnine(self.grobs[g_idx]): - inner_width = min_image_width - inner_height = min_image_height - elif inherits(self.grobs[g_idx], text): - inner_width, inner_height = self.grobs[g_idx]._min_size(to_inches=True) - else: # patch object... - inner_width, inner_height = \ - self.grobs[g_idx]._estimate_default_min_desired_size(parent_index=current_index) - - - min_desired_widths.append((np.max([inner_width]+tag_min_desired_widths) +\ - tag_extra_desired_widths)*\ - 1/inner_area.width) - min_desired_heights.append((np.max([inner_height]+tag_min_desired_heights) +\ - tag_extra_desired_heights) *\ - 1/inner_area.height) - - - # wrap it all up (zeros added to allow for empty sets) - min_desired_width = np.max(min_desired_widths+[0]) +\ - np.sum(extra_desired_widths+[0]) - min_desired_height = np.max(min_desired_heights+[0]) +\ - np.sum(extra_desired_heights+[0]) - - return min_desired_width, min_desired_height - - def _default_size(self, width, height): - """ - (Internal) obtain default recommended size of overall image if width or - height is None - - Arguments - --------- - width : float - width of output image in inches (this should actually be associated - with the svg...) - height : float - height of svg in inches (this should actually be associated - with the svg...) - - Returns - ------- - width : float - returns default width for given object if not provided (else just - returns provided value). If only height is provided then width - proposed is relative to a default aspect ratio for the object. - height : float - returns default height for given object if not provided (else just - returns provided value). If only width is provided then height - proposed is relative to a default aspect ratio for the object. - """ - both_none = False - if width is None or height is None: - _width, _height = self._estimate_default_min_desired_size()#self._size_dive() + # prep section: annotation global size corrections -------------------- + + ## global annotation sizing + + ### default size estimation + if approach == "default-size": + if (width is not None and width > 1) or \ + (height is not None and height > 1): + raise ValueError("if using approach \"default-size\", "+\ + "height and width should be greater or "+\ + "equal to one.") + if data_dict is None or data_dict["default-size-proportion"] is None: + if data_dict is None: + data_dict = dict() + data_dict["default-size-proportion"] = (1,1) + + overall_default_size = (0,0) + else: # using default sizings + if width is None or height is None: + default_width, default_height = \ + self._hierarchical_general_process(width=1, height=1, + approach="default-size") + if width is None and height is not None: + width = default_width/default_height * height + if width is not None and height is None: + height = default_height/default_width * width if width is None and height is None: - both_none = True - width = _width - height = _height - elif width is not None: - height = _height / _width * width - else: - width = _width / _height * height - return width, height - - def _inner_svg_get_sizes(self, width_pt, height_pt, tag_parent_index=()): - """ - (Internal) Calculates required sizes for plot objects to meet required - sizes and logics if the requested sizing was possible - - Arguments - --------- - width_pt : float - overall width of the image in points - height_pt : float - overall height of the image in points - tags_parent_index : tuple - tuple of integers that contain the relative level indices of the - desired tag for parent. - - Returns - ------- - sizes : nested list - For each element in the patch (with nesting structure in the list), - this contains width, height tuples that either capture the size - to request the ggplot object to be to return the actual desired - size (see gg_to_svg notes), OR it contains the fraction defined by - the requested width (or height) w.r.t. the returned width - (or height) from saving the plotnine object. The later option - occurs when when the ggplot's sizing didn't converge to the - desired size. These sizing do not contain information about - annotations (but implicitly reflect them existing) - logics : nested list - For each element in the patch (with nesting structure in the list), - this contains a boolean value if the ggplot object was able to - be correctly size. - sizes_text : nested list - logics_text : nested list + width = default_width + height = default_height - Notes - ----- - Internally this function uses rcParams's ``eps``, ``mini_size_px`` and - ``maxIter`` to determine the parameters to be put in - .svg_utils._select_correcting_size_svg. - """ - layout = self.layout - areas = layout._element_locations(width_pt=width_pt, - height_pt=height_pt, - num_grobs=len(self.grobs)) + if True: # all approaches + title_margin_sizes_dict = self.annotation._calculate_margin_sizes() + text_used_width, text_used_height, = \ + title_margin_sizes_dict["extra_used_width"], \ + title_margin_sizes_dict["extra_used_height"] - sizes = [] - logics = [] - sizes_annotation = [] - logics_annotation = [] + tl_loc = title_margin_sizes_dict["top_left_loc"] - for p_idx in np.arange(len(self.grobs)): - inner_area = areas[p_idx] - inner_width_pt = inner_area.width - inner_height_pt = inner_area.height + if approach == "default-size": + width_inner, height_inner = \ + to_inches(1, units="pt"), to_inches(1, units="pt") + else: + width_inner, height_inner = \ + width - text_used_width, height - text_used_height + + ### sizing estimation + if approach == "size": + if data_dict is None: + data_dict = {} + if data_dict.get("size-num-attempts") is None: + data_dict["size-num-attempts"] = rcParams["num_attempts"] + if data_dict["size-num-attempts"] == 0: + raise StopIteration("Attempts to find the correct sizing of "+\ + "inner plots failed with provided "+\ + "parameters") + if data_dict.get("size-node-level") is None: + data_dict["size-node-level"] = 0 + + size_multiplier = [] + sizes_list = [] + ### saving / showing + if approach == "create": + #### create base image + base_image = sg.SVGFigure() + base_image.set_size((str(width_pt)+"pt", str(height_pt)+"pt")) + # add a view box... (set_size doesn't correctly update this...) + base_image.root.set("viewBox", "0 0 %s %s" % (str(width_pt), str(height_pt))) + + #### process titles/subtitles/captions + titles_and_locs = \ + self.annotation._get_titles_and_locations(width = width, + height = height) + + for loc_tuple, title in titles_and_locs: + _add_to_base_image(base_image, title, loc_tuple) + + + #### sizes of inner grobs: + if data_dict is None or data_dict["sizes"] is None: + sizes = self._hierarchical_general_process(width = 1, + height = 1, + approach = "size") + #### TODO?: maybe track the time it takes to calculate sizes and if it takes to + #### long provide user with progressbar for the actual plot creation? + + + + + ### prep for grob processing + if True: # all approaches + areas = self.layout._element_locations( + width_pt=from_inches(width_inner, units="pt"), # TODO: need to check all pt vs inch assumptions + height_pt=from_inches(height_inner, units="pt"), + num_grobs=len(self.grobs)) + tag_index_array = self._get_grob_tag_ordering() + + # process section: loop through heirarchical structure ---------------- + for p_idx, image in enumerate(self.grobs): + ## prep tag annotation + if True: # all approaches + inner_area = areas[p_idx] + + if tag_index_array[p_idx] is not None: + grob_tag_index = tag_index_array[p_idx] + + fundamental_tag = not inherits(self.grobs[p_idx], patch) + + if date_dict is None or data_dict["parent-index"] is None: + current_index = (grob_tag_index,) + else: + current_index = tuple(list(data_dict["parent-index"])+\ + [grob_tag_index]) + + + else: + fundamental_tag = False + current_index = () + + tag_margin_dict = self.annotation._calculate_tag_margin_sizes( + fundamental=fundamental_tag, + index=current_index) # should return empty dict... + + + if approach in ["create", "size"]: + grob_width, grob_height = \ + inner_area.width - tag_margin_dict["extra_used_width"],\ + inner_area.height - tag_margin_dict["extra_used_height"] + + ### create tag + if approach == "create": + tag_loc, image_loc, tag_image = \ + self.annotation._get_tag_and_location( + width=inner_area.width, + height=inner_area.height, + index=current_index, + fundamental=fundamental_tag) + + _add_to_base_image(base_image, tag_image, tag_loc) + + ## grob processing if inherits(self.grobs[p_idx], patch): - inner_sizes_list, logic_list = \ - self.grobs[p_idx]._inner_svg_get_sizes(width_pt = inner_width_pt, - height_pt = inner_height_pt) - sizes.append(inner_sizes_list) - logics.append(logic_list) + ### default sizing + if approach == "default-size": + default_size_prop = (inner_area.width,inner_area.height) + default_inner_size = self.grobs[p_idx]._hierarchical_general_process( + width=1, + height=1, + data_dict= + {"default-size-proportion": default_size_prop}, + approach="default-size") + ### sizing estimation + if approach == "size": + inner_sizes, inner_size_multiplier = \ + self.grobs[p_idx]._hierarchical_general_process( + width=grob_width, + height=grob_height, + data_dict={"size-node-level": data_dict["size-node-level"] + 1}, + approach="size") + sizes_list.append(inner_sizes) + size_multiplier.append(inner_size_multiplier) + ### saving / showing + if approach == "create": + grob_image = self.grobs[p_idx]._hierarchical_general_process( + width=grob_width, + height=grob_height, + data_dict={"size":sizes[p_idx], + "parent-index":current_index}, + approach="create") + elif inherits_plotnine(self.grobs[p_idx]): - inner_w, inner_h, inner_logic = \ - _select_correcting_size_svg(self.grobs[p_idx], - width=to_inches(inner_width_pt, - units="pt", - dpi=96), - height=to_inches(inner_height_pt, - units="pt", - dpi=96), - dpi=96, - eps=rcParams["eps"], - min_size_px=rcParams["min_size_px"], - maxIter=rcParams["maxIter"], - throw_error=False) - sizes.append((inner_w,inner_h)) - logics.append(inner_logic) + ### default sizing + if approach == "default-size": + default_inner_size = ( + rcParams["base_aspect_ratio"] * rcParams["base_height"], + rcParams["base_height"]) + ### sizing estimation + if approach == "size": + v0, v1, successful_logic = \ + _select_correcting_size_svg( + self.grobs[p_idx], + width=grob_width, + height=grob_height, + dpi=96, + eps=rcParams["eps"], + min_size_px=rcParams["min_size_px"], + maxIter=rcParams["maxIter"], + throw_error=False) + + if successful_logic is False: # track needed size change + _, inner_size_multiplier = \ + _overall_scale_recommendation_single( + image_new_size=(v0*grob_width, + v1*grob_height), + text_inner_size=(tag_margin_dict["min_inner_width"], + tag_margin_dict["min_inner_height"]), + text_extra_size=(tag_margin_dict["extra_used_width"], + tag_margin_dict["extra_used_height"]), + original_overall_size=(grob_width, grob_height)) + sizes_list.append((-1,-1)) + size_multiplier.append(inner_size_multiplier) + else: # collect creation size requirement + sizes_list.append((v0,v1)) + size_multiplier.append(1) + ### saving / showing + if approach == "create": + grob_image = _raw_gg_to_svg(self.grobs[p_idx], + width = sizes[p_idx][0], + height = sizes[p_idx][1], + dpi = 96) + elif inherits(self.grobs[p_idx], text): - raise ValueError("TODO!") + ### default sizing + if approach == "default-size": + m_w,m_h = self.grobs[p_idx]._min_size() + + default_inner_size = ( + np.max([rcParams["base_aspect_ratio"] *\ + rcParams["base_height"], m_w]), + np.max([m_h,rcParams["base_height"]]) + ) + ### sizing estimation + if approach == "size": + m_w, m_h = self.grobs[p_idx]._min_size() + if m_w > grob_width or m_h > grob_height: # track needed size change + inner_size_multiplier = np.max([m_w/grob_width, + m_h/grob_height]) + sizes_list.append((-1,-1)) + size_multiplier.append(inner_size_multiplier) + else: + sizes_list.append((grob_width, grob_height)) + size_multiplier.append(1) + ### saving / showing + if approach == "create": + grob_image = self.grobs[p_idx]._svg( + width_pt=sizes[p_idx][0], + height_pt=sizes[p_idx][1]) else: - raise ValueError("grob idx %i is not a patch object nor"+\ - "a ggplot object" % p_idx) - - return sizes, logics - - def _svg_get_sizes(self, width_pt, height_pt): - """ - captures backend process of making sure the sizes requested can be - provided with the actual images... - """ - - if num_attempts is None: - num_attempts = rcParams["num_attempts"] + raise ValueError("cowpatch currently only processes native "+\ + "classes patch and text as well as "+\ + "plotnine's ggplot objects") + + ## post processing inner loop + ### sizing estimation + if approach == "default-size": + #### we need to deal with mins from tag info + default_inner_size = ( + np.max([default_inner_size[0], + tag_margin_dict["min_inner_width"]]), + np.max([default_inner_size[1], + tag_margin_dict["min_inner_height"]]) + ) + + grob_default_size = ( + default_inner_size[0] + tag_margin_dict["extra_used_width"], + default_inner_size[1] + tag_margin_dict["extra_used_height"] + ) + overall_default_size = ( + np.max([1/inner_area.width * grob_default_size[0], + overall_default_size[0]]), + np.max([1/inner_area.height * grob_default_size[1], + overall_default_size[1]]) + ) + + ### saving / showing + if approach == "create": + #### append to base image @ correct location + _add_to_base_image(base_image, grob_image, image_loc) + + # post processing section: cleaning up + ### sizing estimation + pdb.set_trace() + if approach == "default-size": + default_inner_size = ( + np.max([overall_default_size[0], + title_margin_sizes_dict["min_inner_width"], + title_margin_sizes_dict["min_full_width"] - \ + title_margin_sizes_dict["extra_used_width"]]), + np.max([overall_default_size[1], + title_margin_sizes_dict["min_inner_height"]]) + ) + + return ( + default_inner_size[0] + title_margin_sizes_dict["extra_used_width"], + default_inner_size[1] + title_margin_sizes_dict["extra_used_height"] + ) + ### sizing estimation + if approach == "size": + if not np.allclose(size_multiplier, np.ones(len(size_multiplier))): # track needed size change + _, inner_size_multiplier = \ + _overall_scale_recommendation_patch(interior_image_scalings, + text_inner_size=( + np.max([title_margin_sizes_dict["min_inner_width"], + title_margin_sizes_dict["min_full_width"] - \ + title_margin_sizes_dict["extra_used_width"]]), + title_margin_sizes_dict["min_inner_height"]), + text_extra_size=(title_margin_sizes_dict["extra_used_width"], + title_margin_sizes_dict["extra_used_height"]), + original_overall_size=(width_inner, height_inner)) + + ### repeat + # if sizing incorrect and num-attempts haven't run out, + # repeat process with updated sizing + if data_dict["size-num-attempts"] > 0 and \ + data_dict["size-node-level"] == 0: + return self._hierarchical_general_process(self, + width=inner_size_multiplier*width, + height=inner_size_multiplier*height, + data_dict={"size-num-attempts": + data_dict["size-num-attempts"]-1}, + approach="size") + else: # collect sizes + return sizes_list, 1 - while num_attempts > 0: - sizes, logics = self._svg_get_sizes(width_pt=width_pt, - height_pt=height_pt) - out_info = self._process_sizes(sizes, logics) - if type(out_info) is list: - num_attempts = -412 # strictly less than 0 - else: # out_info is a scaling - width_pt = width_pt*out_info - height_pt = height_pt*out_info - - num_attempts -= 1 - - if num_attempts == 0: - raise StopIteration("Attempts to find the correct sizing of inner"+\ - "plots failed with provided parameters") - - return sizes - - def _process_sizes(self, sizes, logics): - """ - (Internal) draw conclusions about the output of _svg_get_sizes. - - This function assesses if any internal ggplot object failed to - produce an image of the requested size (w.r.t. to given interation - parameters) - - Arguments - --------- - sizes : nested list - sizes output of _svg_get_sizes. This is a nested list with - (width,height) or (requested_width/actual_width, - requested_height/actual_width), with the different being if the - ggplot object was able to be presented in the requested size - logics : nested list - logics output of _svg_get_sizes. This is a nested list of boolean - values w.r.t. the previous different. - - Returns - ------- - sizes : nested list - Returned if no logics values are False. This object is the sizes - nested list that was input. - max_scaling : float - Returned if at least one of the logics values are False. This is - the scaling of the original ``width_pt`` and ``height_pt`` that - defined the sizes and logics that could make the requested sizes - for all ggplots that failed to be correctly sized be at least as - large as the returned size from a basic ggplot saving. - """ - flatten_logics = _flatten_nested_list(logics) - if np.all(flatten_logics): - return sizes - # else suggest a rescale: - flatten_sizes = _flatten_nested_list(sizes) - bad_sizing_info = [flatten_sizes[i] for i in range(len(flatten_sizes)) - if not flatten_logics[i]] - width_scaling = 1/np.min([sizing[0] for sizing in bad_sizing_info]) - height_scaling = 1/np.min([sizing[1] for sizing in bad_sizing_info]) - max_scaling = np.max([width_scaling, height_scaling]) - # ^ keeping aspect ratio with a single scaling - return max_scaling def save(self, filename, width=None, height=None, dpi=96, _format=None, verbose=None): @@ -733,15 +1100,20 @@ def save(self, filename, width=None, height=None, dpi=96, _format=None, io.BytesIO : object that acts like a reading in of bytes """ # updating width and height if necessary (some combine is none) - width, height = self._default_size(width=width, height=height) + #width, height = self._default_size(width=width, height=height) # global default for verbose (if not provided by the user) if verbose is None: verbose = rcParams["save_verbose"] svg_obj, (actual_width_pt, actual_height_pt) = \ - self._svg(width_pt = from_inches(width, "pt", dpi=dpi), - height_pt = from_inches(height, "pt", dpi=dpi)) + self._hierarchical_general_process( + width=from_inches(width, "pt", dpi=dpi), + height=from_inches(height, "pt", dpi=dpi), + data_dict=None, + approach="create") + # self._svg(width_pt = from_inches(width, "pt", dpi=dpi), + # height_pt = from_inches(height, "pt", dpi=dpi)) _save_svg_wrapper(svg_obj, filename=filename, @@ -785,15 +1157,20 @@ def show(self, width=None, height=None, dpi=96, verbose=None): representation. """ # updating width and height if necessary (some combine is none) - width, height = self._default_size(width=width, height=height) + #width, height = self._default_size(width=width, height=height) # global default for verbose (if not provided by the user) if verbose is None: verbose = rcParams["show_verbose"] svg_obj, (actual_width_pt, actual_height_pt) = \ - self._svg(width_pt = from_inches(width, "pt", dpi=dpi), - height_pt = from_inches(height, "pt", dpi=dpi)) + self._hierarchical_general_process( + width=from_inches(width, "pt", dpi=dpi), + height=from_inches(height, "pt", dpi=dpi), + data_dict=None, + approach="create") + # self._svg(width_pt = from_inches(width, "pt", dpi=dpi), + # height_pt = from_inches(height, "pt", dpi=dpi)) _show_image(svg_obj, width=to_inches(actual_width_pt, "pt", dpi=dpi), diff --git a/src/cowpatch/layout_elements.py b/src/cowpatch/layout_elements.py index 5aee97b..93caf76 100644 --- a/src/cowpatch/layout_elements.py +++ b/src/cowpatch/layout_elements.py @@ -517,7 +517,7 @@ def _yokogaki_ordering(self, num_grobs=None): "with self.num_grobs") num_grobs = self.num_grobs - areas = self._element_locations(1,1) # basically getting relative positions (doesn't matter) - nor does it matter about rel_height and width, but ah well + areas = self._element_locations(1,1, num_grobs=num_grobs) # basically getting relative positions (doesn't matter) - nor does it matter about rel_height and width, but ah well all_x_left = np.array([a.x_left for a in areas]) all_y_top = np.array([a.y_top for a in areas]) diff --git a/src/cowpatch/svg_utils.py b/src/cowpatch/svg_utils.py index 7f70cfa..c72da01 100644 --- a/src/cowpatch/svg_utils.py +++ b/src/cowpatch/svg_utils.py @@ -460,3 +460,26 @@ def _show_image(svg, width, height, dpi=300, verbose=True): # jupyter notebook ------ base_image_string = svg.to_str() IPython.display.display(IPython.display.SVG(data = base_image_string)) + + + +def _add_to_base_image(base_image, current_image, loc = (0,0)): + """ + append current image into base_image at location loc + + Arguments + --------- + base_image : svg object + base image object + current_image : svg object + new image object to be added to the base image + loc : tuple + top left corner location for the new image to be placed (within the + base_image) + """ + inner_root = current_image.getroot() + inner_root.moveto(x=loc[0], + y=loc[1]) + base_image.append(inner_root) + + return None diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index ec18563..0df8ef7 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -219,7 +219,6 @@ def _get_full_element_text(self): return new_self.element_text - def _min_size(self, to_inches=False): """ calculate minimum size of bounding box around self in pt and diff --git a/src/cowpatch/utils.py b/src/cowpatch/utils.py index 347d339..8f74a3b 100644 --- a/src/cowpatch/utils.py +++ b/src/cowpatch/utils.py @@ -311,3 +311,111 @@ def _string_tag_format(max_level=0): +def _overall_scale_recommendation_single(image_new_size, + text_inner_size, + text_extra_size, + original_overall_size): + """ + proposal an overall size based on inner image needs and text annotation + sizes + + Arguments + --------- + image_new_size : tuple + size 2 float tuple with the requested minimum size the inner image can + be (relative to the image) + text_inner_size : tuple + size 2 float tuple with the requested minimum size the inner image can + be (relative to the surrounding text) + text_extra_size : tuple + size 2 float tuple with extra width and height for surrounding text, + beyond the inner image + original_overall_size : tuple + size 2 float tuple of original size provided for the overall image + + Returns + ------- + out1 : tuple + size 2 length tuple of newly requested size + out2 : float + fraction scaling of new size to old size + + Notes + ----- + This function assumes ate least one of the new image_new_size values are + greater than the original requested size (original_overall_size - + text_extra_sizes) + """ + + min_inner_size_needed = (np.max([image_new_size[i], text_inner_size[i]]) + for i in [0,1]) + + min_overall_size_needed = tuple(np.array(min_inner_size_needed) +\ + np.array(text_extra_size)) + + size_ratio = orginal_overall_size[0] / orginal_overall_size[0] + + out_array = np.zeros(2) + + if min_overall_size_needed[1] < 1/size_ratio * min_overall_size_needed[0]: + out_array[1] = min_overall_size_needed[1] + out_array[0] = size_ratio * min_overall_size_needed[1] + else: + out_array[0] = min_overall_size_needed[0] + out_array[1] = 1/size_ratio * min_overall_size_needed[0] + + return tuple(out_array), out_array[0]/original_overall_size[0] + +def _overall_scale_recommendation_patch(interior_image_scalings, + text_inner_size, + text_extra_size, + original_overall_size): + """ + proposal an overall size for an arangement based on information about + interior image and overall text + + Arguments + --------- + interior_image_scalings : list + list of floats. Each value is the requested scaling of a inner image + (as a function of the image and the associated tag information) + text_inner_size : tuple + size 2 float tuple with the requested minimum size the inner image can + be (relative to the surrounding text) + text_extra_size : tuple + size 2 float tuple with extra width and height for surrounding text, + beyond the inner image + original_overall_size : tuple + size 2 float tuple of original size provided for the overall image + + Returns + ------- + out1 : tuple + size 2 length tuple of newly requested size + out2 : float + fraction scaling of new size to old size + + Notes + ----- + This function assumes ate least one of the new image_new_size values are + greater than the original requested size (original_overall_size - + text_extra_sizes) + """ + original_inner_size = tuple(np.array(original_overall_size) - \ + np.array(text_extra_size)) + + inner_new_size_request = \ + np.array(original_inner_size) * np.max(interior_image_scalings + [1]) + + out = \ + _overall_scale_recommendation_single(inner_new_size_request, + text_inner_size, + text_extra_size, + original_overall_size) + + return out + + + + + diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index 1488614..93159dd 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -6,6 +6,8 @@ import plotnine.data as p9_data import pytest +from hypothesis import given, strategies as st, settings +import itertools def test__update_tdict_info(): """ @@ -403,7 +405,9 @@ def test__clean_up_attributes(): "_clean_up_attributes function" for key in a.__dict__.keys(): - if key not in ["tags_inherit", "tags_order", "tags_depth"]: + if key not in ["tags_inherit", "tags_order", "tags_depth", + "_tags_format"]: + a2 = cow.annotation(**{key: np.nan}) b2 = cow.annotation(**{key: None}) @@ -419,6 +423,284 @@ def test__clean_up_attributes(): "_clean_up_attributes function " +\ "(key = %s)" % key + if key == "_tags_format": + a2 = cow.annotation(**{"tags_format": np.nan}) + b2 = cow.annotation(**{"tags_format": None}) + + assert a2 != b2 and a2.__dict__[key] != b2.__dict__[key], \ + "before running _clean_up_attributes function, we expect "+\ + "the difference in specific key value to be not equal... " +\ + "(key = %s)" % key + + a2._clean_up_attributes() + + assert a2 == b2, \ + "we expect that np.nans are cleaned up after running the "+\ + "_clean_up_attributes function " +\ + "(key = %s)" % key + +@pytest.mark.parametrize("location", ["title", "subtitle", "caption"]) +def test__calculate_margin_sizes_basic(location): + """ + test _calculate_margin_sizes, static + """ + a0 = cow.annotation(**{location:"example title"}) + a0_size_dict = a0._calculate_margin_sizes(to_inches=False) + + # check that tags don't impact __calculate_margin_sizes + a1 = a0 + cow.annotation(tags = ["banana", "apple"]) + a2 = a0 + cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a")) + a1_size_dict = a1._calculate_margin_sizes(to_inches=False) + a2_size_dict = a2._calculate_margin_sizes(to_inches=False) + + assert a0_size_dict == a1_size_dict and \ + a0_size_dict == a2_size_dict, \ + "tag alterations shouldn't change %s margin sizes" % location + + + # top or bottom structure for basic text input: + assert a0_size_dict["extra_used_width"] == 0 and \ + a0_size_dict["min_inner_height"] == 0 and \ + a0_size_dict["top_left_loc"][0] == 0, \ + ("top or bottom structure text (only) with %s should have a "+\ + "clear set of zeros for margins") % location + + # title/subtitle vs caption sizing structure + if location in ["title", "subtitle"]: + assert a0_size_dict["min_full_width"] == 0, \ + ("top or bottom structure text (only) with %s should only " +\ + "population min_inner_width, not min_full_width based as " +\ + "the %s should be directly over the image") % (location, location) + else: + assert a0_size_dict["min_inner_width"] == 0, \ + ("top or bottom structure text (only) with %s should only " +\ + "population min_full_width, not min_inner_width based as " +\ + "the %s be able to span all other margins") % (location, location) + + + # text height (relative to default rcParams) + # https://stackoverflow.com/questions/41336177/font-size-vs-line-height-vs-actual-height + if location in ["title", "subtitle"]: + # caption doesn't meet this expectation and the world says implimentation like this can happen + assert a0_size_dict["extra_used_height"]/13.5 == \ + cow.rcParams["cow_%s"%location].properties["size"]/\ + cow.rcParams["cow_title"].properties["size"], \ + ("font size for %s correctly aligns with expection relative to "+\ + "output size for title") % location + + # text width (relative to height) + if location in ["title", "subtitle"]: + assert a0_size_dict["min_inner_width"]/a0_size_dict["extra_used_height"] > 5,\ + ("expected the length of the %s text to be > 5x the height of "+\ + "the text") % location + else: + assert a0_size_dict["min_full_width"]/a0_size_dict["extra_used_height"] > 5,\ + ("expected the length of the %s text to be > 5x the height of "+\ + "the text") % location + +@pytest.mark.parametrize("types, location1, location2", + list(itertools.product( + list(itertools.combinations(["title", "subtitle", "caption"], r=2)), + ["top", "bottom", "left", "right"], + ["top", "bottom", "left", "right"])) + ) +def test__calculate_margin_sizes_non_basic(types, location1, location2): + # lets of left, right, top, bottom + some other option. + # allow for overrides and combinations + + # force all sizes to be the same (to deal with different structures) + locations = (location1, location2) + input_dict = dict() + + for t_idx, _type in enumerate(types): + if _type != "caption": + input_dict[_type] = \ + {locations[t_idx]: cow.text("text", + element_text=p9.element_text(size=12))} + else: + input_dict[_type] = cow.text("text", + element_text=p9.element_text(size=12)) + + + a0 = cow.annotation(**input_dict) + + a0_size_dict = a0._calculate_margin_sizes(to_inches=False) + + # check that tags don't impact __calculate_margin_sizes + a1 = a0 + cow.annotation(tags = ["banana", "apple"]) + a2 = a0 + cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a")) + a1_size_dict = a1._calculate_margin_sizes(to_inches=False) + a2_size_dict = a2._calculate_margin_sizes(to_inches=False) + + assert a0_size_dict == a1_size_dict and \ + a0_size_dict == a2_size_dict, \ + "tag alterations shouldn't change %s margin sizes" % location + + # image location matches left & top shifts + count_left = np.sum([locations[l_idx] == "left" for l_idx in [0,1] + if types[l_idx] != "caption"]) + count_width_plus = np.sum([locations[l_idx] in ["left", "right"] + for l_idx in [0,1] if types[l_idx] != "caption"]) + count_top = np.sum([locations[l_idx] == "top" for l_idx in [0,1] + if types[l_idx] != "caption"]) + count_height_plus = np.sum([(locations[l_idx] in ["top", "bottom"]) or\ + (types[l_idx] == "caption") for l_idx in [0,1]]) + + if count_width_plus > 0: + left_side = a0_size_dict["extra_used_width"] * \ + count_left/count_width_plus + else: + left_side = 0 + + if count_height_plus > 0: + top_side = a0_size_dict["extra_used_height"] *\ + count_top/count_height_plus + else: + top_side = 0 + + # TODO this failed... + assert a0_size_dict["top_left_loc"] == (left_side, top_side), \ + ("expected image starting location doesn't match expectation "+\ + "relative to text on top and left ({t1}:{l1}, {t2}:{l2}").\ + format(t1=types[0], t2=types[1], + l1=locations[0], l2=locations[1]) + +@pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) +def test__calculate_tag_margin_sizes(location): + """ + test _calculate_tag_margin_sizes + """ + # test if tag should actually be created + # nesting + + a0_list = cow.annotation(tags = ["banana", "apple"], + tags_loc = location) + a0_list_nest = cow.annotation(tags = (["young", "old"], + ["harry", "hermione", "ron"]), + tags_loc = location) + + a0_tuple_nest = cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a"), + tags_loc = location) + + # check that tags don't impact __calculate_margin_sizes + a0_all = [a0_list, a0_list_nest, a0_tuple_nest] + a_info_str = ["list", "nested-list", "nested-tuple"] + for a_idx, a0 in enumerate(a0_all): + a0_title = a0 + cow.annotation(title = "Conformal Inference") + + if a0.tags_depth == 1: + base = a0._calculate_tag_margin_sizes(index = (1,)) + base_plus_title = a0_title._calculate_tag_margin_sizes(index = (1,)) + else: + base = a0._calculate_tag_margin_sizes(index = (1,0)) + base_plus_title = a0_title._calculate_tag_margin_sizes(index = (1,0)) + + assert base == base_plus_title, \ + ("title attributes shouldn't impact the sizing structure for a "+\ + "tag (structure: %s, loc %s)") % (a_info_str[a_idx], location) + + # fundamental + if a0.tags_depth == 2: + base_f = a0._calculate_tag_margin_sizes(index = (1,), + fundamental=True) + base_plus_title_f = a0_title._calculate_tag_margin_sizes(index = (1,), + fundamental=True) + + assert base_f == base_plus_title_f and \ + base_f != {'min_inner_width': 0, + 'min_full_width': 0, + 'extra_used_width': 0, + 'min_inner_height': 0, + 'extra_used_height': 0, + 'top_left_loc': (0.0, 0)}, \ + ("title attributes shouldn't impact the sizing structure for "+\ + "a tag (fundamental), and fundamental works correctly... "+\ + "(structure: %s, loc %s)") % (a_info_str[a_idx], location) + + # when a tag shouldn't be created + if a0.tags_depth == 1: + base_e = a0._calculate_tag_margin_sizes(index = (1,0)) + base_plus_title_e = a0_title._calculate_tag_margin_sizes(index = (1,0)) + else: + base_e = a0._calculate_tag_margin_sizes(index = (1,0,1)) + base_plus_title_e = a0_title._calculate_tag_margin_sizes(index = (1,0,1)) + + assert base_e == base_plus_title_e and \ + base_e == {'min_inner_width': 0, + 'min_full_width': 0, + 'extra_used_width': 0, + 'min_inner_height': 0, + 'extra_used_height': 0, + 'top_left_loc': (0.0, 0)}, \ + ("if requesting tag for depth below max depth, no tag would be "+\ + "created - aka margins == 0, (with and without title) " +\ + "(structure: %s, loc %s)") % (a_info_str[a_idx], location) + + # not fundamental + if a0.tags_depth == 2: + base_nf = a0._calculate_tag_margin_sizes(index = (1,), + fundamental=False) + base_plus_title_nf = a0_title._calculate_tag_margin_sizes(index = (1,), + fundamental=False) + + assert base_nf == base_plus_title_nf and \ + base_nf == {'min_inner_width': 0, + 'min_full_width': 0, + 'extra_used_width': 0, + 'min_inner_height': 0, + 'extra_used_height': 0, + 'top_left_loc': (0.0, 0)}, \ + ("if not a fundamental tag, no tag should be created "+\ + "(title or not) "+\ + "(structure: %s, loc %s)") % (a_info_str[a_idx], location) + + + # list_nest vs tuple_nest + a0 = a0_list_nest + a0_title = a0 + cow.annotation(title = "Conformal Inference") + + base_le = a0._calculate_tag_margin_sizes(index = (1,5)) + base_plus_title_le = a0_title._calculate_tag_margin_sizes(index = (1,5)) + + assert base_le == base_plus_title_le and \ + base_le == {'min_inner_width': 0, + 'min_full_width': 0, + 'extra_used_width': 0, + 'min_inner_height': 0, + 'extra_used_height': 0, + 'top_left_loc': (0.0, 0)}, \ + ("tags based in lists should have finite length of non-zero tags " +\ + "(structure: %s, loc %s)") % (a_info_str[a_idx], location) + + + a0 = a0_tuple_nest + a0_title = a0 + cow.annotation(title = "Conformal Inference") + + for t_idx in np.random.choice(100,2): + base_t = a0._calculate_tag_margin_sizes(index = (1,t_idx)) + base_plus_title_t = a0_title._calculate_tag_margin_sizes(index = (1,t_idx)) + + assert base_t == base_plus_title_t and \ + base_t != {'min_inner_width': 0, + 'min_full_width': 0, + 'extra_used_width': 0, + 'min_inner_height': 0, + 'extra_used_height': 0, + 'top_left_loc': (0.0, 0)}, \ + ("tags based in auto creation shouldn't have finite length of "+\ + "non-zero tags (structure: %s, loc %s, t_idx: %i)") % (a_info_str[a_idx], location, t_idx) + + +def test__get_tag_and_location(): + raise ValueError("Not Tested") + +def test__get_titles_and_locations(): + raise ValueError("Not Tested") +def test__step_down_tags_info(): + raise ValueError("Not Tested") diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index ca8fdd8..6d3566a 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -63,6 +63,337 @@ def test_patch__init__(): assert len(mypatch_args2.grobs) == 3, \ "grobs can be passed through the grobs parameter indirectly" +def test_patch__get_grob_tag_ordering(): + """ + test patch's internal _get_grob_tag_ordering + """ + g0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.labs(title = 'Plot 0') + + g1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 1') + + g2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 2') + + g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", + "suv", + "pickup"])]) +\ + p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ + p9.facet_wrap("class") + + # basic option ---------- + vis0 = cow.patch(g1,g0,g2) + + + # with layout ------------ + visl1 = vis0 +\ + cow.layout(design = np.array([[1,0], + [1,2]]), + rel_heights = [1,2]) + + o_l1 = visl1._get_grob_tag_ordering() + + assert np.all(np.array([None]*3) == o_l1), \ + "if no tags, we expect tag ordering to be a array of Nones for "+\ + "each grob (with defined layout - l1)" + + visl2 = visl1 +\ + cow.annotation(title = "Combination") + + o_l2 = visl2._get_grob_tag_ordering() + + assert np.all(np.array([None]*3) == o_l2), \ + "if no tags, we expect tag ordering to be a array of Nones for "+\ + "each grob (with defined layout, some annotation - l2)" + + visl3 = visl2 + cow.annotation(tags = ["Figure 0"]) # tag_order = "auto" (input) + + o_l3 = visl3._get_grob_tag_ordering() + + assert np.all(np.array([0, None, None]) == o_l3), \ + "with tag_order =\"auto\" (leads to input) and a shorter list of "+\ + "tags than the full set observations (and with some early order "+\ + "observations without tags, one should expect some nones and so "+\ + "non-nones in the grob_tag_ordering (with defined layout - l3)" + + + visl3_1 = visl2 + cow.annotation(tags = ["Figure 0"], tags_order="input") + + o_l3_1 = visl3_1._get_grob_tag_ordering() + + + assert np.all(np.array([0, None, None]) == o_l3_1), \ + "with tag_order =\"input\" and a shorter list of "+\ + "tags than the full set observations (and with some early order "+\ + "observations without tags, one should expect some nones and so "+\ + "non-nones in the grob_tag_ordering (with defined layout - l3_1)" + + visl3_2 = visl2 + cow.annotation(tags = ["Figure 0"], tags_order="yokogaki") + + o_l3_2 = visl3_2._get_grob_tag_ordering() + + + assert np.all(np.array([None, 0, None]) == o_l3_2), \ + "with tag_order =\"yokogaki\" and a shorter list of "+\ + "tags than the full set observations (and with some early order "+\ + "observations without tags, one should expect some nones and so "+\ + "non-nones in the grob_tag_ordering (with defined layout - l3_2)" + + visl4 = visl2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", )) # tag_order = "auto" (yokogaki) + + + o_l4 = visl4._get_grob_tag_ordering() + + assert np.all(np.array([1,0,2]) == o_l4), \ + "with tag_order =\"auto\" (leads to yokogaki) and tuple for tags, "+\ + "in the grob_tag_ordering (with defined layout - o_l4)" + + + visl4_1 = visl2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", ), tags_order="input") + + o_l4_1 = visl4_1._get_grob_tag_ordering() + + assert np.all(np.array([0,1,2]) == o_l4_1), \ + "with tag_order =\"input\" and tuple for tags, "+\ + "in the grob_tag_ordering (with defined layout - o_l4_1)" + + + visl4_2 = visl2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", ), tags_order="yokogaki") + + o_l4_2 = visl4_2._get_grob_tag_ordering() + + assert np.all(np.array([1,0,2]) == o_l4_2), \ + "with tag_order =\"yokogaki\" and tuple for tags, "+\ + "in the grob_tag_ordering (with defined layout - o_l4_2)" + + + + # without layout ------------ + + vis_nol_2 = vis0 +\ + cow.annotation(title = "Combination") + + o_nol_2 = vis_nol_2._get_grob_tag_ordering() + + assert np.all(np.array([None]*3) == o_nol_2), \ + "if no tags, we expect tag ordering to be a array of Nones for "+\ + "each grob (with no defined layout - nol_2)" + + + vis_nol_3 = vis_nol_2 + cow.annotation(tags = ["Figure 0"]) # tag_order = "auto" (input) + + o_nol_3 = vis_nol_3._get_grob_tag_ordering() + + + assert np.all(np.array([0,None,None]) == o_nol_3), \ + "with tag_order =\"auto\" (leads to input) and a shorter list of "+\ + "tags than the full set observations (with no defined layout - nol_3)" + + vis_nol_3_1 = vis_nol_2 + cow.annotation(tags = ["Figure 0"], + tags_order="input") + + o_nol_3_1 = vis_nol_3_1._get_grob_tag_ordering() + + assert np.all(np.array([0,None,None]) == o_nol_3_1), \ + "with tag_order =\"input\" and a shorter list of "+\ + "tags than the full set observations (with no defined layout - nol_3_1)" + + + vis_nol_3_2 = vis_nol_2 + cow.annotation(tags = ["Figure 0"], + tags_order="yokogaki") + o_nol_3_2 = vis_nol_3_2._get_grob_tag_ordering() + + assert np.all(np.array([0,None,None]) == o_nol_3_2), \ + "with tag_order =\"yokogaki\" and a shorter list of "+\ + "tags than the full set observations (with no defined layout - nol_3_2)" + + + + vis_nol_4 = vis_nol_2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", )) # tag_order = "auto" (yokogaki) + + o_nol_4 = vis_nol_4._get_grob_tag_ordering() + + assert np.all(np.array([0,1,2]) == o_nol_4), \ + "with tag_order =\"auto\" and tuple for tags, "+\ + "in the grob_tag_ordering (with no defined layout - o_nol_4)" + + + vis_nol_4_1 = vis_nol_2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", ), tags_order="input") + + o_nol_4_1 = vis_nol_4_1._get_grob_tag_ordering() + + assert np.all(np.array([0,1,2]) == o_l4_1), \ + "with tag_order =\"input\" and tuple for tags, "+\ + "in the grob_tag_ordering (with no defined layout - o_nol_4_1)" + + vis_nol_4_2 = vis_nol_2 + cow.annotation(tags_format = ("Figure {0}",), + tags = ("0", ), tags_order="yokogaki") + + o_nol_4_2 = vis_nol_4_2._get_grob_tag_ordering() + + assert np.all(np.array([0,1,2]) == o_nol_4_2), \ + "with tag_order =\"yokogaki\" and tuple for tags, "+\ + "in the grob_tag_ordering (with no defined layout - o_nol_4_2)" + +def test_patch__get_grob_tag_ordering2(): + """ + static testing of _get_grob_tag_ordering + """ + g0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.labs(title = 'Plot 0') + + g1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 1') + + g2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 2') + + g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", + "suv", + "pickup"])]) +\ + p9.geom_histogram(p9.aes(x="hwy"), bins=10) +\ + p9.facet_wrap("class") + + my_patch = cow.patch(g0,g1,g2,g3) + my_layout = cow.layout(design = np.array([[0,0,0,3,3,3], + [0,0,0,2,2,2], + [1,1,1,2,2,2]])) + + my_annotation1 = cow.annotation(tags = ("Fig {0}",), tags_format = ("a",), + tags_order = "auto") + my_annotation2 = cow.annotation(tags = ["Fig 1", "Fig 2", "Fig 3"], # notice this isn't complete... + tags_order = "auto") + # yokogaki ---- + my_a_y1 = my_annotation1 + cow.annotation(tags_order = "yokogaki") + vis_y1 = my_patch + my_layout + my_a_y1 + + out_y1 = vis_y1._get_grob_tag_ordering() + + assert np.all(out_y1 == np.array([0,3,2,1])), \ + "yokogaki ordering incorrect (static example 1, tuple tags)" + + my_a_y1_auto = my_annotation1 + vis_y1_auto = my_patch + my_layout + my_a_y1_auto + + out_y1_auto = vis_y1_auto._get_grob_tag_ordering() + + assert np.all(out_y1_auto == np.array([0,3,2,1])), \ + "yokogaki ordering incorrect - auto tags_order (static example "+\ + "1_auto, tuple tags)" + + my_a_y2 = my_annotation2 + cow.annotation(tags_order = "yokogaki") + vis_y2 = my_patch + my_layout + my_a_y2 + + out_y2 = vis_y2._get_grob_tag_ordering() + + assert np.all(out_y2 == np.array([0,None,2,1])), \ + "yokogaki ordering incorrect (static example 2, list tags)" + + # input ---- + my_a_i1 = my_annotation1 + cow.annotation(tags_order = "input") + vis_i1 = my_patch + my_layout + my_a_i1 + + out_i1 = vis_i1._get_grob_tag_ordering() + + assert np.all(out_i1 == np.array([0,1,2,3])), \ + "input ordering incorrect (static example 1, tuple tags)" + + my_a_i2 = my_annotation2 + cow.annotation(tags_order = "input") + vis_i2 = my_patch + my_layout + my_a_i2 + + out_i2 = vis_i2._get_grob_tag_ordering() + + assert np.all(out_i2 == np.array([0,1,2,None])), \ + "input ordering incorrect (static example 2, list tags)" + + my_a_i2_auto = my_annotation2 + cow.annotation(tags_order = "auto") + vis_i2_auto = my_patch + my_layout + my_a_i2_auto + + out_i2_auto = vis_i2_auto._get_grob_tag_ordering() + + assert np.all(out_i2_auto == np.array([0,1,2,None])), \ + "input ordering incorrect - auto tag ordering (static example 2, list tags)" + + # no tags (different ways) ----- + + my_n1_a = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "auto") + vis_n1_a = my_patch + my_layout + my_n1_a + out_n1_a = vis_n1_a._get_grob_tag_ordering() + + assert np.all(out_n1_a == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, auto)" + + my_n1_y = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "yokogaki") + vis_n1_y = my_patch + my_layout + my_n1_y + out_n1_y = vis_n1_y._get_grob_tag_ordering() + + assert np.all(out_n1_y == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, yokogaki)" + + my_n1_i = my_annotation1 + \ + cow.annotation(tags = np.nan, tags_order = "input") + vis_n1_i = my_patch + my_layout + my_n1_i + out_n1_i = vis_n1_i._get_grob_tag_ordering() + + assert np.all(out_n1_i == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 1 - np.nan override, input)" + + + + + my_n2_a = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "auto") + vis_n2_a = my_patch + my_layout + my_n2_a + out_n2_a = vis_n2_a._get_grob_tag_ordering() + + assert np.all(out_n2_a == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, auto)" + + my_n2_y = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "yokogaki") + vis_n2_y = my_patch + my_layout + my_n2_y + out_n2_y = vis_n2_y._get_grob_tag_ordering() + + assert np.all(out_n2_y == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, yokogaki)" + + my_n2_i = my_annotation2 + \ + cow.annotation(tags = np.nan, tags_order = "input") + vis_n2_i = my_patch + my_layout + my_n2_i + out_n2_i = vis_n2_i._get_grob_tag_ordering() + + assert np.all(out_n2_i == np.array([None]*4)), \ + "if tags are missing, we expect the _get_grob_tag_ordering to "+\ + "return an empty array (static example 2 - np.nan override, input)" + + + vis_n_n = my_patch + my_layout + out_n_a = vis_n_n._get_grob_tag_ordering() + + + assert np.all(out_n_a == np.array([None]*4)), \ + "if annotation object itelf is missing, we expect the "+\ + "_get_grob_tag_ordering to return an empty array" + def test_patch__estimate_default_min_desired_size_NoAnnotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ @@ -88,20 +419,21 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): [0,2]]), rel_heights = [1,2]) - sug_width, sug_height = \ - vis1._estimate_default_min_desired_size() + vis1._hierarchical_general_process(approach="default-size") assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ - "suggested width incorrectly sizes the smallest width of the images (v1)" + "suggested width incorrectly sizes the smallest width of the images "+\ + "(v1)" assert np.allclose(sug_height, (3 * # 1/ rel width of smallest width of images cow.rcParams["base_height"])), \ - "suggested height incorrectly sizes the smallest height of the images (v1)" + "suggested height incorrectly sizes the smallest height of the images "+\ + "- rel_heights example (v1)" # nested option -------- @@ -111,7 +443,7 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): sug_width_n, sug_height_n = \ - vis_nested._estimate_default_min_desired_size() + vis_nested._hierarchical_general_process(approach="default-size") assert np.allclose(sug_width_n, (2 * # 1/ rel width of smallest width of images @@ -126,6 +458,8 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): "suggested height incorrectly sizes the smallest height of the images "+\ "(v2 - nested)" +# TODO: test when some plots have tags, others don't +# TODO: this test (below) still needs to be examined def test_patch__estimate_default_min_desired_size_Annotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ @@ -152,9 +486,9 @@ def test_patch__estimate_default_min_desired_size_Annotation(): rel_heights = [1,2]) +\ cow.annotation(title = "My title") - + #TODO FIX sug_width, sug_height = \ - vis1._estimate_default_min_desired_size() + vis1._hierarchical_general_process(approach="default-size") assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images @@ -250,8 +584,6 @@ def test_patch__default_size__both_none(): cow.rcParams["base_height"])), \ "_default_size incorrectly connects with _size_dive output - height (v2-nested)" -@given(st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49)) def test_patch__default_size__both_not_none(height,width): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ @@ -560,156 +892,6 @@ def test_patch__process_sizes(w1,h1,w2,h2,w3,h3): "expected max_scaling should be the max of 1/width_scale and "+\ "1/height_scale assoicated with failed plot(s) (v2.2 - 2 plot failed)" -def test_patch__get_grob_tag_ordering(): - """ - static testing of _get_grob_tag_ordering - """ - g0 = p9.ggplot(p9_data.mpg) +\ - p9.geom_bar(p9.aes(x="hwy")) +\ - p9.labs(title = 'Plot 0') - - g1 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - p9.labs(title = 'Plot 1') - - g2 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - p9.labs(title = 'Plot 2') - - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"), bins=10) +\ - p9.facet_wrap("class") - - my_patch = cow.patch(g0,g1,g2,g3) - my_layout = cow.layout(design = np.array([[0,0,0,3,3,3], - [0,0,0,2,2,2], - [1,1,1,2,2,2]])) - - my_annotation1 = cow.annotation(tags = ("Fig {0}",), tags_format = ("a",), - tags_order = "auto") - my_annotation2 = cow.annotation(tags = ["Fig 1", "Fig 2", "Fig 3"], # notice this isn't complete... - tags_order = "auto") - # yokogaki ---- - my_a_y1 = my_annotation1 + cow.annotation(tags_order = "yokogaki") - vis_y1 = my_patch + my_layout + my_a_y1 - - out_y1 = vis_y1._get_grob_tag_ordering() - - assert np.all(out_y1 == np.array([0,3,2,1])), \ - "yokogaki ordering incorrect (static example 1, tuple tags)" - - my_a_y1_auto = my_annotation1 - vis_y1_auto = my_patch + my_layout + my_a_y1_auto - - out_y1_auto = vis_y1_auto._get_grob_tag_ordering() - - assert np.all(out_y1_auto == np.array([0,3,2,1])), \ - "yokogaki ordering incorrect - auto tags_order (static example "+\ - "1_auto, tuple tags)" - - my_a_y2 = my_annotation2 + cow.annotation(tags_order = "yokogaki") - vis_y2 = my_patch + my_layout + my_a_y2 - - out_y2 = vis_y2._get_grob_tag_ordering() - - assert np.all(out_y2 == np.array([0,3,2,1])), \ - "yokogaki ordering incorrect (static example 2, list tags)" - - # input ---- - my_a_i1 = my_annotation1 + cow.annotation(tags_order = "input") - vis_i1 = my_patch + my_layout + my_a_i1 - - out_i1 = vis_i1._get_grob_tag_ordering() - - assert np.all(out_i1 == np.array([0,1,2,3])), \ - "input ordering incorrect (static example 1, tuple tags)" - - my_a_i2 = my_annotation2 + cow.annotation(tags_order = "input") - vis_i2 = my_patch + my_layout + my_a_i2 - - out_i2 = vis_i2._get_grob_tag_ordering() - - assert np.all(out_i2 == np.array([0,1,2,3])), \ - "input ordering incorrect (static example 2, list tags)" - - my_a_i2_auto = my_annotation2 + cow.annotation(tags_order = "auto") - vis_i2_auto = my_patch + my_layout + my_a_i2_auto - - out_i2_auto = vis_i2_auto._get_grob_tag_ordering() - - assert np.all(out_i2_auto == np.array([0,1,2,3])), \ - "input ordering incorrect - auto tag ordering (static example 2, list tags)" - - # no tags (different ways) ----- - - my_n1_a = my_annotation1 + \ - cow.annotation(tags = np.nan, tags_order = "auto") - vis_n1_a = my_patch + my_layout + my_n1_a - out_n1_a = vis_n1_a._get_grob_tag_ordering() - - assert np.all(out_n1_a == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 1 - np.nan override, auto)" - - my_n1_y = my_annotation1 + \ - cow.annotation(tags = np.nan, tags_order = "yokogaki") - vis_n1_y = my_patch + my_layout + my_n1_y - out_n1_y = vis_n1_y._get_grob_tag_ordering() - - assert np.all(out_n1_y == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 1 - np.nan override, yokogaki)" - - my_n1_i = my_annotation1 + \ - cow.annotation(tags = np.nan, tags_order = "input") - vis_n1_i = my_patch + my_layout + my_n1_i - out_n1_i = vis_n1_i._get_grob_tag_ordering() - - assert np.all(out_n1_i == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 1 - np.nan override, input)" - - - - - my_n2_a = my_annotation2 + \ - cow.annotation(tags = np.nan, tags_order = "auto") - vis_n2_a = my_patch + my_layout + my_n2_a - out_n2_a = vis_n2_a._get_grob_tag_ordering() - - assert np.all(out_n2_a == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 2 - np.nan override, auto)" - - my_n2_y = my_annotation2 + \ - cow.annotation(tags = np.nan, tags_order = "yokogaki") - vis_n2_y = my_patch + my_layout + my_n2_y - out_n2_y = vis_n2_y._get_grob_tag_ordering() - - assert np.all(out_n2_y == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 2 - np.nan override, yokogaki)" - - my_n2_i = my_annotation2 + \ - cow.annotation(tags = np.nan, tags_order = "input") - vis_n2_i = my_patch + my_layout + my_n2_i - out_n2_i = vis_n2_i._get_grob_tag_ordering() - - assert np.all(out_n2_i == np.array([])), \ - "if tags are missing, we expect the _get_grob_tag_ordering to "+\ - "return an empty array (static example 2 - np.nan override, input)" - - - vis_n_n = my_patch + my_layout - out_n_a = vis_n_n._get_grob_tag_ordering() - - - assert np.all(out_n_a == np.array([])), \ - "if annotation objet itelf is missing, we expect the "+\ - "_get_grob_tag_ordering to return an empty array" - # global savings and showing and creating ------ def _layouts_and_patches_patch_plus_layout(idx): From 918dd53086266cc37f17762a916a4e6435225c7b Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 18 Dec 2022 17:02:17 -0800 Subject: [PATCH 04/13] minor improvements within complete overall - incomplete --- src/cowpatch/annotation_elements.py | 68 +++++++---- src/cowpatch/base_elements.py | 175 +++++++++++++++++++++------- src/cowpatch/text_elements.py | 5 +- tests/test_annotation_elements.py | 16 ++- tests/test_base_elements.py | 70 ++++++++--- 5 files changed, 248 insertions(+), 86 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 2214c3c..1cb74b7 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -59,9 +59,9 @@ def __init__(self, title=None, subtitle=None, caption=None, or ("0","0"). The default, if tags on auto-constructed, is ("{0}", "{0}.{1}", "{0}.{1}.{2}", "{0}.{1}.{2}.{3}", ...) tags_order : str ["auto", "input", "yokogaki"] - How we the tags. If auto, the default is by "input" if you provide - your own labels and "yokogaki" if you don't. "Input" means that the - tags ordering will be assoicated with the grobs ordering. + How we orderthe tags. If auto, the default is by "input" if you + provide your own labels and "yokogaki" if you don't. "Input" means + that the tags ordering will be assoicated with the grobs ordering. "Yokogaki" means that the tags will be associated with the top-to-bottom, left-to-right ordering of the grobs. tags_loc : str ["top", "left", "right", "bottom"] @@ -245,7 +245,7 @@ def _update_all_attributes(self, title=None, subtitle=None, caption=None, elif not inherits(tags, list) and not inherits(tags, tuple) and tags is not None: raise ValueError("tags should be either a list or tuple") elif inherits(tags, list): - self.tags = (tags) + self.tags = (tags,) elif tags is not None: self.tags = tags @@ -343,7 +343,6 @@ def _get_tag(self, index=(0,)): ----- this should return objects relative to correct rotation... """ - pdb.set_trace() if inherits(index, int): index = (index,) @@ -356,14 +355,23 @@ def _get_tag(self, index=(0,)): if np.max(indices_used) > len(index)-1: raise ValueError("tags_format has more indices than the tag hierarchy has.") - et = copy.deepcopy(self.tags_format[len(index)-1]) - et.label = et.label.format( - *[self._get_index_value(i,x) for i,x in enumerate(index)]) - + if np.all([True if not inherits(self.tags[i], list) + else True if len(self.tags[i]) > x + else False for i, x in enumerate(index)]): + et = copy.deepcopy(self.tags_format[len(index)-1]) + et.label = et.label.format( + *[self._get_index_value(i,x) if not inherits(self.tags[i], list) + else self.tags[i][x] if len(self.tags[i]) > x + else "" for i,x in enumerate(index) + ]) + else: + et = text(label = "", _type = "cow_tag") return et + + def _get_index_value(self, level=0, index=0): """ provide index level of a tag @@ -455,14 +463,9 @@ def _calculate_tag_margin_sizes(self, index=(0,), tuple of top left corner of inner image relative to title text """ - - # clean-up - if not inherits(index, tuple): - index = (index, ) - - # if we shouldn't actually make the tag - if self.tags_depth != len(index) and not fundamental: + if index is None or \ + (self.tags_depth != len(index) and not fundamental): return {"min_inner_width": 0, "min_full_width": 0, # not able to be nonzero for tag "extra_used_width": 0, @@ -471,6 +474,10 @@ def _calculate_tag_margin_sizes(self, index=(0,), "top_left_loc": (0,0) } + # clean-up + if not inherits(index, tuple): + index = (index, ) + # getting tag ------------------- tag = self._get_tag(index=index) tag_sizes = tag._min_size(to_inches=to_inches) @@ -504,13 +511,27 @@ def _get_tag_and_location(self, width, height, index = (0,), fundamental=False): """ + create desired tag and identify location to place tag and associated + image + + Arguments + --------- + width : float + width in pt + height : float + height in pt + index : tuple + index of the tag. The size of the tuple captures + depth. Return ------ tag_loc : tuple upper left corner location for tag image_loc : tuple - upper left corner location for image (assoicated with tag) + upper left corner location for image (assoicated with tag). If + the tag is on the top, this means where the corner of the image + should be placed to correctly be below the tag. tag_image : tag text svg object """ @@ -650,7 +671,10 @@ def _get_titles_and_locations(self, width, height): ------- out_list : list list of tuples of the location to place the title (top left corner) - and the image of the title itself + and the image of the title itself. The list has entries for any + titles, then any subtitles and then the caption (if any). Each + entry is a tuple with (1) a tuple of the top left corner location + for the title and (2) the svg object of the title itself Notes ----- @@ -859,6 +883,7 @@ def _update_tdict_info(self, t, current_t = dict(), _type = "title"): return current_t + def _step_down_tags_info(self, parent_index): """ Create an updated version of tags_info for children @@ -873,9 +898,9 @@ def _step_down_tags_info(self, parent_index): annotation with all tag attributes to update for children's annotation. """ - #TODO: likely can remove some of the other index stuff due to passing - - if len(self.tags) <= 1 or len(self.tags_format) <= 1: + if self.tags is None or \ + len(self.tags) <= 1 or \ + len(self.tags_format) <= 1: return annotation() tags = self.tags[1:] @@ -902,6 +927,7 @@ def _step_down_tags_info(self, parent_index): return inner_annotation + def __add__(self, other): """ update annotations through addition diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index daba6c8..7fde595 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -146,9 +146,14 @@ def annotation(self): defines ``annotation`` that either returns the most up-to-date ``cow.annotation`` object or the default ``annotation`` if no annotation has been explicitly defined + + Details + ------- + if no annotation is provide, we do make sure that + "tags_inherit='override'" """ if self.__annotation is None: - return annotation() + return annotation(tags_inherit="override") else: return self.__annotation @@ -250,10 +255,18 @@ def __mul__(self, other): def __and__(self, other): raise ValueError("currently not implimented &") - def _get_grob_tag_ordering(self): + def _get_grob_tag_ordering(self, cur_annotation=None): """ get ordering of tags related to grob index + Arguments + --------- + cur_annotation : annotation object + annotation object if we are not going to use the patch's own + annotation object but some alternation from a parent. If this + is None, we shall be using the internal annotation object + associated with the patch. + Returns ------- numpy array of tag order for each grob @@ -262,14 +275,18 @@ def _get_grob_tag_ordering(self): ---- This function leverages the patch's layout and annotation objects """ + + if cur_annotation is None: + cur_annotation = self.annotation + self._check_layout() - if self.annotation.tags is None: + if cur_annotation.tags is None or cur_annotation.tags[0] is None: return np.array([None]*len(self.grobs)) - tags_order = self.annotation.tags_order + tags_order = cur_annotation.tags_order if tags_order == "auto": - if inherits(self.annotation.tags,list): + if inherits(cur_annotation.tags[0],list): tags_order = "input" else: tags_order = "yokogaki" @@ -283,10 +300,11 @@ def _get_grob_tag_ordering(self): raise ValueError("patch's annotation's tags_order is not an expected option") # list correction - if inherits(self.annotation.tags, list) and \ - len(self.annotation.tags) < len(self.grobs): + if cur_annotation is not None and \ + inherits(cur_annotation.tags[0], list) and \ + len(cur_annotation.tags[0]) < len(self.grobs): out_array = np.array(out_array, dtype = object) # to allow for None - out_array[out_array > len(self.annotation.tags)-1] = None + out_array[out_array > len(cur_annotation.tags[0])-1] = None return out_array @@ -725,14 +743,64 @@ def _hierarchical_general_process(self, Arguments --------- - (width, height): tuple of integers + width: float + probably inches based - not 100% sure + height: float probably inches based - not 100% sure data_dict : dictionary dictionary of data to pass to children patches approach : str string of which approach should be used + + Details + ------- + This internal function is likely hard to read, and can be used to + accomplish 3 different tasks that require depth-search style progression + through grobs. For easier read, we would recommend selecting one of the + approaches and then folding / code-collapsing all the conditional + statements that refer to the other approaches. Below are details about + each of the approaches including information about how the parameters + are used. + + "default-size": + + This approach calculates a default-size for the patch to observe a + minimum width and height of a images (as stored in rcParams) and for all + titles, subtitles, captions, and tags to have enough space to be + correctly presented. For the + arguments, (width, height) are ___ and the data_dict may have the + attribute "default-size-proportion" which should be a tuple of positive + floats (less than or equal to 1) that captures the relative size of this + patch to the overall. + + The math for the "default-size" is pretty basic, and based on the idea + that for each plot and associated captions/titles one could describe + the associated `smallest_overall_size` would be the maximum of (the + required minimum size of the plot + `required_size_of_text`) times + the scaling factory to get from the overall patch to the interior plot. + Note that this is does for height and width separately. + + all: + + The data_dict can include an attribute "parent-index" which should be + a tuple of the parent's index (used for tag tracking). The data_dict can + include an attribute "parent-guided-annotation-update" which contains + an annotation object that the parent is providing. + """ + # initialization section ---------------------------------------------- + + cur_annotation = copy.deepcopy(self.annotation) + + if data_dict is not None and \ + data_dict.get("parent-guided-annotation-update") is not None: + + # should only actually update if tags_inherit="override" within + # cur_annotation + cur_annotation += data_dict["parent-guided-annotation-update"] + + # prep section: annotation global size corrections -------------------- ## global annotation sizing @@ -744,10 +812,23 @@ def _hierarchical_general_process(self, raise ValueError("if using approach \"default-size\", "+\ "height and width should be greater or "+\ "equal to one.") - if data_dict is None or data_dict["default-size-proportion"] is None: + # TODO: why do we care they are greater than 1? + if data_dict is None or \ + data_dict.get("default-size-proportion") is None: + if data_dict is None: data_dict = dict() data_dict["default-size-proportion"] = (1,1) + + # data_dict check + if not inherits(data_dict["default-size-proportion"], tuple) or \ + len(data_dict["default-size-proportion"]) != 2 or \ + not np.all(np.array(data_dict["default-size-proportion"]) > 0) or \ + not np.all(np.array(data_dict["default-size-proportion"]) <= 1): + + raise ValueError("data_dict[\"default-size-proportion\"] must " +\ + "be a 2 length tuple of non-negative floats (less " +\ + "than or equal to 1") overall_default_size = (0,0) else: # using default sizings @@ -765,7 +846,8 @@ def _hierarchical_general_process(self, if True: # all approaches - title_margin_sizes_dict = self.annotation._calculate_margin_sizes() + title_margin_sizes_dict = \ + cur_annotation._calculate_margin_sizes(to_inches=True) text_used_width, text_used_height, = \ title_margin_sizes_dict["extra_used_width"], \ title_margin_sizes_dict["extra_used_height"] @@ -805,7 +887,7 @@ def _hierarchical_general_process(self, #### process titles/subtitles/captions titles_and_locs = \ - self.annotation._get_titles_and_locations(width = width, + cur_annotation._get_titles_and_locations(width = width, height = height) for loc_tuple, title in titles_and_locs: @@ -813,7 +895,7 @@ def _hierarchical_general_process(self, #### sizes of inner grobs: - if data_dict is None or data_dict["sizes"] is None: + if data_dict is None or data_dict.get("sizes") is None: sizes = self._hierarchical_general_process(width = 1, height = 1, approach = "size") @@ -829,7 +911,9 @@ def _hierarchical_general_process(self, width_pt=from_inches(width_inner, units="pt"), # TODO: need to check all pt vs inch assumptions height_pt=from_inches(height_inner, units="pt"), num_grobs=len(self.grobs)) - tag_index_array = self._get_grob_tag_ordering() + tag_index_array = self._get_grob_tag_ordering( + cur_annotation=cur_annotation + ) # process section: loop through heirarchical structure ---------------- for p_idx, image in enumerate(self.grobs): @@ -837,12 +921,13 @@ def _hierarchical_general_process(self, if True: # all approaches inner_area = areas[p_idx] + # TODO: start here if tag_index_array[p_idx] is not None: grob_tag_index = tag_index_array[p_idx] - fundamental_tag = not inherits(self.grobs[p_idx], patch) + fundamental_tag = not inherits(image, patch) - if date_dict is None or data_dict["parent-index"] is None: + if data_dict is None or data_dict.get("parent-index") is None: current_index = (grob_tag_index,) else: current_index = tuple(list(data_dict["parent-index"])+\ @@ -851,12 +936,15 @@ def _hierarchical_general_process(self, else: fundamental_tag = False + grob_tag_index = None current_index = () - - tag_margin_dict = self.annotation._calculate_tag_margin_sizes( + + + tag_margin_dict = cur_annotation._calculate_tag_margin_sizes( fundamental=fundamental_tag, - index=current_index) # should return empty dict... - + index=grob_tag_index, + to_inches=True) + if approach in ["create", "size"]: grob_width, grob_height = \ @@ -866,45 +954,55 @@ def _hierarchical_general_process(self, ### create tag if approach == "create": tag_loc, image_loc, tag_image = \ - self.annotation._get_tag_and_location( + cur_annotation._get_tag_and_location( width=inner_area.width, height=inner_area.height, - index=current_index, + index=grob_tag_index, fundamental=fundamental_tag) _add_to_base_image(base_image, tag_image, tag_loc) ## grob processing - if inherits(self.grobs[p_idx], patch): + if inherits(image, patch): ### default sizing + data_dict_pass_through = data_dict.copy() + data_dict_pass_through["parent-index"] = current_index + data_dict_pass_through["parent-guided-annotation-update"] = \ + cur_annotation._step_down_tags_info(parent_index = current_index) + if approach == "default-size": - default_size_prop = (inner_area.width,inner_area.height) + default_size_prop = (inner_area.width, inner_area.height) + data_dict_pass_through["default-size-proportion"] = \ + default_size_prop + + default_inner_size = self.grobs[p_idx]._hierarchical_general_process( - width=1, - height=1, - data_dict= - {"default-size-proportion": default_size_prop}, + data_dict=data_dict_pass_through, approach="default-size") ### sizing estimation if approach == "size": + data_dict_pass_through["size-node-level"] = \ + data_dict["size-node-level"] + 1 + inner_sizes, inner_size_multiplier = \ - self.grobs[p_idx]._hierarchical_general_process( + image._hierarchical_general_process( width=grob_width, height=grob_height, - data_dict={"size-node-level": data_dict["size-node-level"] + 1}, + data_dict=data_dict_pass_through, approach="size") sizes_list.append(inner_sizes) size_multiplier.append(inner_size_multiplier) + ### saving / showing if approach == "create": - grob_image = self.grobs[p_idx]._hierarchical_general_process( + data_dict_pass_through["size"] = sizes[p_idx] + grob_image = image._hierarchical_general_process( width=grob_width, height=grob_height, - data_dict={"size":sizes[p_idx], - "parent-index":current_index}, + data_dict=data_dict_pass_through, approach="create") - elif inherits_plotnine(self.grobs[p_idx]): + elif inherits_plotnine(image): ### default sizing if approach == "default-size": default_inner_size = ( @@ -940,12 +1038,12 @@ def _hierarchical_general_process(self, size_multiplier.append(1) ### saving / showing if approach == "create": - grob_image = _raw_gg_to_svg(self.grobs[p_idx], + grob_image = _raw_gg_to_svg(image, width = sizes[p_idx][0], height = sizes[p_idx][1], dpi = 96) - elif inherits(self.grobs[p_idx], text): + elif inherits(image, text): ### default sizing if approach == "default-size": m_w,m_h = self.grobs[p_idx]._min_size() @@ -957,7 +1055,7 @@ def _hierarchical_general_process(self, ) ### sizing estimation if approach == "size": - m_w, m_h = self.grobs[p_idx]._min_size() + m_w, m_h = image._min_size() if m_w > grob_width or m_h > grob_height: # track needed size change inner_size_multiplier = np.max([m_w/grob_width, m_h/grob_height]) @@ -968,7 +1066,7 @@ def _hierarchical_general_process(self, size_multiplier.append(1) ### saving / showing if approach == "create": - grob_image = self.grobs[p_idx]._svg( + grob_image = image._svg( width_pt=sizes[p_idx][0], height_pt=sizes[p_idx][1]) else: @@ -1005,7 +1103,6 @@ def _hierarchical_general_process(self, # post processing section: cleaning up ### sizing estimation - pdb.set_trace() if approach == "default-size": default_inner_size = ( np.max([overall_default_size[0], diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index 0df8ef7..28e0506 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -15,6 +15,7 @@ from .utils import to_pt, from_pt, to_inches, from_inches, \ _transform_size_to_pt, inherits +from .utils import to_inches as _to_inches from .svg_utils import _show_image, _save_svg_wrapper from .config import rcParams @@ -276,8 +277,8 @@ def _min_size(self, to_inches=False): plt.close() if to_inches: - min_width = to_inches(min_width_pt, units = "pt") - min_height = to_inches(min_height_pt, units = "pt") + min_width = _to_inches(min_width_pt, units = "pt") + min_height = _to_inches(min_height_pt, units = "pt") else: min_width = min_width_pt min_height = min_height_pt diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index 93159dd..20f08a2 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -695,12 +695,18 @@ def test__calculate_tag_margin_sizes(location): def test__get_tag_and_location(): + # to test we need to make some tags, + # get a good undestanding of the size of the tag (height & width) + # look through 4 different locations for the tag + # also look depths down + # and fundamental-ness + # identify where it should land and where the image should land + # create a similar tag and raise ValueError("Not Tested") def test__get_titles_and_locations(): + # create a set of static combinations of + # titles, subtitles, captions (in different locations) + # identify expected locations and potentially try to create + # svg objects of the image themselves to compare the output too raise ValueError("Not Tested") - -def test__step_down_tags_info(): - raise ValueError("Not Tested") - - diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index 6d3566a..38eab8e 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -427,13 +427,13 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ "suggested width incorrectly sizes the smallest width of the images "+\ - "(v1)" + "(v1, no annotation)" assert np.allclose(sug_height, (3 * # 1/ rel width of smallest width of images cow.rcParams["base_height"])), \ "suggested height incorrectly sizes the smallest height of the images "+\ - "- rel_heights example (v1)" + "- rel_heights example (v1, no annotation)" # nested option -------- @@ -450,16 +450,17 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ "suggested width incorrectly sizes the smallest width of the images "+\ - "(v2 - nested)" + "(v2 - nested, no annotation)" assert np.allclose(sug_height_n, (3 * # 1/ rel width of smallest width of images cow.rcParams["base_height"])), \ "suggested height incorrectly sizes the smallest height of the images "+\ - "(v2 - nested)" + "(v2 - nested, no annotation)" # TODO: test when some plots have tags, others don't # TODO: this test (below) still needs to be examined +# See "TODO FIX" inside plot def test_patch__estimate_default_min_desired_size_Annotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ @@ -473,12 +474,6 @@ def test_patch__estimate_default_min_desired_size_Annotation(): p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ p9.labs(title = 'Plot 2') - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ - p9.facet_wrap("class") - # basic option ---------- vis1 = cow.patch(g0,g1,g2) +\ cow.layout(design = np.array([[0,1], @@ -486,7 +481,6 @@ def test_patch__estimate_default_min_desired_size_Annotation(): rel_heights = [1,2]) +\ cow.annotation(title = "My title") - #TODO FIX sug_width, sug_height = \ vis1._hierarchical_general_process(approach="default-size") @@ -494,35 +488,73 @@ def test_patch__estimate_default_min_desired_size_Annotation(): (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ - "suggested width incorrectly sizes the smallest width of the images (v1)" + "suggested width incorrectly sizes the smallest width of the images (v1, annotation)" assert np.allclose(sug_height, (3 * # 1/ rel width of smallest width of images - cow.rcParams["base_height"])), \ - "suggested height incorrectly sizes the smallest height of the images (v1)" + cow.rcParams["base_height"] +\ + cow.text("My title", _type="cow_title").\ + _min_size(to_inches=True)[1] + ) + ), \ + "suggested height incorrectly sizes the smallest height of the images (v1, annotation)" # nested option -------- vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ cow.layout(ncol=1, rel_heights = [1,2])) +\ - cow.layout(nrow=1) + cow.layout(nrow=1) +\ + cow.annotation(subtitle = "My subtitle") sug_width_n, sug_height_n = \ - vis_nested._estimate_default_min_desired_size() + vis_nested._hierarchical_general_process(approach="default-size") assert np.allclose(sug_width_n, (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ "suggested width incorrectly sizes the smallest width of the images "+\ - "(v2 - nested)" + "(v2 - nested, annotation)" assert np.allclose(sug_height_n, (3 * # 1/ rel width of smallest width of images - cow.rcParams["base_height"])), \ + cow.rcParams["base_height"] +\ + cow.text("My subtitle", _type="cow_subtitle").\ + _min_size(to_inches=True)[1] + )), \ + "suggested height incorrectly sizes the smallest height of the images "+\ + "(v2 - nested, annotation)" + + # tag nested option ----------- + vis_nested_tag = cow.patch(g0,cow.patch(g1,g2)+\ + cow.layout(ncol=1, rel_heights = [1,2]) +\ + cow.annotation(tags_inherit="override")) +\ + cow.layout(nrow=1) +\ + cow.annotation(caption = "My caption") +\ + cow.annotation(tags=("0", "a"), tags_format=("Fig {0}", "Fig {0}.{1}"), + tags_loc="top") + + sug_width_nt, sug_height_nt = \ + vis_nested_tag._hierarchical_general_process(approach="default-size") + + assert np.allclose(sug_width_nt, + (2 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"] * + cow.rcParams["base_aspect_ratio"])), \ + "suggested width incorrectly sizes the smallest width of the images "+\ + "(v2 - nested + tagged, annotation)" + + # TODO: this looks like the tag structure isn't being correctly taken into acount + assert np.allclose(sug_height_nt, + (3 * # 1/ rel width of smallest width of images (and include the caption and 1 tag) + (cow.rcParams["base_height"] +\ + cow.text("Fig 01ab", _type="cow_tag").\ + _min_size(to_inches=True)[1]) +\ + cow.text("My caption", _type="cow_caption").\ + _min_size(to_inches=True)[1])), \ "suggested height incorrectly sizes the smallest height of the images "+\ - "(v2 - nested)" + "(v2 - nested + tagged, annotation)" def test_patch__default_size__both_none(): """ From 522fbd694f44fcb66a18243307b073c9a3afff89 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 8 Jan 2023 12:22:03 -0800 Subject: [PATCH 05/13] intermediate work --- src/cowpatch/annotation_elements.py | 1 + src/cowpatch/base_elements.py | 10 +++++++--- tests/test_base_elements.py | 2 ++ 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 1cb74b7..9cd60bf 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -346,6 +346,7 @@ def _get_tag(self, index=(0,)): if inherits(index, int): index = (index,) + pdb.set_trace() if len(self.tags_format) < len(index): raise ValueError("tags_format tuple has less indices than _get_tag index suggests") diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index 7fde595..4989601 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -939,10 +939,14 @@ def _hierarchical_general_process(self, grob_tag_index = None current_index = () - + # TODO: should it be index=grob_tag_index or index=current_index? + # due to the function _step_down_tags_info, I think we need to + # update the _calculate_tag_margin_sizes to actual depth + # (say length of current_index), but should only use + # the _step_down_tags_info tag_margin_dict = cur_annotation._calculate_tag_margin_sizes( fundamental=fundamental_tag, - index=grob_tag_index, + index=current_index, to_inches=True) @@ -966,7 +970,7 @@ def _hierarchical_general_process(self, if inherits(image, patch): ### default sizing data_dict_pass_through = data_dict.copy() - data_dict_pass_through["parent-index"] = current_index + data_dict_pass_through["parent-index"] = data_dict_pass_through["parent-guided-annotation-update"] = \ cur_annotation._step_down_tags_info(parent_index = current_index) diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index 38eab8e..4630224 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -546,6 +546,8 @@ def test_patch__estimate_default_min_desired_size_Annotation(): "(v2 - nested + tagged, annotation)" # TODO: this looks like the tag structure isn't being correctly taken into acount + # it looks like it's not stepping down correctly (tags_format versus index) + # probably associated with _step_down_tags_info assert np.allclose(sug_height_nt, (3 * # 1/ rel width of smallest width of images (and include the caption and 1 tag) (cow.rcParams["base_height"] +\ From ae3061e6d35d83367d39500b8a6b9aeb0353921c Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 15 Jan 2023 16:06:35 -0800 Subject: [PATCH 06/13] minor removal of completed todo comment in annotations --- src/cowpatch/annotation_elements.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 9cd60bf..93c0c41 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -602,8 +602,6 @@ def _calculate_margin_sizes(self, to_inches=False): extra height required for title & subtitles on top or bottom top_left_loc : tuple tuple of top left corner of inner image relative to title text - - TODO: need to make sure left/right objects are correctly rotated... [9/22 I think this is done] """ min_inner_width = \ np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.title.get("top"), From 5955b5e57690a8fb48d9f79908d8c236809f442c Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 22 Jan 2023 14:20:11 -0800 Subject: [PATCH 07/13] updates to text object and testing - testing still incomplete specifically related to _svg required update --- src/cowpatch/text_elements.py | 28 +- tests/test_text_elements.py | 320 +++++++++++++++++- .../test__base_text_image2_cow_caption_.png | Bin 0 -> 2302 bytes .../test__base_text_image2_cow_subtitle_.png | Bin 0 -> 2489 bytes .../test__base_text_image2_cow_tag_.png | Bin 0 -> 2500 bytes .../test__base_text_image2_cow_text_.png | Bin 0 -> 2441 bytes .../test__base_text_image2_cow_title_.png | Bin 0 -> 2500 bytes .../test__base_text_image_cow_caption_.png | Bin 0 -> 2302 bytes .../test__base_text_image_cow_subtitle_.png | Bin 0 -> 2625 bytes .../test__base_text_image_cow_tag_.png | Bin 0 -> 2713 bytes .../test__base_text_image_cow_text_.png | Bin 0 -> 2430 bytes .../test__base_text_image_cow_title_.png | Bin 0 -> 2713 bytes tests/test_text_elements/test__min_size2.yml | 15 + ...st_text__base_text_image2_cow_caption_.png | Bin 0 -> 2302 bytes ...t_text__base_text_image2_cow_subtitle_.png | Bin 0 -> 2489 bytes .../test_text__base_text_image2_cow_tag_.png | Bin 0 -> 2500 bytes .../test_text__base_text_image2_cow_text_.png | Bin 0 -> 2441 bytes ...test_text__base_text_image2_cow_title_.png | Bin 0 -> 2500 bytes ...est_text__base_text_image_cow_caption_.png | Bin 0 -> 2302 bytes ...st_text__base_text_image_cow_subtitle_.png | Bin 0 -> 2625 bytes .../test_text__base_text_image_cow_tag_.png | Bin 0 -> 2713 bytes .../test_text__base_text_image_cow_text_.png | Bin 0 -> 2430 bytes .../test_text__base_text_image_cow_title_.png | Bin 0 -> 2713 bytes .../test_text__min_size2.yml | 15 + 24 files changed, 366 insertions(+), 12 deletions(-) create mode 100644 tests/test_text_elements/test__base_text_image2_cow_caption_.png create mode 100644 tests/test_text_elements/test__base_text_image2_cow_subtitle_.png create mode 100644 tests/test_text_elements/test__base_text_image2_cow_tag_.png create mode 100644 tests/test_text_elements/test__base_text_image2_cow_text_.png create mode 100644 tests/test_text_elements/test__base_text_image2_cow_title_.png create mode 100644 tests/test_text_elements/test__base_text_image_cow_caption_.png create mode 100644 tests/test_text_elements/test__base_text_image_cow_subtitle_.png create mode 100644 tests/test_text_elements/test__base_text_image_cow_tag_.png create mode 100644 tests/test_text_elements/test__base_text_image_cow_text_.png create mode 100644 tests/test_text_elements/test__base_text_image_cow_title_.png create mode 100644 tests/test_text_elements/test__min_size2.yml create mode 100644 tests/test_text_elements/test_text__base_text_image2_cow_caption_.png create mode 100644 tests/test_text_elements/test_text__base_text_image2_cow_subtitle_.png create mode 100644 tests/test_text_elements/test_text__base_text_image2_cow_tag_.png create mode 100644 tests/test_text_elements/test_text__base_text_image2_cow_text_.png create mode 100644 tests/test_text_elements/test_text__base_text_image2_cow_title_.png create mode 100644 tests/test_text_elements/test_text__base_text_image_cow_caption_.png create mode 100644 tests/test_text_elements/test_text__base_text_image_cow_subtitle_.png create mode 100644 tests/test_text_elements/test_text__base_text_image_cow_tag_.png create mode 100644 tests/test_text_elements/test_text__base_text_image_cow_text_.png create mode 100644 tests/test_text_elements/test_text__base_text_image_cow_title_.png create mode 100644 tests/test_text_elements/test_text__min_size2.yml diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index d638dde..103b779 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -288,6 +288,22 @@ def _min_size(self, to_inches=False): def _base_text_image(self, close=True): """ + Create base figure and obtain bbox structure that contains + the image in the figure + + Arguments + --------- + close : boolean + if we should close the matplotlib plt instance (default True) + + Returns + ------- + fig : matplotlib figure + figure of the text (in the smallest area possible) + bbox : matplotlib bbox object + bbox for the associated figure (describing bounding box + of text object in the image) + Note ---- this code is simlar to that in _min_size @@ -317,7 +333,7 @@ def _base_text_image(self, close=True): return fig, bbox - def _default_size(self, width, height): + def _default_size(self, width=None, height=None): """ (Internal) obtain default recommended size of overall text object if width or height is None @@ -336,11 +352,17 @@ def _default_size(self, width, height): width : float returns default width for given object if not provided (else just returns provided value). If only height is provided then width - proposed is the minimum width (including margins). + proposed is the minimum width (including margins). THIS IS IN INCHES height : float returns default height for given object if not provided (else just returns provided value). If only width is provided then height - proposed is the minimum height (including margins). + proposed is the minimum height (including margins). THIS IS IN INCHES + + Notes + ----- + This function is internal, it does assume that in width/height is not + None that you've done your homework and not set width or height as too + small. """ if width is not None and height is not None: return width, height diff --git a/tests/test_text_elements.py b/tests/test_text_elements.py index 8c11dc9..51fa17d 100644 --- a/tests/test_text_elements.py +++ b/tests/test_text_elements.py @@ -5,11 +5,25 @@ import copy import numpy as np -def test__clean_element_text(): +from pytest_regressions import data_regression, image_regression +from hypothesis import given, strategies as st, settings +from datetime import timedelta + +import io +import re +import matplotlib.pyplot as plt + +import pdb + +# text object --------------------- + +def test_text__clean_element_text(): """ test that element_text stored inside is always a p9.themes.themeable (static) Both through initialization and addition + + tests __init__ and _clean_element_text """ et_simple = p9.element_text(size = 12) @@ -31,7 +45,7 @@ def test__clean_element_text(): "internal saving of element_text is expected to be a themable.text "+\ "(not a element_text) - updated with addition" -def test__add__(): +def test_text__add__(): """ test addition (static) @@ -57,7 +71,7 @@ def test__add__(): "expected that the __add__ updates and creates a new object and " +\ "doesn't update the previous text object directly" -def test__update_element_text_from_theme(): +def test_text__update_element_text_from_theme(): """ test of _update_element_text_from_theme (static test) """ @@ -91,7 +105,7 @@ def test__update_element_text_from_theme(): "expected theme to update element_text of text object "+\ "(with element_text default)" -def test__update_element_text_from_theme2(): +def test_text__update_element_text_from_theme2(): """ test _update_element_text_from_theme @@ -225,7 +239,7 @@ def test__update_element_text_from_theme2(): "text object, so we expect an error (c, theme 2)" -def test__get_full_element_text(): +def test_text__get_full_element_text(): """ test _get_full_element_text (static test - using _type text not cow_text, etc.) """ @@ -253,7 +267,7 @@ def test__get_full_element_text(): (("expected all properties but key=size (key = %s) to match "+\ "if all inherits properties from global theme") % key) -def test__get_full_element_text2(): +def test_text__get_full_element_text2(): """ test _get_full_element_text, static @@ -304,16 +318,304 @@ def test__get_full_element_text2(): "with default size information (_type :%s, key: %s)" % (_type, key) -def test__min_size(): +def test_text__min_size(): """ test _min_size (static) This function only checks relative sizes reported from _min_size """ - pass + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + + # base option + a = cow.text("Fig 1", _type = _type) + + out = a._min_size() + out_in = a._min_size(to_inches=True) + + assert np.all([np.allclose(cow.utils.to_inches(out[i], "pt"), out_in[i]) + for i in [0,1]]), \ + ("error in _min_size's application of `to_inches` parameter "+ + "type: %s, test 1" % _type) + + a_long = cow.text("Fig 1 Fig 1", _type = _type) + out_long = a_long._min_size() + + assert (3 * out[0]> out_long[0] > 2 * out[0]) and \ + np.allclose(out_long[1], out[1]), \ + ("if text size 2x+ in length, relative sizes should match, " + + "type: %s, text 1" % _type) + + a_tall = cow.text("Fig 1\nFig 1", _type = _type) + out_tall = a_tall._min_size() + + assert (2.5 * out[1] > out_tall[1] > 1.5 * out[1]) and \ + np.allclose(out_tall[0], out[0]), \ + ("if text size ~2x in height, relative sizes should match, " + + "type: %s, text 1" % _type) + + # rotated text 90 degrees + a2 = a + p9.element_text(angle = 90) + + out2 = a2._min_size() + out2_in = a2._min_size(to_inches=True) + + assert np.all([np.allclose(cow.utils.to_inches(out2[i], "pt"), out2_in[i]) + for i in [0,1]]), \ + ("error in _min_size's application of `to_inches` parameter "+ + "type: %s, test 2" % _type) + + assert np.allclose(out, out2[::-1]), \ + ("expected a rotation of 90 degrees to text to directly flip " + + "required size, type : %s, test 1+2" % _type) + + + a2_long = cow.text("Fig 1 Fig 1", _type = _type) +\ + p9.element_text(angle = 90) + out2_long = a2_long._min_size() + + assert (3 * out2[1]> out2_long[1] > 2 * out2[1]) and \ + np.allclose(out2_long[0], out2[0]), \ + ("if text size 2x+ in length (and rotated 90 degrees), " + + "relative sizes should match, type: %s, text 2" % _type) + + a2_tall = cow.text("Fig 1\nFig 1", _type = _type) +\ + p9.element_text(angle = 90) + out2_tall = a2_tall._min_size() + + assert (2.5 * out2[0] > out2_tall[0] > 1.5 * out2[0]) and \ + np.allclose(out2_tall[1], out2[1]), \ + ("if text size ~2x in height (and rotated 90 degrees), "+ + "relative sizes should match, type: %s, text 2" % _type) + +def test_text__min_size2(data_regression): + """ + _min_size data regression test on sizing for different base types + + Notes + ----- + the usage of data_regression requires ensuring the saved + dictionary is "yaml"-ifable (which is why we do things like + converting the values to strings). + """ + static_min_size_data = {} + + for _type in ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]: + + a = cow.text("Fig 1", _type = _type) + out = a._min_size() + inner_dict_list = [str(x) for x in out] + static_min_size_data[_type] = inner_dict_list + + data_regression.check(static_min_size_data) + + +@pytest.mark.parametrize("_type", ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]) +def test_text__base_text_image(image_regression, _type): + """ + tests for _base_text_image (regression for image, + and bbox versus _min_size check) + """ + # base option + a = cow.text("Fig 1", _type = _type) + fig, bbox = a._base_text_image() + + # matchin with min_size + ms_out = a._min_size() + assert (bbox.width > ms_out[0] > .5 * bbox.width) and \ + (bbox.height > ms_out[1] > .5 * bbox.height), \ + ("expect that bbox is slightly bigger than min_sizing, " + + "type = %s, test 1" % _type) + + with io.BytesIO() as fid2: + fig.savefig(fname=fid2, format = "png") + image_regression.check(fid2.getvalue(), diff_threshold=.1) + + +@pytest.mark.parametrize("_type", ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]) +def test_text__base_text_image2(image_regression, _type): + """ + tests for _base_text_image (regression for image, + and bbox versus _min_size check) + + test rotated 90 degrees + """ + # rotated option + a = cow.text("Fig 1", _type = _type) +\ + p9.element_text(angle = 90) + fig, bbox = a._base_text_image() + + # matchin with min_size + ms_out = a._min_size() + assert (bbox.width > ms_out[0] > .5 * bbox.width) and \ + (bbox.height > ms_out[1] > .5 * bbox.height), \ + ("expect that bbox is slightly bigger than min_sizing, " + + "type = %s, test 1" % _type) + + with io.BytesIO() as fid2: + fig.savefig(fname=fid2, format = "png") + image_regression.check(fid2.getvalue(), diff_threshold=.1) + +@pytest.mark.parametrize("_type", ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]) +@given(st.floats(min_value=.5, max_value=49), + st.floats(min_value=.5, max_value=49)) +@settings(max_examples=4, deadline=timedelta(milliseconds=1000)) +def test_text__default_size(_type, height, width): + """ + test for _default_size function + """ + # base option + a = cow.text("Fig 1", _type = _type) + ms_out = a._min_size(to_inches = True) + + assert np.allclose(a._default_size(), ms_out), \ + ("if width & height input are none, expect to return " + + "_min_size(to_inches=True), type = %s, test 1" % _type) + + ds_out = a._default_size(width=width) + + assert np.allclose(ds_out, (width, ms_out[1])), \ + ("if width is non-none, height none, expected return " + + "(width, min_size(to_inches=True)[1]), type = %s, test 1" % _type) + + ds_out2 = a._default_size(height=height) + + assert np.allclose(ds_out2, (ms_out[0], height)), \ + ("if height is non-none, width none, expected return " + + "(min_size(to_inches=True)[0], height), type = %s, test 1" % _type) + + ds_out3 = a._default_size(height=height, width=width) + + assert np.allclose(ds_out3, (width, height)), \ + ("if height is non-none, width non-none, expected return " + + "(width, height), type = %s, test 1" % _type) + + + # long option + a_long = cow.text("Fig 1 Fig 1", _type = _type) + ms_out_long = a_long._min_size(to_inches = True) + + assert np.allclose(a_long._default_size(), ms_out_long), \ + ("if width & height input are none, expect to return " + + "_min_size(to_inches=True), type = %s, test 2" % _type) + + assert np.allclose(a_long._default_size(), ms_out_long), \ + ("if width & height input are none, expect to return " + + "_min_size(to_inches=True), type = %s, test 2" % _type) + + ds_out_long = a_long._default_size(width=width) + + assert np.allclose(ds_out_long, (width, ms_out_long[1])), \ + ("if width is non-none, height none, expected return " + + "(width, min_size(to_inches=True)[1]), type = %s, test 2" % _type) + + ds_out2_long = a_long._default_size(height=height) + + assert np.allclose(ds_out2_long, (ms_out_long[0], height)), \ + ("if height is non-none, width none, expected return " + + "(min_size(to_inches=True)[0], height), type = %s, test 2" % _type) + + ds_out3_long = a_long._default_size(height=height, width=width) + + assert np.allclose(ds_out3_long, (width, height)), \ + ("if height is non-none, width non-none, expected return " + + "(width, height), type = %s, test 2" % _type) + + + + + # 2x height option + a_tall = cow.text("Fig 1\nFig 1", _type = _type) + ms_out_tall = a_tall._min_size(to_inches = True) + + assert np.allclose(a_tall._default_size(), ms_out_tall), \ + ("if width & height input are none, expect to return " + + "_min_size(to_inches=True), type = %s, test 3" % _type) + + ds_out_tall = a_tall._default_size(width=width) + + assert np.allclose(ds_out_tall, (width, ms_out_tall[1])), \ + ("if width is non-none, height none, expected return " + + "(width, min_size(to_inches=True)[1]), type = %s, test 3" % _type) + + ds_out2_tall = a_tall._default_size(height=height) + + assert np.allclose(ds_out2_tall, (ms_out_tall[0], height)), \ + ("if height is non-none, width none, expected return " + + "(min_size(to_inches=True)[0], height), type = %s, test 3" % _type) + + ds_out3_tall = a_tall._default_size(height=height, width=width) + + assert np.allclose(ds_out3_tall, (width, height)), \ + ("if height is non-none, width non-none, expected return " + + "(width, height), type = %s, test 3" % _type) + + +def test_text__svg(): + raise ValueError("Not Tested") + + +def test_text_save(): + raise ValueError("Not Tested") + + +def test_text_show(): + raise ValueError("Not Tested") + + +# printing ------------- +@pytest.mark.parametrize("_type", ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]) +def test_text__str__(monkeypatch, capsys, _type): + """ + test text.__str__, static + + print(.) also creates the figure + """ + monkeypatch.setattr(plt, "show", lambda:None) + + a = cow.text("Fig 1", _type = _type) + + print(a) + captured = capsys.readouterr() + + re_cap = re.search("\\n", captured.out) + assert re_cap is not None and \ + re_cap.start() == 0 and re_cap.end() == len(captured.out),\ + "expected __str__ expression for text to be of format" + + +@pytest.mark.parametrize("_type", ["cow_tag", "cow_caption", "cow_text", + "cow_title", "cow_subtitle"]) +def test_text__repr__(monkeypatch, capsys, _type): + """ + test text.__repr__, static + """ + monkeypatch.setattr(plt, "show", lambda:None) + + a = cow.text("Fig 1", _type = _type) + + print(repr(a)) + captured = capsys.readouterr() + lines = re.split("\\n", captured.out) + + re_cap = re.search("\\n", captured.out) + assert re_cap is not None and \ + re_cap.start() == 0 and re_cap.end() == (len(lines[0]) + 1),\ + "expected __repr__ first line expression for text to be "+\ + "of format" -# printing ---------- + assert ((lines[1] == "label:") and + (lines[2] == " |" + a.label) and + (lines[3] == "element_text:") and + (lines[5] == "_type:") and + (lines[6] == " |\"%s\"" % _type)), \ + "expected overall __repr__ expression to follow a specific format" diff --git a/tests/test_text_elements/test__base_text_image2_cow_caption_.png b/tests/test_text_elements/test__base_text_image2_cow_caption_.png new file mode 100644 index 0000000000000000000000000000000000000000..927f006280d0bb3355d243fc53cc97604d3aa221 GIT binary patch literal 2302 zcmeAS@N?(olHy`uVBq!ia0y~yV5(pY z&d4)3bo^vy;22dj8U&-MU^F9)mIb55;b@I8S}D@4T~NgMl%0Lok3;K=fNd29Pgg&e IbxsLQ0Q(B73jhEB literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image2_cow_subtitle_.png b/tests/test_text_elements/test__base_text_image2_cow_subtitle_.png new file mode 100644 index 0000000000000000000000000000000000000000..4ad282beffe26311a0308bfc294b584d5a292cd8 GIT binary patch literal 2489 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}u_rd-Kh{^NVkNQMdcKEH^D` zTB;-igUXruYM_a?1%ak3KH_IkaQVZ?(9&Vg&>--Uok2k99|MEa5qSoOjvven9HWXx zgJ3ijjAn$fa+$Q|9SHJvu9~CeC@o?fJQHSxyByYjQJ4% zVM03YhRp8y`hLqV=l4IWlnW0JfBfxRSxih!ju8(-gZ=x@mi3=)`ZH(Q*+)!!I{kZc wPhn-H<-7Oqk6*rgc}9A?6cfXr>^g=C>nxA8C4J2SHlP?hUHx3vIVCg!09`8Ez5oCK literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image2_cow_tag_.png b/tests/test_text_elements/test__base_text_image2_cow_tag_.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c8db294cc62f4c124b26d3ebf089c028875331 GIT binary patch literal 2500 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}?qs8tUEuFfz1s*fTT;d}L=3Q2NKf;B-Wu!J*>^GXsYp&}zj;{0s^% zql!j@U^EqsW`xnQV6-?Ktr12mMf7&Tgyu)@zkMrvQ?`5SHU@?bn}FPxFEihK{|)9E z_17{oIQ8l2>+i4m@$qBfUR4H$HF>j^WmXpLa`lbU5t_vGxYR%?pwwx9F4D=Q1E_>&S@hC29F!L zwhRmn^OgTGFgP8NXK?8F!OXxR2((1;5kG^1%O6IDmJWM{27!<43<64{ibjKAG!=|y zgwe8Kv^X5C5k@OT^mf4s^?&QGzn<~D^0Y1k!i2Rp9H`HW5&Tk`1kK> z8NT-3bVh~^ZJFC||1CXy{9}d8^wX(lKJPiI26WOV?i>$&gWNjNW56aBgQu&X%Q~lo FCIEI+!A$@F literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image2_cow_title_.png b/tests/test_text_elements/test__base_text_image2_cow_title_.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c8db294cc62f4c124b26d3ebf089c028875331 GIT binary patch literal 2500 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}?qs8tUEuFfz1s*fTT;d}L=3Q2NKf;B-Wu!J*>^GXsYp&}zj;{0s^% zql!j@U^EqsW`xnQV6-?Ktr12mMf7&Tgyu)@zkMrvQ?`5SHU@?bn}FPxFEihK{|)9E z_17{oIQ8l2>+i4m@$qBfUR4H$HF>j^WmpY z&d4)3bo^vy;22dj8U&-MU^F9)mIb55;b@I8S}D@4T~NgMl%0Lok3;K=fNd29Pgg&e IbxsLQ0Q(B73jhEB literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image_cow_subtitle_.png b/tests/test_text_elements/test__base_text_image_cow_subtitle_.png new file mode 100644 index 0000000000000000000000000000000000000000..33bc3cefe481e0adba2d0eb543f6bf8222ceb8be GIT binary patch literal 2625 zcmeAS@N?(olHy`uVBq!ia0y~yV5(<<$TNNMiGbp(HVPt6O zuxDrx_{h#6p!AP{!Rd%RgG0xtqR}82O$DPFVYDn5Ee=O(gwaZoTJ3^Ovu0Y_*vv_d zT-VdiFMsaLnN2#MUtL|D9r@wxZ1c_g_uE@rTW{7mEh8@W*17+|#N=l9FopWCEU zTvhe!JNLSpG!BLjt8=E_*q(oX+p)CGKOZi+zCQl^!-o&|RD4XbtNCHTFK08Oxw%>T zrU3(k{d=Q-`|AE$ZO^-VOX~go{rim`{{H@cbKLspPft$=QtA2m`PW{5wW<5_!_AzJ z;XwWNuZNG{ym|B5QJ}K;nFWP~hVlDqW-j-ipEtMu+nbwvYJY#Lzoo{&aQ$KV3I2oy UE#V1Pz@{OCr>mdKI;Vst0DVg=qyPW_ literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image_cow_tag_.png b/tests/test_text_elements/test__base_text_image_cow_tag_.png new file mode 100644 index 0000000000000000000000000000000000000000..14f55428c55cd45bffb02ff2b42033f210a9afc0 GIT binary patch literal 2713 zcmeAS@N?(olHy`uVBq!ia0y~yV5(K%~YAZRVfV$C`` zN4@$Q@9x#}?zVofOz1lN$M}EvzB4@Og(W2=CJYQJbAGI6XJB}6U-=&cgVPat28WIx z%nTfYKxZgE;%88B`NPQ2(qYfgAn=i$K|pC#(P$8irh?IoFj^Lj7KfuX!f2&PVY{H- z`rp01)#lwtljhmg?mAY;z!0%q*x0zey?t|pPM__!@87Rm9r^wJeL4{2-QQ>X{M_8x zN0SWA^Y6vHp2xu8D!u=I;rDlUcNRZCSKgg>XNRGVf>`}1>W-QQn(YJPs2{+^NHfc&HPx3*>%|Nr+lSv)^4Z(VA~+qZ9jet39zj%{^W zTAEn*(Wq~X3@d)W%>TEq?yr@#wRQ2sL#;RO+?lgFeEqq5_wK!U|NeZ^#ugu@tb$^$O@2~or#mC3@3>ZA0|NQ)% zzdl>uuErvKT}HYBEtl^*syqMX&l)>+pzh82 z@(c_QijT-MICT7AX5bJ6ny>hXpFzRp4^YnEd7VE4!=97t!;iiGT4f{GzcE7Rm_)MA8UQ@yAsjz{bpuhkT<&h{`-Ak5q6;cOxWtH+kwhHDDG!e Y`l!sJ-@tVh*sfymboFyt=akR{0D8E)4FCWD literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test__base_text_image_cow_title_.png b/tests/test_text_elements/test__base_text_image_cow_title_.png new file mode 100644 index 0000000000000000000000000000000000000000..14f55428c55cd45bffb02ff2b42033f210a9afc0 GIT binary patch literal 2713 zcmeAS@N?(olHy`uVBq!ia0y~yV5(K%~YAZRVfV$C`` zN4@$Q@9x#}?zVofOz1lN$M}EvzB4@Og(W2=CJYQJbAGI6XJB}6U-=&cgVPat28WIx z%nTfYKxZgE;%88B`NPQ2(qYfgAn=i$K|pC#(P$8irh?IoFj^Lj7KfuX!f2&PVY{H- z`rp01)#lwtljhmg?mAY;z!0%q*x0zey?t|pPM__!@87Rm9r^wJeL4{2-QQ>X{M_8x zN0SWA^Y6vHp2xu8D!u=I;rDlUcNRZCSKgg>XNRGVf>`}1>W-QQn(YJPs2{+^NHfc&HPx3*>%|Nr+lSv)^4Z(VA~+qZ9jet39zj%{^W zTAEn*(Wq~X3@d)W%>TEq?yr@#wRQ2sL#;RO+?lgFeEqq5_wK!U|NeZ^#ugu@tb$^$O@2~or#mC3@3>ZA0|NQ)% zzdl>uuErvKT}pY z&d4)3bo^vy;22dj8U&-MU^F9)mIb55;b@I8S}D@4T~NgMl%0Lok3;K=fNd29Pgg&e IbxsLQ0Q(B73jhEB literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image2_cow_subtitle_.png b/tests/test_text_elements/test_text__base_text_image2_cow_subtitle_.png new file mode 100644 index 0000000000000000000000000000000000000000..4ad282beffe26311a0308bfc294b584d5a292cd8 GIT binary patch literal 2489 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}u_rd-Kh{^NVkNQMdcKEH^D` zTB;-igUXruYM_a?1%ak3KH_IkaQVZ?(9&Vg&>--Uok2k99|MEa5qSoOjvven9HWXx zgJ3ijjAn$fa+$Q|9SHJvu9~CeC@o?fJQHSxyByYjQJ4% zVM03YhRp8y`hLqV=l4IWlnW0JfBfxRSxih!ju8(-gZ=x@mi3=)`ZH(Q*+)!!I{kZc wPhn-H<-7Oqk6*rgc}9A?6cfXr>^g=C>nxA8C4J2SHlP?hUHx3vIVCg!09`8Ez5oCK literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image2_cow_tag_.png b/tests/test_text_elements/test_text__base_text_image2_cow_tag_.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c8db294cc62f4c124b26d3ebf089c028875331 GIT binary patch literal 2500 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}?qs8tUEuFfz1s*fTT;d}L=3Q2NKf;B-Wu!J*>^GXsYp&}zj;{0s^% zql!j@U^EqsW`xnQV6-?Ktr12mMf7&Tgyu)@zkMrvQ?`5SHU@?bn}FPxFEihK{|)9E z_17{oIQ8l2>+i4m@$qBfUR4H$HF>j^WmXpLa`lbU5t_vGxYR%?pwwx9F4D=Q1E_>&S@hC29F!L zwhRmn^OgTGFgP8NXK?8F!OXxR2((1;5kG^1%O6IDmJWM{27!<43<64{ibjKAG!=|y zgwe8Kv^X5C5k@OT^mf4s^?&QGzn<~D^0Y1k!i2Rp9H`HW5&Tk`1kK> z8NT-3bVh~^ZJFC||1CXy{9}d8^wX(lKJPiI26WOV?i>$&gWNjNW56aBgQu&X%Q~lo FCIEI+!A$@F literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image2_cow_title_.png b/tests/test_text_elements/test_text__base_text_image2_cow_title_.png new file mode 100644 index 0000000000000000000000000000000000000000..b0c8db294cc62f4c124b26d3ebf089c028875331 GIT binary patch literal 2500 zcmeAS@N?(olHy`uVBq!ia0y~yV5(}?qs8tUEuFfz1s*fTT;d}L=3Q2NKf;B-Wu!J*>^GXsYp&}zj;{0s^% zql!j@U^EqsW`xnQV6-?Ktr12mMf7&Tgyu)@zkMrvQ?`5SHU@?bn}FPxFEihK{|)9E z_17{oIQ8l2>+i4m@$qBfUR4H$HF>j^WmpY z&d4)3bo^vy;22dj8U&-MU^F9)mIb55;b@I8S}D@4T~NgMl%0Lok3;K=fNd29Pgg&e IbxsLQ0Q(B73jhEB literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image_cow_subtitle_.png b/tests/test_text_elements/test_text__base_text_image_cow_subtitle_.png new file mode 100644 index 0000000000000000000000000000000000000000..33bc3cefe481e0adba2d0eb543f6bf8222ceb8be GIT binary patch literal 2625 zcmeAS@N?(olHy`uVBq!ia0y~yV5(<<$TNNMiGbp(HVPt6O zuxDrx_{h#6p!AP{!Rd%RgG0xtqR}82O$DPFVYDn5Ee=O(gwaZoTJ3^Ovu0Y_*vv_d zT-VdiFMsaLnN2#MUtL|D9r@wxZ1c_g_uE@rTW{7mEh8@W*17+|#N=l9FopWCEU zTvhe!JNLSpG!BLjt8=E_*q(oX+p)CGKOZi+zCQl^!-o&|RD4XbtNCHTFK08Oxw%>T zrU3(k{d=Q-`|AE$ZO^-VOX~go{rim`{{H@cbKLspPft$=QtA2m`PW{5wW<5_!_AzJ z;XwWNuZNG{ym|B5QJ}K;nFWP~hVlDqW-j-ipEtMu+nbwvYJY#Lzoo{&aQ$KV3I2oy UE#V1Pz@{OCr>mdKI;Vst0DVg=qyPW_ literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image_cow_tag_.png b/tests/test_text_elements/test_text__base_text_image_cow_tag_.png new file mode 100644 index 0000000000000000000000000000000000000000..14f55428c55cd45bffb02ff2b42033f210a9afc0 GIT binary patch literal 2713 zcmeAS@N?(olHy`uVBq!ia0y~yV5(K%~YAZRVfV$C`` zN4@$Q@9x#}?zVofOz1lN$M}EvzB4@Og(W2=CJYQJbAGI6XJB}6U-=&cgVPat28WIx z%nTfYKxZgE;%88B`NPQ2(qYfgAn=i$K|pC#(P$8irh?IoFj^Lj7KfuX!f2&PVY{H- z`rp01)#lwtljhmg?mAY;z!0%q*x0zey?t|pPM__!@87Rm9r^wJeL4{2-QQ>X{M_8x zN0SWA^Y6vHp2xu8D!u=I;rDlUcNRZCSKgg>XNRGVf>`}1>W-QQn(YJPs2{+^NHfc&HPx3*>%|Nr+lSv)^4Z(VA~+qZ9jet39zj%{^W zTAEn*(Wq~X3@d)W%>TEq?yr@#wRQ2sL#;RO+?lgFeEqq5_wK!U|NeZ^#ugu@tb$^$O@2~or#mC3@3>ZA0|NQ)% zzdl>uuErvKT}HYBEtl^*syqMX&l)>+pzh82 z@(c_QijT-MICT7AX5bJ6ny>hXpFzRp4^YnEd7VE4!=97t!;iiGT4f{GzcE7Rm_)MA8UQ@yAsjz{bpuhkT<&h{`-Ak5q6;cOxWtH+kwhHDDG!e Y`l!sJ-@tVh*sfymboFyt=akR{0D8E)4FCWD literal 0 HcmV?d00001 diff --git a/tests/test_text_elements/test_text__base_text_image_cow_title_.png b/tests/test_text_elements/test_text__base_text_image_cow_title_.png new file mode 100644 index 0000000000000000000000000000000000000000..14f55428c55cd45bffb02ff2b42033f210a9afc0 GIT binary patch literal 2713 zcmeAS@N?(olHy`uVBq!ia0y~yV5(K%~YAZRVfV$C`` zN4@$Q@9x#}?zVofOz1lN$M}EvzB4@Og(W2=CJYQJbAGI6XJB}6U-=&cgVPat28WIx z%nTfYKxZgE;%88B`NPQ2(qYfgAn=i$K|pC#(P$8irh?IoFj^Lj7KfuX!f2&PVY{H- z`rp01)#lwtljhmg?mAY;z!0%q*x0zey?t|pPM__!@87Rm9r^wJeL4{2-QQ>X{M_8x zN0SWA^Y6vHp2xu8D!u=I;rDlUcNRZCSKgg>XNRGVf>`}1>W-QQn(YJPs2{+^NHfc&HPx3*>%|Nr+lSv)^4Z(VA~+qZ9jet39zj%{^W zTAEn*(Wq~X3@d)W%>TEq?yr@#wRQ2sL#;RO+?lgFeEqq5_wK!U|NeZ^#ugu@tb$^$O@2~or#mC3@3>ZA0|NQ)% zzdl>uuErvKT} Date: Sun, 22 Jan 2023 14:20:56 -0800 Subject: [PATCH 08/13] minor update to base_elements test for correct identification of str versus repr testing --- tests/test_base_elements.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index 5c45927..d701c24 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -526,7 +526,7 @@ def test_patch__estimate_default_min_desired_size_Annotation(): )), \ "suggested height incorrectly sizes the smallest height of the images "+\ "(v2 - nested, annotation)" - + # tag nested option ----------- vis_nested_tag = cow.patch(g0,cow.patch(g1,g2)+\ cow.layout(ncol=1, rel_heights = [1,2]) +\ @@ -557,7 +557,7 @@ def test_patch__estimate_default_min_desired_size_Annotation(): cow.text("My caption", _type="cow_caption").\ _min_size(to_inches=True)[1])), \ "suggested height incorrectly sizes the smallest height of the images "+\ - "(v2 - nested + tagged, annotation)" + "(v2 - nested + tagged, annotation)" def test_patch__default_size__both_none(): """ @@ -1108,9 +1108,9 @@ def test_patch__svg(): # printing ---------- -def test_patch__repr__(monkeypatch,capsys): +def test_patch__str__(monkeypatch,capsys): """ - test patch .__repr__, static + test patch .__str__, static print(.) also creates the figure """ @@ -1140,7 +1140,7 @@ def test_patch__repr__(monkeypatch,capsys): re_cap.start() == 0 and re_cap.end() == len(captured.out),\ "expected __str__ expression for patch to be of format" -def test_patch__str__(capsys): +def test_patch__repr__(capsys): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ From 0f16af1d8fc78357f8ed32331953f4327a83da8a Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 22 Jan 2023 14:21:40 -0800 Subject: [PATCH 09/13] updates to annotation elements and testing - incomplete --- src/cowpatch/annotation_elements.py | 158 ++++++++++++++++++++----- src/cowpatch/base_elements.py | 32 +++-- tests/test_annotation_elements.py | 177 +++++++++++++++++++++++----- 3 files changed, 303 insertions(+), 64 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 93c0c41..87551a6 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -94,7 +94,7 @@ def __init__(self, title=None, subtitle=None, caption=None, self.tags_loc = None self.tags_inherit = None self._tags_format = None - self.tags_depth = -1 + #self.tags_depth = -1 self._update_all_attributes(title=title, subtitle=subtitle, @@ -134,7 +134,22 @@ def tags_format(self): return new_tags_format + @property + def tags_depth(self): + """ + tags_depth definition + """ + if inherits(self.tags, list): + tags_depth = 1 + elif (self.tags_format is None or self.tags_format is np.nan) and \ + (self.tags is None or self.tags is np.nan): + tags_depth = -1 + elif self.tags_format is None or self.tags_format is np.nan: + tags_depth = len(self.tags) + else: + tags_depth = len(self.tags_format) + return tags_depth def _clean_up_attributes(self): """ @@ -314,20 +329,22 @@ def _update_all_attributes(self, title=None, subtitle=None, caption=None, self._tags_format = new_tags_format - # tags_depth definition -------------------- - if inherits(self.tags, list): - self.tags_depth = 1 - elif (self.tags_format is None or self.tags_format is np.nan) and \ - (self.tags is None or self.tags is np.nan): - self.tags_depth = -1 - elif self.tags_format is None or self.tags_format is np.nan: - self.tags_depth = len(self.tags) - else: - self.tags_depth = len(self.tags_format) + # # tags_depth definition -------------------- + # if inherits(self.tags, list): + # self.tags_depth = 1 + # elif (self.tags_format is None or self.tags_format is np.nan) and \ + # (self.tags is None or self.tags is np.nan): + # self.tags_depth = -1 + # elif self.tags_format is None or self.tags_format is np.nan: + # self.tags_depth = len(self.tags) + # else: + # self.tags_depth = len(self.tags_format) + - def _get_tag(self, index=(0,)): + + def _get_tag_full(self, index=(0,)): """ - Create text of tag for given level and index + Create text of tag for given level and index (fully goes down) Arguments --------- @@ -341,12 +358,11 @@ def _get_tag(self, index=(0,)): Notes ----- - this should return objects relative to correct rotation... + TODO: need to update to just deal with 1 level of index? """ if inherits(index, int): index = (index,) - pdb.set_trace() if len(self.tags_format) < len(index): raise ValueError("tags_format tuple has less indices than _get_tag index suggests") @@ -371,6 +387,48 @@ def _get_tag(self, index=(0,)): return et + def _get_tag(self, index=0): + """ + Create text of tag for given level and index + + Arguments + --------- + index : integer + current index for desired tag + + Returns + ------- + cow.text object for tag + """ + + level_format = self.tags_format[0].label + indices_used = [int(re.findall("[0-9]+", x)[0]) + for x in re.findall("\{[0-9]+\}", + level_format)] + + if np.max(indices_used) > 0: + raise ValueError("current tags_format structure %s has more "+ + "indices than the level expected" % level_format) + + if self.tags is None: + return text(label = "", _type = "cow_tag") + + if inherits(self.tags[0], list): + if index < len(self.tags[0]): + index_string = self.tags[0][index] + else: + #index_string = "" + # in doesn't have index - return empty element + return text(label = "", _type = "cow_tag") + else: + index_string = self._get_index_value(level=0,index=index) + + et = copy.deepcopy(self.tags_format[0]) + et.label = et.label.format(index_string) + + return et + + def _get_index_value(self, level=0, index=0): @@ -385,6 +443,10 @@ def _get_index_value(self, level=0, index=0): tuple of integers that contain the relative level indices of the desired tag. + Note + ---- + For developers: this function is overkill as we now create child + annotation objects so "level=0" should always be the case """ if len(self.tags) < level: return "" @@ -430,15 +492,18 @@ def _get_auto_index_value(self, index=0, _type = ["0","1", "a", "A", "i","I"][0] raise ValueError('type of auto-tags must be in '+\ '["0","1", "a", "A", "i", "I"]') - def _calculate_tag_margin_sizes(self, index=(0,), + def _calculate_tag_margin_sizes(self, index=0, full_index=None, fundamental=False, to_inches=False): """ - (Internal) calculate tag's margin sizes + (Internal) calculate tag's margin sizes (all zeros if not actually + creating object) Arguments --------- - index : int or tuple + index : int + integer of current level index (the last value of the full_index) + full_index : int or tuple tuple of indices relative to the hierarchical ordering of the tag fundamental : boolean if the associated object being "tagged" is a fundamental object, @@ -448,7 +513,7 @@ def _calculate_tag_margin_sizes(self, index=(0,), Returns ------- - dictatiory with following keys/ objects + dictionary with following keys/ objects min_inner_width : float minimum width required for title & subtitles on top or bottom min_full_width : float @@ -463,10 +528,27 @@ def _calculate_tag_margin_sizes(self, index=(0,), top_left_loc : tuple tuple of top left corner of inner image relative to title text + Notes + ----- + For developers: this function also returns the above dictionary with + all zeros if there will be no tag created (which is a function of the + `fundamental` argument and `full_index` argument) + """ + # clean-up + if full_index is None: + full_index = (index, ) + + if not inherits(full_index, tuple): + full_index = (full_index, ) + + if index != full_index[-1]: + raise ValueError("structure between arguments `index` and "+ + "`full_index` disagree.") + # if we shouldn't actually make the tag if index is None or \ - (self.tags_depth != len(index) and not fundamental): + (self.tags_depth != len(full_index) and not fundamental): return {"min_inner_width": 0, "min_full_width": 0, # not able to be nonzero for tag "extra_used_width": 0, @@ -475,9 +557,6 @@ def _calculate_tag_margin_sizes(self, index=(0,), "top_left_loc": (0,0) } - # clean-up - if not inherits(index, tuple): - index = (index, ) # getting tag ------------------- tag = self._get_tag(index=index) @@ -509,7 +588,8 @@ def _calculate_tag_margin_sizes(self, index=(0,), } def _get_tag_and_location(self, width, height, - index = (0,), + index=0, + full_index=None, fundamental=False): """ create desired tag and identify location to place tag and associated @@ -524,6 +604,11 @@ def _get_tag_and_location(self, width, height, index : tuple index of the tag. The size of the tuple captures depth. + full_index : int or tuple + tuple of indices relative to the hierarchical ordering of the tag + fundamental : boolean + if the associated object being "tagged" is a fundamental object, + if not, a tag is only made if the tags_depth is at the final level. Return ------ @@ -537,14 +622,23 @@ def _get_tag_and_location(self, width, height, tag text svg object """ # clean-up - if not inherits(index, tuple): - index = (index, ) + if full_index is None: + full_index = (index, ) + + if not inherits(full_index, tuple): + full_index = (full_index, ) + + if index != full_index[-1]: + raise ValueError("structure between arguments `index` and "+ + "`full_index` disagree.") + # if we shouldn't actually make the tag - if self.tags_depth != len(index) and not fundamental: + if index is None or \ + (self.tags_depth != len(full_index) and not fundamental): return None, None, None - tag_image = self.get_tag(index = index) + tag_image = self._get_tag(index=index) if self.tags_loc in ["top", "bottom"]: inner_width_pt = width @@ -896,6 +990,11 @@ def _step_down_tags_info(self, parent_index): ------- annotation with all tag attributes to update for children's annotation. + + Notes + ----- + For developers: we step down both the the `tags` and `tags_format` + parameters of the annotation object. """ if self.tags is None or \ len(self.tags) <= 1 or \ @@ -926,6 +1025,9 @@ def _step_down_tags_info(self, parent_index): return inner_annotation + def inheritance_type(self): + return self.tag_inherit + def __add__(self, other): """ diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index 72b4d39..c7cce7f 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -289,7 +289,7 @@ def _get_grob_tag_ordering(self, cur_annotation=None): else: raise ValueError("patch's annotation's tags_order is not an expected option") - # list correction + # list correction (length of output) if cur_annotation is not None and \ inherits(cur_annotation.tags[0], list) and \ len(cur_annotation.tags[0]) < len(self.grobs): @@ -786,9 +786,11 @@ def _hierarchical_general_process(self, if data_dict is not None and \ data_dict.get("parent-guided-annotation-update") is not None: - # should only actually update if tags_inherit="override" within - # cur_annotation - cur_annotation += data_dict["parent-guided-annotation-update"] + # Note: this addition allows for the keeping of titles but the + # update of tags + if cur_annotation.inheritance_type() == "override": + cur_annotation += data_dict["parent-guided-annotation-update"] + # prep section: annotation global size corrections -------------------- @@ -889,10 +891,17 @@ def _hierarchical_general_process(self, sizes = self._hierarchical_general_process(width = 1, height = 1, approach = "size") - #### TODO?: maybe track the time it takes to calculate sizes and if it takes to - #### long provide user with progressbar for the actual plot creation? + else: + sizes = data_dict["sizes"] + #### prep index tracking (for uniquification of svg objections): + if data_dict is None or data_dict.get("u_idx") is None: + _u_idx = str(self.__hash__()) + else: + _u_idx = data_dict["u_idx"] + #### TODO?: maybe track the time it takes to calculate sizes and if it takes to + #### long provide user with progressbar for the actual plot creation? ### prep for grob processing @@ -912,6 +921,7 @@ def _hierarchical_general_process(self, inner_area = areas[p_idx] # TODO: start here + # (1/16) what if within this space we check more things? if tag_index_array[p_idx] is not None: grob_tag_index = tag_index_array[p_idx] @@ -924,7 +934,7 @@ def _hierarchical_general_process(self, [grob_tag_index]) - else: + else: # no tag required fundamental_tag = False grob_tag_index = None current_index = () @@ -936,7 +946,8 @@ def _hierarchical_general_process(self, # the _step_down_tags_info tag_margin_dict = cur_annotation._calculate_tag_margin_sizes( fundamental=fundamental_tag, - index=current_index, + index=grob_tag_index, + full_index=current_index, to_inches=True) @@ -947,6 +958,7 @@ def _hierarchical_general_process(self, ### create tag if approach == "create": + inner_u_idx = _u_idx + "_" + str(p_idx) tag_loc, image_loc, tag_image = \ cur_annotation._get_tag_and_location( width=inner_area.width, @@ -960,7 +972,7 @@ def _hierarchical_general_process(self, if inherits(image, patch): ### default sizing data_dict_pass_through = data_dict.copy() - data_dict_pass_through["parent-index"] = + data_dict_pass_through["parent-index"] = current_index data_dict_pass_through["parent-guided-annotation-update"] = \ cur_annotation._step_down_tags_info(parent_index = current_index) @@ -989,6 +1001,7 @@ def _hierarchical_general_process(self, ### saving / showing if approach == "create": + data_dict_pass_through["u_idx"] = inner_u_idx data_dict_pass_through["size"] = sizes[p_idx] grob_image = image._hierarchical_general_process( width=grob_width, @@ -1036,6 +1049,7 @@ def _hierarchical_general_process(self, width = sizes[p_idx][0], height = sizes[p_idx][1], dpi = 96) + grob_image = _uniquify_svg_safe(grob_image, inner_u_idx) elif inherits(image, text): ### default sizing diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index 20f08a2..152bb73 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -241,6 +241,25 @@ def test__add__(): assert a2_expected == a1_updated, \ ("updating with addition failed to perform as expected (attribute %s)" % inner_key) +def test__get_tag_full(): + mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), + tags_loc = "top") + + assert mya._get_tag_full(0) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag_full((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), tuple" + + assert mya._get_tag_full((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 1)" + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2,3)) + # can't obtain a tag when we don't have formats that far down def test__get_tag(): mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, subtitle = {"top":"my very special plot", @@ -251,16 +270,71 @@ def test__get_tag(): assert mya._get_tag(0) == cow.text("Fig 1", _type = "cow_tag"), \ "expect tag creation to match tags_format structure (level 0), int" - assert mya._get_tag((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ + + index = np.random.choice(100) + assert mya._get_tag(index) == cow.text("Fig %i" % (index+1), _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + + + # can't obtain a tag when we don't have formats that far down + with pytest.raises(Exception) as e_info: + mya._get_tag((0,)) + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2)) + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2,3)) + + # list structure + mya2 = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags = (["banana", "apple"],), + tags_loc = "top") + + assert (mya2._get_tag(0) == cow.text("banana", _type = "cow_tag")) & \ + (mya2._get_tag(1) == cow.text("apple", _type = "cow_tag")), \ + "expect tag creation to match list structure (level 0), int" + + assert mya2._get_tag(3) == cow.text("", _type = "cow_tag"), \ + "expected tag of index beyond list length to be an empty tag "+\ + "(but not raise an error)" + + with pytest.raises(Exception) as e_info: + mya2._get_tag((0,)) + + with pytest.raises(Exception) as e_info: + mya2._get_tag((0,0)) + + +def test__get_tag_full_rotations(): + """ + test that ._get_tag works correctly with rotation informatin + + """ + + mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), + tags_loc = "left") + + assert mya._get_tag_full(0) == cow.text("Fig 1", _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag_full((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ "expect tag creation to match tags_format structure (level 0), tuple" - assert mya._get_tag((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ + assert mya._get_tag_full((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ "expect tag creation to match tags_format structure (level 1)" with pytest.raises(Exception) as e_info: mya._get_tag((1,2,3)) # can't obtain a tag when we don't have formats that far down + def test__get_tag_rotations(): """ test that ._get_tag works correctly with rotation informatin @@ -276,15 +350,45 @@ def test__get_tag_rotations(): assert mya._get_tag(0) == cow.text("Fig 1", _type = "cow_tag"), \ "expect tag creation to match tags_format structure (level 0), int" - assert mya._get_tag((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 0), tuple" - assert mya._get_tag((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 1)" + + index = np.random.choice(100) + assert mya._get_tag(index) == cow.text("Fig %i" % (index+1), _type = "cow_tag"), \ + "expect tag creation to match tags_format structure (level 0), int" + + + # can't obtain a tag when we don't have formats that far down + with pytest.raises(Exception) as e_info: + mya._get_tag((0,)) + + with pytest.raises(Exception) as e_info: + mya._get_tag((1,2)) with pytest.raises(Exception) as e_info: mya._get_tag((1,2,3)) - # can't obtain a tag when we don't have formats that far down + + # list structure + mya2 = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, + subtitle = {"top":"my very special plot", + "bottom":"below my plot's bottom is the subtitle"}, + caption = "this is an example figure", + tags = (["banana", "apple"],), + tags_loc = "left") + + assert (mya2._get_tag(0) == cow.text("banana", _type = "cow_tag")) & \ + (mya2._get_tag(1) == cow.text("apple", _type = "cow_tag")), \ + "expect tag creation to match list structure (level 0), int" + + assert mya2._get_tag(3) == cow.text("", _type = "cow_tag"), \ + "expected tag of index beyond list length to be an empty tag "+\ + "(but not raise an error)" + + with pytest.raises(Exception) as e_info: + mya2._get_tag((0,)) + + with pytest.raises(Exception) as e_info: + mya2._get_tag((0,0)) + def test__step_down_tags_info(): junk_dict = dict(title = {"top":cow.text("banana", _type = "cow_title")}, @@ -579,7 +683,8 @@ def test__calculate_tag_margin_sizes(location): tags_loc = location) a0_list_nest = cow.annotation(tags = (["young", "old"], ["harry", "hermione", "ron"]), - tags_loc = location) + tags_loc = location, + tags_format = ("{0}", "{0}{1}")) a0_tuple_nest = cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), @@ -591,22 +696,30 @@ def test__calculate_tag_margin_sizes(location): for a_idx, a0 in enumerate(a0_all): a0_title = a0 + cow.annotation(title = "Conformal Inference") - if a0.tags_depth == 1: - base = a0._calculate_tag_margin_sizes(index = (1,)) - base_plus_title = a0_title._calculate_tag_margin_sizes(index = (1,)) - else: - base = a0._calculate_tag_margin_sizes(index = (1,0)) - base_plus_title = a0_title._calculate_tag_margin_sizes(index = (1,0)) + + base = a0._calculate_tag_margin_sizes(index = 1) + base_plus_title = a0_title._calculate_tag_margin_sizes(index = 1) assert base == base_plus_title, \ ("title attributes shouldn't impact the sizing structure for a "+\ "tag (structure: %s, loc %s)") % (a_info_str[a_idx], location) - # fundamental if a0.tags_depth == 2: - base_f = a0._calculate_tag_margin_sizes(index = (1,), + # not title + a0_s = a0._step_down_tags_info(1) + a0_title_s = a0_title._step_down_tags_info(1) + + base_s = a0_s._calculate_tag_margin_sizes(index = 0) + base_plus_title_s = a0_title_s._calculate_tag_margin_sizes(index = 0) + + assert base_s == base_plus_title_s, \ + ("title attributes shouldn't impact the sizing structure for a "+\ + "tag - stepdown (structure: %s, loc %s)") % (a_info_str[a_idx], location) + + # fundamental + base_f = a0._calculate_tag_margin_sizes(index = 1, fundamental=True) - base_plus_title_f = a0_title._calculate_tag_margin_sizes(index = (1,), + base_plus_title_f = a0_title._calculate_tag_margin_sizes(index = 1, fundamental=True) assert base_f == base_plus_title_f and \ @@ -622,11 +735,15 @@ def test__calculate_tag_margin_sizes(location): # when a tag shouldn't be created if a0.tags_depth == 1: - base_e = a0._calculate_tag_margin_sizes(index = (1,0)) - base_plus_title_e = a0_title._calculate_tag_margin_sizes(index = (1,0)) + a0_s = a0._step_down_tags_info(1) + a0_title_s = a0_title._step_down_tags_info(1) + base_e = a0_s._calculate_tag_margin_sizes(index = 0) + base_plus_title_e = a0_title_s._calculate_tag_margin_sizes(index = 0) else: - base_e = a0._calculate_tag_margin_sizes(index = (1,0,1)) - base_plus_title_e = a0_title._calculate_tag_margin_sizes(index = (1,0,1)) + a0_s = a0._step_down_tags_info(1)._step_down_tags_info(0) + a0_title_s = a0_title._step_down_tags_info(1)._step_down_tags_info(0) + base_e = a0._calculate_tag_margin_sizes(index = 1) + base_plus_title_e = a0_title._calculate_tag_margin_sizes(index = 1) assert base_e == base_plus_title_e and \ base_e == {'min_inner_width': 0, @@ -641,9 +758,9 @@ def test__calculate_tag_margin_sizes(location): # not fundamental if a0.tags_depth == 2: - base_nf = a0._calculate_tag_margin_sizes(index = (1,), + base_nf = a0._calculate_tag_margin_sizes(index = 1, fundamental=False) - base_plus_title_nf = a0_title._calculate_tag_margin_sizes(index = (1,), + base_plus_title_nf = a0_title._calculate_tag_margin_sizes(index = 1, fundamental=False) assert base_nf == base_plus_title_nf and \ @@ -662,8 +779,10 @@ def test__calculate_tag_margin_sizes(location): a0 = a0_list_nest a0_title = a0 + cow.annotation(title = "Conformal Inference") - base_le = a0._calculate_tag_margin_sizes(index = (1,5)) - base_plus_title_le = a0_title._calculate_tag_margin_sizes(index = (1,5)) + a0_s = a0._step_down_tags_info(1) + a0_title_s = a0_title._step_down_tags_info(1) + base_le = a0_s._calculate_tag_margin_sizes(index = 5) + base_plus_title_le = a0_title_s._calculate_tag_margin_sizes(index = 5) assert base_le == base_plus_title_le and \ base_le == {'min_inner_width': 0, @@ -680,8 +799,10 @@ def test__calculate_tag_margin_sizes(location): a0_title = a0 + cow.annotation(title = "Conformal Inference") for t_idx in np.random.choice(100,2): - base_t = a0._calculate_tag_margin_sizes(index = (1,t_idx)) - base_plus_title_t = a0_title._calculate_tag_margin_sizes(index = (1,t_idx)) + a0_s = a0._step_down_tags_info(1) + a0_title_s = a0_title._step_down_tags_info(1) + base_t = a0_s._calculate_tag_margin_sizes(index = t_idx) + base_plus_title_t = a0_title_s._calculate_tag_margin_sizes(index = t_idx) assert base_t == base_plus_title_t and \ base_t != {'min_inner_width': 0, @@ -710,3 +831,5 @@ def test__get_titles_and_locations(): # identify expected locations and potentially try to create # svg objects of the image themselves to compare the output too raise ValueError("Not Tested") + + From 00cb455ea64fae853db91f4d67ff3c210725c779 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sat, 28 Jan 2023 14:25:52 -0800 Subject: [PATCH 10/13] tag correction to deal with rotations relative to tag_loc being 'left' or 'right' --- src/cowpatch/annotation_elements.py | 61 ++- src/cowpatch/text_elements.py | 41 ++ tests/test_annotation_elements.py | 483 ++++++++++++++++-- .../test__get_tag_and_location2_0_bottom_.png | Bin 0 -> 4775 bytes .../test__get_tag_and_location2_0_left_.png | Bin 0 -> 4848 bytes .../test__get_tag_and_location2_0_right_.png | Bin 0 -> 4848 bytes .../test__get_tag_and_location2_0_top_.png | Bin 0 -> 4775 bytes .../test__get_tag_and_location2_1_bottom_.png | Bin 0 -> 7124 bytes .../test__get_tag_and_location2_1_left_.png | Bin 0 -> 7100 bytes .../test__get_tag_and_location2_1_right_.png | Bin 0 -> 7100 bytes .../test__get_tag_and_location2_1_top_.png | Bin 0 -> 7124 bytes .../test__get_tag_and_location2_2_bottom_.png | Bin 0 -> 4034 bytes .../test__get_tag_and_location2_2_left_.png | Bin 0 -> 3994 bytes .../test__get_tag_and_location2_2_right_.png | Bin 0 -> 3994 bytes .../test__get_tag_and_location2_2_top_.png | Bin 0 -> 4034 bytes ...on__calculate_tag_margin_sizes_bottom_.yml | 35 ++ ...tion__calculate_tag_margin_sizes_left_.yml | 35 ++ ...ion__calculate_tag_margin_sizes_right_.yml | 35 ++ ...ation__calculate_tag_margin_sizes_top_.yml | 35 ++ tests/test_text_elements.py | 88 ++++ .../test_text__additional_rotation.png | Bin 0 -> 1538 bytes .../test_text__additional_rotation0.png | Bin 0 -> 1440 bytes 22 files changed, 747 insertions(+), 66 deletions(-) create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_0_bottom_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_0_left_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_0_right_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_0_top_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_1_bottom_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_1_left_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_1_right_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_1_top_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_2_bottom_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_2_left_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_2_right_.png create mode 100644 tests/test_annotation_elements/test__get_tag_and_location2_2_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_bottom_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_left_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_right_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_top_.yml create mode 100644 tests/test_text_elements/test_text__additional_rotation.png create mode 100644 tests/test_text_elements/test_text__additional_rotation0.png diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 87551a6..4d5bc60 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -341,7 +341,6 @@ def _update_all_attributes(self, title=None, subtitle=None, caption=None, # self.tags_depth = len(self.tags_format) - def _get_tag_full(self, index=(0,)): """ Create text of tag for given level and index (fully goes down) @@ -358,7 +357,11 @@ def _get_tag_full(self, index=(0,)): Notes ----- - TODO: need to update to just deal with 1 level of index? + *IMPORTANT*: the `_get_tag` function is likely the function you actually + want to use! + + This function does process rotation expectation from `self.tag_loc` + attribute. """ if inherits(index, int): index = (index,) @@ -385,6 +388,10 @@ def _get_tag_full(self, index=(0,)): et = text(label = "", _type = "cow_tag") + if self.tags_loc in ["left", "right"]: + et = et._additional_rotation(angle=90) + + return et def _get_tag(self, index=0): @@ -399,6 +406,14 @@ def _get_tag(self, index=0): Returns ------- cow.text object for tag + + Notes + ----- + If the annotation level is described with a list, then we + return an emtpy string, even if tags_format suggests otherwise. + + This function does process rotation expectation from `self.tag_loc` + attribute. """ level_format = self.tags_format[0].label @@ -420,15 +435,17 @@ def _get_tag(self, index=0): #index_string = "" # in doesn't have index - return empty element return text(label = "", _type = "cow_tag") + else: index_string = self._get_index_value(level=0,index=index) et = copy.deepcopy(self.tags_format[0]) et.label = et.label.format(index_string) - return et - + if self.tags_loc in ["left", "right"]: + et = et._additional_rotation(angle=90) + return et def _get_index_value(self, level=0, index=0): @@ -598,11 +615,15 @@ def _get_tag_and_location(self, width, height, Arguments --------- width : float - width in pt + width in pt. If to small, request will auto-correct as this is + an internal function - the assessment should probably happen before + if you want to throw a warning. height : float - height in pt - index : tuple - index of the tag. The size of the tuple captures + height in pt. If to small, request will auto-correct as this is + an internal function - the assessment should probably happen before + if you want to throw a warning. + index : int + index of the tag. The annotation object should already capture depth. full_index : int or tuple tuple of indices relative to the hierarchical ordering of the tag @@ -615,11 +636,16 @@ def _get_tag_and_location(self, width, height, tag_loc : tuple upper left corner location for tag image_loc : tuple - upper left corner location for image (assoicated with tag). If - the tag is on the top, this means where the corner of the image - should be placed to correctly be below the tag. + upper left corner location for image (associated with tag - NOT + THE TAG). If the tag is on the top, this means where the corner of + the image should be placed to correctly be below the tag. tag_image : tag text svg object + + Notes + ----- + TODO: This function does process rotation expectation from `self.tag_loc` + attribute. """ # clean-up if full_index is None: @@ -640,6 +666,10 @@ def _get_tag_and_location(self, width, height, tag_image = self._get_tag(index=index) + # if we shouldn't make the tag since it is an empty string + if tag_image.label == '': + return None, None, None + if self.tags_loc in ["top", "bottom"]: inner_width_pt = width inner_height_pt = None @@ -648,7 +678,7 @@ def _get_tag_and_location(self, width, height, inner_height_pt = height tag_image, size_pt = \ - inner_tag._svg(width_pt=inner_width_pt, + tag_image._svg(width_pt=inner_width_pt, height_pt=inner_height_pt) if self.tags_loc == "top": @@ -658,10 +688,10 @@ def _get_tag_and_location(self, width, height, tag_loc = (0,0) image_loc = (size_pt[0], 0) elif self.tags_loc == "bottom": - tag_loc = (0, height - size_pt[0]) + tag_loc = (0, height - size_pt[1]) image_loc = (0,0) else: # self.tags_loc == "right": - tag_loc = (width - size_pt[1],0) + tag_loc = (width - size_pt[0],0) image_loc = (0,0) return tag_loc, image_loc, tag_image @@ -669,9 +699,6 @@ def _get_tag_and_location(self, width, height, - - - def _calculate_margin_sizes(self, to_inches=False): """ (Internal) calculates marginal sizes needed to be displayed for titles diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index 103b779..651b0de 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -30,6 +30,10 @@ def __init__(self, label, element_text=None, _type = "cow_text"): text label with desirable format (e.g. sympy, etc.) element_text : plotnine.themes.elements.element_text element object from plotnine + _type : string + string of which cowpatch text object should inherit attributes + from, if element_text argument doesn't competely define the text + attributes Notes ----- @@ -157,6 +161,41 @@ def __eq__(self, other): return self.__dict__ == other.__dict__ + def _additional_rotation(self, angle=0): + """ + (internal function) to create a rotation of the original object + + Arguments + --------- + angle : float + angle in degrees (0-360) to rotate the current text + + Returns + ------- + new text object that is a rotation of the current one + """ + + new_text_object = copy.deepcopy(self) + + if angle == 0: # not alterations done + return new_text_object + + if new_text_object.element_text is None: + new_text_object += p9.element_text(angle = angle) + else: + # grab the elment_text from text object + et = new_text_object.element_text.theme_element + current_angle = et.properties["rotation"] + et.properties["rotation"] = \ + (((current_angle + angle) / 360.) - + np.floor(((current_angle + angle) / 360.))) * 360 + + new_text_object += et + + return new_text_object + + + def _update_element_text_from_theme(self, theme, key=None): """ @@ -407,6 +446,7 @@ def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): # TODO: update to the "correction proposal approach" if width_pt is not None: if width_pt < min_width_pt - 1e-10: # eps needed + plt.close() raise ValueError("requested width of text object isn't "+\ "large enough for text") else: #if width is None @@ -415,6 +455,7 @@ def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): if height_pt is not None: if height_pt < min_height_pt - 1e-10: # eps needed + plt.close() raise ValueError("requested height of text object isn't "+\ "large enough for text") else: #if height is None diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index 152bb73..59694b5 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -1,4 +1,6 @@ import cowpatch as cow +from cowpatch.svg_utils import _save_svg_wrapper +from cowpatch.utils import to_inches import numpy as np import pandas as pd import copy @@ -7,9 +9,11 @@ import pytest from hypothesis import given, strategies as st, settings +from pytest_regressions import image_regression, data_regression import itertools +import io -def test__update_tdict_info(): +def test_annotation__update_tdict_info(): """ tests annotation._update_tdict_info function """ @@ -196,7 +200,7 @@ def test__update_tdict_info(): "if subtitle in dictionary is np.nan, we expect the subtitle to be erased "+\ "(other updates should still happen)" -def test__add__(): +def test_annotation__add__(): """ test addition update for annotation objects """ @@ -241,7 +245,10 @@ def test__add__(): assert a2_expected == a1_updated, \ ("updating with addition failed to perform as expected (attribute %s)" % inner_key) -def test__get_tag_full(): +def test_annotation__get_tag_full(): + """ + test annotation's _get_tag_full + """ mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, subtitle = {"top":"my very special plot", "bottom":"below my plot's bottom is the subtitle"}, @@ -258,9 +265,13 @@ def test__get_tag_full(): "expect tag creation to match tags_format structure (level 1)" with pytest.raises(Exception) as e_info: - mya._get_tag((1,2,3)) + mya._get_tag_full((1,2,3)) # can't obtain a tag when we don't have formats that far down -def test__get_tag(): + +def test_annotation__get_tag(): + """ + test annotation's _get_tag function + """ mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, subtitle = {"top":"my very special plot", "bottom":"below my plot's bottom is the subtitle"}, @@ -309,9 +320,9 @@ def test__get_tag(): mya2._get_tag((0,0)) -def test__get_tag_full_rotations(): +def test_annotation__get_tag_full_rotations(): """ - test that ._get_tag works correctly with rotation informatin + test that annotation's _get_tag works correctly with rotation informatin """ @@ -322,23 +333,28 @@ def test__get_tag_full_rotations(): tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), tags_loc = "left") - assert mya._get_tag_full(0) == cow.text("Fig 1", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 0), int" - assert mya._get_tag_full((0,)) == cow.text("Fig 1", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 0), tuple" + assert mya._get_tag_full(0) == \ + cow.text("Fig 1", _type = "cow_tag")._additional_rotation(angle=90), \ + ("expect tag creation to match tags_format structure (level 0), int "+ + "(left rotation)") + assert mya._get_tag_full((0,)) == \ + cow.text("Fig 1", _type = "cow_tag")._additional_rotation(angle=90), \ + ("expect tag creation to match tags_format structure (level 0), tuple "+ + "(left rotation)") - assert mya._get_tag_full((1,2)) == cow.text("Fig 2.c", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 1)" + assert mya._get_tag_full((1,2)) == \ + cow.text("Fig 2.c", _type = "cow_tag")._additional_rotation(angle=90), \ + ("expect tag creation to match tags_format structure (level 1) "+ + "(left rotation)") with pytest.raises(Exception) as e_info: - mya._get_tag((1,2,3)) + mya._get_tag_full((1,2,3)) # can't obtain a tag when we don't have formats that far down -def test__get_tag_rotations(): +def test_annotation__get_tag_rotations(): """ - test that ._get_tag works correctly with rotation informatin - + test that annotation's _get_tag works correctly with rotation informatin """ mya = cow.annotation(title = {"top":"my plot", "bottom":"my plot's bottom"}, @@ -348,13 +364,17 @@ def test__get_tag_rotations(): tags_format = ("Fig {0}", "Fig {0}.{1}"), tags = ("1", "a"), tags_loc = "left") - assert mya._get_tag(0) == cow.text("Fig 1", _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag(0) == \ + cow.text("Fig 1", _type = "cow_tag")._additional_rotation(angle=90), \ + ("expect tag creation to match tags_format structure (level 0), int "+ + "(left rotation)") index = np.random.choice(100) - assert mya._get_tag(index) == cow.text("Fig %i" % (index+1), _type = "cow_tag"), \ - "expect tag creation to match tags_format structure (level 0), int" + assert mya._get_tag(index) == \ + cow.text("Fig %i" % (index+1), _type = "cow_tag")._additional_rotation(angle=90), \ + ("expect tag creation to match tags_format structure (level 0), int "+ + "(left rotation)") # can't obtain a tag when we don't have formats that far down @@ -375,13 +395,18 @@ def test__get_tag_rotations(): tags = (["banana", "apple"],), tags_loc = "left") - assert (mya2._get_tag(0) == cow.text("banana", _type = "cow_tag")) & \ - (mya2._get_tag(1) == cow.text("apple", _type = "cow_tag")), \ - "expect tag creation to match list structure (level 0), int" + assert (mya2._get_tag(0) == + cow.text("banana", _type = "cow_tag")._additional_rotation(angle=90)) & \ + (mya2._get_tag(1) == + cow.text("apple", _type = "cow_tag")._additional_rotation(angle=90)), \ + ("expect tag creation to match list structure (level 0), int "+ + "(left rotation)") - assert mya2._get_tag(3) == cow.text("", _type = "cow_tag"), \ - "expected tag of index beyond list length to be an empty tag "+\ - "(but not raise an error)" + assert mya2._get_tag(3) == \ + cow.text("", _type = "cow_tag"), \ + ("expected tag of index beyond list length to be an empty tag "+ + "(but not raise an error) "+ + "(left rotation)") with pytest.raises(Exception) as e_info: mya2._get_tag((0,)) @@ -389,8 +414,10 @@ def test__get_tag_rotations(): with pytest.raises(Exception) as e_info: mya2._get_tag((0,0)) - -def test__step_down_tags_info(): +def test_annotation__step_down_tags_info(): + """ + test for annnotation's _step_down_tags_info functionality + """ junk_dict = dict(title = {"top":cow.text("banana", _type = "cow_title")}, caption = "minion") tag_dict = dict(tags = (["apple", "pear"], "i", "a"), @@ -493,9 +520,9 @@ def test_annotation_passing(): title = "no pass through, top defined, bottom generated") # see docs for expectation... -def test__clean_up_attributes(): +def test_annotation__clean_up_attributes(): """ - test _clean_up_attributes + test annotation's _clean_up_attributes """ @@ -544,9 +571,9 @@ def test__clean_up_attributes(): "(key = %s)" % key @pytest.mark.parametrize("location", ["title", "subtitle", "caption"]) -def test__calculate_margin_sizes_basic(location): +def test_annontation__calculate_margin_sizes_basic(location): """ - test _calculate_margin_sizes, static + test annotation's _calculate_margin_sizes, static / basic """ a0 = cow.annotation(**{location:"example title"}) a0_size_dict = a0._calculate_margin_sizes(to_inches=False) @@ -609,7 +636,10 @@ def test__calculate_margin_sizes_basic(location): ["top", "bottom", "left", "right"], ["top", "bottom", "left", "right"])) ) -def test__calculate_margin_sizes_non_basic(types, location1, location2): +def test_annotation__calculate_margin_sizes_non_basic(types, location1, location2): + """ + test annnotation's _calculate_margin_sizes (non-basic) + """ # lets of left, right, top, bottom + some other option. # allow for overrides and combinations @@ -672,12 +702,17 @@ def test__calculate_margin_sizes_non_basic(types, location1, location2): l1=locations[0], l2=locations[1]) @pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) -def test__calculate_tag_margin_sizes(location): +def test_annotation__calculate_tag_margin_sizes(data_regression, location): """ - test _calculate_tag_margin_sizes + test annotation's_calculate_tag_margin_sizes + + Details + ------- + Deals with + (1) if a tag should be created, + (2) nested versus non-nested + (3) different location types [TODO] rotation structures """ - # test if tag should actually be created - # nesting a0_list = cow.annotation(tags = ["banana", "apple"], tags_loc = location) @@ -690,9 +725,11 @@ def test__calculate_tag_margin_sizes(location): tags = ("1", "a"), tags_loc = location) - # check that tags don't impact __calculate_margin_sizes + # check that titles don't impact __calculate_tag_margin_sizes a0_all = [a0_list, a0_list_nest, a0_tuple_nest] a_info_str = ["list", "nested-list", "nested-tuple"] + + data_reg_dict = {} for a_idx, a0 in enumerate(a0_all): a0_title = a0 + cow.annotation(title = "Conformal Inference") @@ -704,6 +741,10 @@ def test__calculate_tag_margin_sizes(location): ("title attributes shouldn't impact the sizing structure for a "+\ "tag (structure: %s, loc %s)") % (a_info_str[a_idx], location) + # data regression tracking + data_reg_dict[str(a_idx)+"_base"] = \ + {key:str(value) for key, value in base.items()} + if a0.tags_depth == 2: # not title a0_s = a0._step_down_tags_info(1) @@ -716,7 +757,12 @@ def test__calculate_tag_margin_sizes(location): ("title attributes shouldn't impact the sizing structure for a "+\ "tag - stepdown (structure: %s, loc %s)") % (a_info_str[a_idx], location) - # fundamental + # data regression tracking + data_reg_dict[str(a_idx)+"_base_s"] = \ + {key:str(value) for key, value in base_s.items()} + + + # fundamental base_f = a0._calculate_tag_margin_sizes(index = 1, fundamental=True) base_plus_title_f = a0_title._calculate_tag_margin_sizes(index = 1, @@ -814,16 +860,355 @@ def test__calculate_tag_margin_sizes(location): ("tags based in auto creation shouldn't have finite length of "+\ "non-zero tags (structure: %s, loc %s, t_idx: %i)") % (a_info_str[a_idx], location, t_idx) + data_regression.check(data_reg_dict) + + +def test_annotation__get_tag_and_location(): + """ + test annotation's _get_tag_and_location + + Details + ------- + This group of tests is static, but examines a range of cases: + 1. nested versus single depth tags + 2. list versus infinite length tags + 3. all 4 location descriptions (and correctly deals with 90 degree rotation) + 4. across fundamental & non-fundamental tags + + We also look at 4 specical cases: + 1. [Non-error] full_index is None (fundamental & not) + 2. [Non-error] full_index type is integer (fundamental & not) + + 3. [Error] index != full_index[-1] + 4. [Error] width and/or height is too small + + Additional Notes + ---------------- + We also check manual regression tests on some min_sizing of text objects + """ + + a0_list_t = cow.text("banana", _type = "cow_tag") + a0_list_nest_t = cow.text("Age: old, Name: harry", _type = "cow_tag") + a0_tuple_nest_t = cow.text("Fig 5.b", _type = "cow_tag") + + ms_a0_list_t = a0_list_t._min_size() + ms_a0_list_nest_t = a0_list_nest_t._min_size() + ms_a0_tuple_nest_t = a0_tuple_nest_t._min_size() + + # "regression check" for default tag elements + assert np.allclose(ms_a0_list_t, (49.5, 13.5)) and \ + np.allclose(ms_a0_list_nest_t, (146.25, 13.5)) and \ + np.allclose(ms_a0_tuple_nest_t, (43.96875, 13.5)), \ + ("min_sizes of static examples should be fixed against " + + "current value (simple regression check)") + + # rotationed objects for left/right structure definition + a0_list_t_r = a0_list_t._additional_rotation(angle=90) + a0_list_nest_t_r = a0_list_nest_t._additional_rotation(angle=90) + a0_tuple_nest_t_r = a0_tuple_nest_t._additional_rotation(angle=90) + + ms_a0_list_t_r = a0_list_t_r._min_size() + ms_a0_list_nest_t_r = a0_list_nest_t_r._min_size() + ms_a0_tuple_nest_t_r = a0_tuple_nest_t_r._min_size() + + # another check on rotation sizes + assert np.allclose(ms_a0_list_t, ms_a0_list_t_r[::-1]) and \ + np.allclose(ms_a0_list_nest_t, ms_a0_list_nest_t_r[::-1]) and \ + np.allclose(ms_a0_tuple_nest_t, ms_a0_tuple_nest_t_r[::-1]), \ + ("static examples sizes post 90 degree rotation should just be" + + "flipped") + + + # actual checks set-up + standard_width, standard_height = 175, 175 + + tag_loc_d = { + "a0_list_d": {"top": (0,0), "left": (0,0), + "right": (standard_width - ms_a0_list_t_r[0],0), + "bottom": (0, standard_height - ms_a0_list_t[1])}, + "a0_list_nest_d": {"top": (0,0), "left": (0,0), + "right": (standard_width - ms_a0_list_nest_t_r[0],0), + "bottom": (0, standard_height - ms_a0_list_nest_t[1])}, + "a0_tuple_nest_d": {"top": (0,0), "left": (0,0), + "right": (standard_width - ms_a0_tuple_nest_t_r[0],0), + "bottom": (0, standard_height - ms_a0_tuple_nest_t[1])} + } + + image_loc_d = { + "a0_list_d": {"bottom": (0,0), "right": (0,0), + "left": (ms_a0_list_t_r[0], 0), + "top": (0, ms_a0_list_t[1])}, + "a0_list_nest_d": {"bottom": (0,0), "right": (0,0), + "left": (ms_a0_list_nest_t_r[0], 0), + "top": (0, ms_a0_list_nest_t[1])}, + "a0_tuple_nest_d": {"bottom": (0,0), "right": (0,0), + "left": (ms_a0_tuple_nest_t_r[0], 0), + "top": (0, ms_a0_tuple_nest_t[1])} + } + + # real output (tag_loc and image_loc) + for location in ["top", "bottom", "left", "right"]: + a0_list = cow.annotation(tags = ["banana", "apple"], + tags_loc = location) + a0_list_nest = cow.annotation(tags = (["young", "old"], + ["harry", "hermione", "ron"]), + tags_loc = location, + tags_format = ("Age: {0}", "Age: {0}, Name: {1}")) + + a0_tuple_nest = cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a"), + tags_loc = location) + + ann_options = [a0_list, a0_list_nest, a0_tuple_nest] + ann_strings = ["a0_list_d", "a0_list_nest_d", "a0_tuple_nest_d"] + full_index_options = [(1,), (1,0), (5,1)] + + # full input is provided (no None) + for ann_index in [0,1,2]: + ann = ann_options[ann_index] + ann_str = ann_strings[ann_index] + full_index = full_index_options[ann_index] + index = full_index[-1] + + tag_loc, image_loc, _ = ann._get_tag_and_location(index=index, + full_index=full_index, + width=standard_width, + height=standard_height, + fundamental=False) + + assert np.allclose(tag_loc, tag_loc_d[ann_str][location]), \ + ("tag loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size") % (ann_str, location) + + assert np.allclose(image_loc, image_loc_d[ann_str][location]), \ + ("image loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size") % (ann_str, location) + + # full_index = None (no error), + # would be seen as non-fundamental for nested items (why we only look at case 0) + for ann_index in [0]: + ann = ann_options[ann_index] + ann_str = ann_strings[ann_index] + index = full_index_options[ann_index][-1] + + tag_loc, image_loc, _ = ann._get_tag_and_location(index=index, + full_index=None, + width=standard_width, + height=standard_height, + fundamental=False) + + assert np.allclose(tag_loc, tag_loc_d[ann_str][location]), \ + ("tag loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size, "+ + "even when full_index is None") % (ann_str, location) + + assert np.allclose(image_loc, image_loc_d[ann_str][location]), \ + ("image loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size, "+ + "even when full_index is None") % (ann_str, location) + + # type(full_index) = integer (no error), + # would be seen as non-fundamental for nested items (why we only look at case 0) + for ann_index in [0]: + ann = ann_options[ann_index] + ann_str = ann_strings[ann_index] + index = full_index_options[ann_index][-1] + full_index = index + + tag_loc, image_loc, _ = ann._get_tag_and_location(index=index, + full_index=full_index, + width=standard_width, + height=standard_height, + fundamental=False) + + assert np.allclose(tag_loc, tag_loc_d[ann_str][location]), \ + ("tag loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size, "+ + "even when full_index is None") % (ann_str, location) + + assert np.allclose(image_loc, image_loc_d[ann_str][location]), \ + ("image loc (%s, %s) doesn't match expected as a function " + + "of location and tag min_size, "+ + "even when full_index is None") % (ann_str, location) + + + # special cases - no real output + for location in ["top", "bottom", "left", "right"]: + + a0_list = cow.annotation(tags = ["banana", "apple"], + tags_loc = location) + a0_list_nest = cow.annotation(tags = (["young", "old"], + ["harry", "hermione", "ron"]), + tags_loc = location, + tags_format = ("Age: {0}", "Age: {0}, Name: {1}")) + + a0_tuple_nest = cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a"), + tags_loc = location) + + # returns (None)^3 --------------- + # fundamental = False + out_list_fF = a0_list_nest._get_tag_and_location(index=0, + full_index=(0,), + width=100, # really large + height=100, # really large + fundamental=False) + + assert np.all(out_list_fF == (None,None,None)), \ + ("if fundamental is False, return None^3 is expected from " + + "_get_tag_and_location, loc: %s, type = list" % location) + + out_tuple_fF = a0_tuple_nest._get_tag_and_location(index =0, + full_index = (0,), + width=100, # really large + height=100, # really large + fundamental=False) + assert np.all(out_tuple_fF == (None,None,None)), \ + ("if fundamental is False, return None^3 is expected from " + + "_get_tag_and_location, loc: %s, type = tuple" % location) + + + # outside list size + out_list_l1 = a0_list_nest._get_tag_and_location(index=3, + full_index=(3,), + width=100, # really large + height=100) # really large + assert np.all(out_tuple_fF == (None,None,None)), \ + ("if index > list length, return None^3 is expected from " + + "_get_tag_and_location, loc: %s, type = list, level 1" % location) + + out_list_l2 = a0_list_nest._get_tag_and_location(index=5, + full_index=(0,5), + width=100, # really large + height=100) # really large + + assert np.all(out_tuple_fF == (None,None,None)), \ + ("if index > list length, return None^3 is expected from " + + "_get_tag_and_location, loc: %s, type = list, level 2" % location) + + # None for full index + fundamental=False with nested structure + # leads to (None)^2 output + for ann_index in [1,2]: + ann = ann_options[ann_index] + ann_str = ann_strings[ann_index] + index = full_index_options[ann_index][-1] + + info_out = ann._get_tag_and_location(index=index, + full_index=None, + width=standard_width, + height=standard_height, + fundamental=False) + + assert np.all(info_out == (None,None,None)), \ + ("tag loc (%s, %s) with full_index=None, fundamental=False" + + "should not produce any tags, leading to all output of "+ + "_get_tag_and_location being None") % (ann_str, location) + + # type(full_index) = integer + fundamental=False with nested structure + # leads to (None)^2 output + for ann_index in [1,2]: + ann = ann_options[ann_index] + ann_str = ann_strings[ann_index] + index = full_index_options[ann_index][-1] + full_index = index + + info_out = ann._get_tag_and_location(index=index, + full_index=full_index, + width=standard_width, + height=standard_height, + fundamental=False) + assert np.all(info_out == (None,None,None)), \ + ("tag loc (%s, %s) with type(full_index)=int, fundamental=False" + + "should not produce any tags, leading to all output of "+ + "_get_tag_and_location being None") % (ann_str, location) + + # errors ----------- + # index != full_index[-1] (error) + with pytest.raises(Exception) as e_info: + a0_list._get_tag_and_location(index=5, + full_index=(4), + width=100, # really large + height=100) + with pytest.raises(Exception) as e_info: + a0_list_nest._get_tag_and_location(index=5, + full_index=(0,4), + width=100, # really large + height=100) + with pytest.raises(Exception) as e_info: + a0_tuple_nest._get_tag_and_location(index=5, + full_index=(0,4), + width=100, # really large + height=100) + + # width or height too small (error) + + with pytest.raises(Exception) as e_info: + a0_list._get_tag_and_location(index=0, + full_index=(0), + width=1, # really small + height=1) + with pytest.raises(Exception) as e_info: + a0_list_nest._get_tag_and_location(index=0, + full_index=(0,0), + width=1, # really small + height=1) + with pytest.raises(Exception) as e_info: + a0_tuple_nest._get_tag_and_location(index=0, + full_index=(0,0), + width=1, # really small + height=1) + + + +@pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) +@pytest.mark.parametrize("ann_index", [0,1,2]) +def test__get_tag_and_location2(image_regression, location, ann_index): + """ + regression tests for tag images for get_tag_and_location + + Details + ------- + Includes implicit rotations, different nesting, different types + of tags (lists or types) + """ + + a0_list = cow.annotation(tags = ["banana", "apple"], + tags_loc = location) + a0_list_nest = cow.annotation(tags = (["young", "old"], + ["harry", "hermione", "ron"]), + tags_loc = location, + tags_format = ("Age: {0}", "Age: {0}, Name: {1}")) + + a0_tuple_nest = cow.annotation(tags_format = ("Fig {0}", "Fig {0}.{1}"), + tags = ("1", "a"), + tags_loc = location) + + # tag names for manual checks + tag_names = ["banana", "Age: old, Name: harry", "Fig 5.b"] + + + ann_options = [a0_list, a0_list_nest, a0_tuple_nest] + full_index_options = [(1,), (1,0), (5,1)] + + # actual assessment + ann = ann_options[ann_index] + full_index = full_index_options[ann_index] + index = full_index[-1] + + _,_,tag_image = ann._get_tag_and_location(width=175, + height=175, + index=index, + full_index = full_index) + + + with io.BytesIO() as fid2: + _save_svg_wrapper(tag_image, fid2, + width=to_inches(175, "pt"), + height=to_inches(175, "pt"), + _format="png", verbose=False) + image_regression.check(fid2.getvalue(), diff_threshold=.1) + -def test__get_tag_and_location(): - # to test we need to make some tags, - # get a good undestanding of the size of the tag (height & width) - # look through 4 different locations for the tag - # also look depths down - # and fundamental-ness - # identify where it should land and where the image should land - # create a similar tag and - raise ValueError("Not Tested") def test__get_titles_and_locations(): # create a set of static combinations of diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_bottom_.png b/tests/test_annotation_elements/test__get_tag_and_location2_0_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..858804f3e7ec22848b823a9a40c7102f4cc8132a GIT binary patch literal 4775 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 DgG&Z+ literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_right_.png b/tests/test_annotation_elements/test__get_tag_and_location2_0_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..aa1dc0fca3bf25c84eb9391895f6e4cf8a6d5b24 GIT binary patch literal 4848 zcmeH}YgAKL7ROIWOSB49t%^X13lV2&i}k5+JduvZ_3^ zRz(p|sRnY%Ly;hX5ReKM5!8AE$RlDp0mCDvknjkBxv8_(tXb`RnD3so?w5V{x#xfW z=ePGgKkWARHT%r!GXQ|u_HA1N0Wj$>KC?{G$hW&l1pv&i*uG_BQ1XqT?v&<3(P53f zt$v&bi^P6MVo!^(KRH`%xUwmBPT)f9_3Jl3-y{2c_ZG9I3mtB5-0;J>&$n&+;(~+O z&Y+*?*{`x&`qdYAFx2zAVs5V5Hpj2xqDUnAZOKgXT{m^gSW-dUWH{x7TV-LTT0iDF z<5#mT@rns5;p6z|z()uE9|u?mSms;QK8YOdZyXufGp?P&D@VhxW@hn~5OjKvFED5i>)?hh@1 z^_K^*evL1XO16BuT1+=7=UZjA=58wFUqjse<9 zapnyb!By@L7X#}*m)XVE=w{2T(o>ZUvt=f<*{I-?T|`TD2$~)Kt5-B}(S6KazbB@- z+3L9s9Lh5Pn=MyVC2YBK;RgJ_l^)Cr?@SUnAtR2v``$TCk2gm{_5K8v7&=VF{MriLk=*BtFFt!#mAN_FO?Yi+ggkc?qgg4PQ>pHoO7U}vRtprT~ z0GDNNmBu=xelvayYM(#iV$;mfxan8$6wXq$MZ4z$AqKSnA_|gHyI&@o=p3?oLeS2i z*P~OJ@ltcNI~t`yi~S_A;7WN{)jmvaO&qchxx_6jZYj>@*|zt~2N+yyIL|6=xnU*3 zfnz!H|z`qWb>xiorD@$Ll>L`o<)-0SH<~raA)?6}{#haz4sM@{8aOx9N zT9c^7_=$&tdG|4Z*8Dvx+H$3Zm&wtt?Z^+tBtAbrA6oF&CC~`A=aLzb>CY_)$q&Sr zsn=G&CU*yRhlK453-4ado(AB}&13P}X_YueZI$B*y9Bww*#+rOQL73IWc}PxDqDM^ zaR}*z?K!VW05o45Lbl?QAKKc=x#{ue`cPSkz2B?OH4ubqk}gQPy1WcSn}=%iYtta! zlxRLNIzjtP6^kUno!lz8h>Hv2kaM`h(k5GJxI|wYYKk7N?&1}xiJ?QaH7nj>-!qwN zP=(LxS$n^WeR0K^6ad!thdqB8Ic|+_PkYtqF8)^Xs;97ZhH3(i5p<6iWxQeXvaNrc zcHy0J*`t_p9kM_r{qyHV8S()*s&Ri`*3MTceYvp!)b1Zgv*mp#+Xil|Dde1IWLpTy z%)rt4%gh?&0_MT|(J0b)MCyap3M2V;*d(*xs`&?T_W%M1QL!_z=}qpJ36J@3Bge<&nA?F@4<`fNQCbG zngPRD^{vT!*ujMWm|KN%y$|>D>=|$ZnHiWLhOcR|^n3(tQtUCR5G6g$93Wt*4zDgBA2pZzG^|1$&}CjV97ytEXt;e zf3$aDU&?ytg7yyV(SM-q0TAVQ01Ik!XQQ_{;-St%-JsU-L}XFM;=T?DCH?P4`zqt| zVA0wK^po1}V|=0J8pQ>bD24(hLMhriF@2+^~tzMy2o!Pq{g(P>5r&YJob(kfY(M*=) zn}PGm@8{9P{(NViKQ*#t2)T?b%;NjaB$abKZqqkF^r8%Vf_Mk7jhx^mJcWL8I_>ry z!Fu^#0|ad>a?dP%To20y)3Mh`YQC*^Ll2rUJhfwIMz3HE{h-{ZTRK_fW8rN+R#W|& z4j8D={Z+Rg+l~L|Q^CrtUkQ6&E!LDpy(Xalqb7T*O}d~Y+a@u8&eh_PVb?YQSe5Tf zQF^2OZnnr&+>QAz0Vp6VjuqP0o}4RpJds$Gv9vKRKwC$S!4e~l;NBowGE2m znK`iewcSqO%Afy&;kt28lw1R?n)70X3FZU3r_38N0*vTdJBG|*IT*XThQQYRx}dZ? zia7oH6ZCLvn{9&Aq-P?cYOe-yeuSEv^e3OiS_o_-4F1~7 zb+!yRfV|AVI049LPfM*q`<9-)q?1z3*T}mS&C+|~5^?zpL#J=BuluCmr0Ae*DnUEp z6Unf5=7me^%26~SifEiWQjK36FgS==GmlV&0W8-A|J`u?T62QER}DA@--9O`O7s4X z-48L=wUNiZtchcrAYF~(Csl4*Ap=A!hV~;yD?? z!7jn>L27PGr&`@AI2GIpE8sFNCnzAMck^IUkbv)1DNm3u2^c_k$t-yJHe>!+kl=`B zYfgZfl9YOl)LQB#{G~wZ-XlZp_X_KJdm*jYEwRn&z8czC9xGH{#=Rz@qRTc@ZN8H} z|D&~`)crtPzwBb?SZG-l{n6=FXpxT$-gBxsXDD5$O!yLgsnQakpoFKr90|2dS98rH zBBzN53PlYb8NqVPQc`S`6IQqs*J6T%()qt1StVrm1|k=Gs6p)J3ayiWvVice$}C^Os~29yCp@ zz$p^j_6!_nYi3r_qt^h?#aUl^Mu2pN;qN_~@-#bp24D)h&;F6sE3dbKs>+Kg@yPU#&#p@*53FL-wKt6e;6 zYf!V}CAZJ?j>W>w%mYQ8JI>yk7(fciE+qvNiD>fe{1IJnMUZdS#Co*E;s=X$4~U-> zN}8@GiB2Ym4`{9*FZ={Ol{^e7lqOPSiSW#n&XG|t^5Ir(pjrZdgT2> zc2C#wCl5d}4^`>|dSnsf0gAVSO`xP&%)cxMpNZDq*uMpn`}NE$tj&JWD)DWD0-2rV zbuE2wQJW!+|Cn&^Z|^Ywy8=GG`RKq$2T%u;PiHVSt6kpMC+>J{G-Uf$|1JDYwD0}} DgG&Z+ literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_top_.png b/tests/test_annotation_elements/test__get_tag_and_location2_0_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..858804f3e7ec22848b823a9a40c7102f4cc8132a GIT binary patch literal 4775 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 literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_left_.png b/tests/test_annotation_elements/test__get_tag_and_location2_2_left_.png new file mode 100644 index 0000000000000000000000000000000000000000..5815b3bc33bf9233a30d5257b1060710dddd3ef1 GIT binary patch literal 3994 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 TU-aR(F#vAPo=)8J#5?~5NLWUb literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_right_.png b/tests/test_annotation_elements/test__get_tag_and_location2_2_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..5815b3bc33bf9233a30d5257b1060710dddd3ef1 GIT binary patch literal 3994 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 TU-aR(F#vAPo=)8J#5?~5NLWUb literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_top_.png b/tests/test_annotation_elements/test__get_tag_and_location2_2_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..e6534536a48370751d1cc30c16c5ac12ad36ff3f GIT binary patch literal 4034 zcmeHJ`8V5H8^3fqrrL1Slv+A^Xsg>$gIdEomZGr~i7f~Xp=zm`h@CU1rH0hW>9n+s zq^hk~5=*Ql60y`$L)tN4OBGd1+6bv7p^fmydH;$z$35r%^4!lkpU?BT&vT!ff5p=U zrmU?D0D!rH((!pqlUI^eZxjyfm4~i1ZKS&P>N45C?Co98qp` zeRA^WU(e`YBL6doa-5$uCt5Xsq48E~67a+|7+ zc#EyLkAZ%5&DQPT8U;Oq8xuQP*~~ONe&MOQ5(u+)mfW;0OC#V3YhMGWeKL0(rUA(D z3`|zsvY+;sMdJnJ1=h>5%sHCXpc3F7C>gmo*#CJ*fQ6PIe{H`uUKQn^T4g&wGSx3+ z^bIRh=1}(?7@KYKjo3ls#k!Q##qR0$fDjWkn+th-Mgb3*K-=cDwuGm5nP?*0gV;~2 zUP(SfI&{Qp{eBSeh)EH88Jm~0XQFhnM)K(PHAWL1x?WYBcPT{@(cno00Le}ywytsI zRB(vdtgZb&o5h9+UV3(b`}gU(Vk~9)CBdiNOXT2Q;#GuY;2 z)@NO+0^r`c7fvA6)AcBKmJ%%QL{@Oh(`J~9PjkCMhGIYAw>Jb-I>9fYN+n*Kup?+u zPm*E81k<^?^LjPQF)0;;KXGma0}#zz(qFSJTqqP+==?FDZpD}%fCgwp?5v*2O?U#M_Kiv{1t3%~?7A#zv!#XWed>+F{?(=Z?kr`hw!XJ@+~Tm5dOJ!1)b=tE zUXuL~EUtz-C42>ArN!!sdXC)85_3tL#TunQ0B&f#%DM3_T-4IxAMxs7MV&CAr}jxmDw-6 zvyzAeBsF%Cm64opC6|~=_ldx~PYKV!;zte)B_@kWBrCs`zU`Kt>G}Hu?#dvnM(12z zky&h1^YG%5FU1@|lC_eUQhFZcQ^8M3S&rxWt$!TnqX-RRc>EG)%gT$JT*-foDW+ec^xQX#j z5go$tzAzp~9kp3lC5^e@$ZO?L5{``3%6j^3mbAGopyeLP0zf0e##xeWAq+~adl`K~ zQmMt?nk$?wo=Jr;$O_MHRU|K_G3~Vi80kFQT;MLjjtVQZrY_EII!j3cW4hF%Th)(O z6(5vx2>?bciR}$fIl&pu5~d$3N!-$xg*i%xWQHH}w1b@DgLg4VIyz#yX2q{K;V(;3 zhgPrgYhT(TUCF&$RGn3qHri>b2tJ_w8vE>!ljeu1=dGI;g>q1*I-P)2CEwAjtd4Io zu22LQl;}YbqqP(L&y)r&`HWe@#B+JabMiOg5Q4s_K~Vz$&A3U*>~$5Im=0MjZchH@ zCSGpJnpuk$BcR8cyMXfDdtG-G=9>Y1#L({=1`eGhU@?w&guFwfCkmTqtmG((YXX-J~Ccw4YV;)VLk zwyzE=fDcY`A+i-gk~)(pK-rKJDrS=h2q;k{Cuy!8Qu;Z8U*X@i!B`F|itb`ylE3R|7h_Vr~tM=u-kYRsr|*(=m8y-=?ue zM&K8x3@`D0lGOuL1m;R+_+_K)+7I#;Eh$HxsiTLQ{jwe1W%te=Q)(Ug{u&HDeQeBZ zp^22L5g0)w2vf15wQu_uOh=>yq+oe7|n-y6~ylCYcqiWa37^Op#7Pu<_!;j>na+l>ve|SY&CjQBaqO^K}(5Wvcem3YVnZtWEwtw^KQu{o-%-XQ_k*!}H zmXE4Zcbh_7X)IFJsv}($X#}&oud*T}kG`nwnJ>@fA!Arm>RD-2 zQ*A5Z6a0%9p46K=OArx;DamjVV^~xubp*Z1uP+?+j~svW|9{~tA-MW+A& literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_bottom_.yml b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_bottom_.yml new file mode 100644 index 0000000..a236229 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_bottom_.yml @@ -0,0 +1,35 @@ +0_base: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '36.84375' + top_left_loc: (0.0, 0.0) +1_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +1_base_s: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '55.40625' + top_left_loc: (0.0, 0.0) +2_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +2_base_s: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '43.59375' + top_left_loc: (0.0, 0.0) diff --git a/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_left_.yml b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_left_.yml new file mode 100644 index 0000000..b9eb820 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_left_.yml @@ -0,0 +1,35 @@ +0_base: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '36.84375' + min_inner_width: '0.0' + top_left_loc: (13.500000000000004, 0.0) +1_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +1_base_s: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '55.40625' + min_inner_width: '0.0' + top_left_loc: (13.500000000000004, 0.0) +2_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +2_base_s: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '43.59375' + min_inner_width: '0.0' + top_left_loc: (13.500000000000004, 0.0) diff --git a/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_right_.yml b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_right_.yml new file mode 100644 index 0000000..c59c534 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_right_.yml @@ -0,0 +1,35 @@ +0_base: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '36.84375' + min_inner_width: '0.0' + top_left_loc: (0.0, 0.0) +1_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +1_base_s: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '55.40625' + min_inner_width: '0.0' + top_left_loc: (0.0, 0.0) +2_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +2_base_s: + extra_used_height: '0.0' + extra_used_width: '13.500000000000004' + min_full_width: '0' + min_inner_height: '43.59375' + min_inner_width: '0.0' + top_left_loc: (0.0, 0.0) diff --git a/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_top_.yml b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_top_.yml new file mode 100644 index 0000000..8fd63b9 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_tag_margin_sizes_top_.yml @@ -0,0 +1,35 @@ +0_base: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '36.84375' + top_left_loc: (0.0, 13.5) +1_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +1_base_s: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '55.40625' + top_left_loc: (0.0, 13.5) +2_base: + extra_used_height: '0' + extra_used_width: '0' + min_full_width: '0' + min_inner_height: '0' + min_inner_width: '0' + top_left_loc: (0, 0) +2_base_s: + extra_used_height: '13.5' + extra_used_width: '0.0' + min_full_width: '0' + min_inner_height: '0.0' + min_inner_width: '43.59375' + top_left_loc: (0.0, 13.5) diff --git a/tests/test_text_elements.py b/tests/test_text_elements.py index 51fa17d..cbbd2d3 100644 --- a/tests/test_text_elements.py +++ b/tests/test_text_elements.py @@ -239,6 +239,94 @@ def test_text__update_element_text_from_theme2(): "text object, so we expect an error (c, theme 2)" +def test_text__additional_rotation0(image_regression): + """ + test text's _additional_rotation function + + ensure that 0 angle rotation returns the same object + """ + # no angle change means that same object + myt = cow.text(label="Portland", + element_text=p9.element_text(size=15,angle=15), + _type="cow_text") + + myt2 = myt._additional_rotation() + + assert myt2 == myt, \ + "default rotation of 0 should return the same text element" + + myt2_2 = myt._additional_rotation(angle=0) + + assert myt2_2 == myt, \ + "explicit rotation of 0 should return the same text element" + + # no element text pre-defined (this highlights the function shouldn't + # be used outside internal functions + + myt_b = cow.text(label="Portland", + _type="cow_text") + + myt_b2 = myt_b._additional_rotation() + + assert myt_b2 == myt_b, \ + ("default rotation of 0 should return the same text element, " + + "initial has non element_text defined") + + myt_b2_2 = myt_b._additional_rotation(angle=0) + + assert myt_b2_2 == myt_b, \ + ("explicit rotation of 0 should return the same text element, " + + "initial has non element_text defined") + + # image regression for partially rotated object + with io.BytesIO() as fid2: + myt.save(filename=fid2, _format = "png") + image_regression.check(fid2.getvalue(), diff_threshold=.1) + +def test_text__additional_rotation(image_regression): + """ + test text's _additional_rotation function + + ensures correct addition of rotation without overwriting other + parmeters + """ + # additional parameters not lost + myt = cow.text(label="Portland", + element_text=p9.element_text(size=15,angle=15,color="blue"), + _type="cow_text") + + myt2 = myt._additional_rotation(angle = 20) + + old = myt.element_text.theme_element.properties + new_expected = old.copy() + new_expected["rotation"] = 35 + + assert myt2.element_text.theme_element.properties == new_expected, \ + ("_additional_rotation should only impact the angle, not other "+ + "element attributes") + + # beyond 360 only does a max of 360 degrees + myt2_b = myt._additional_rotation(angle = 350) + + assert np.allclose(myt2_b.element_text.theme_element.properties["rotation"], 5), \ + "_additional_rotation should keep angle betwen [0, 360)" + + myt2_b2 = myt._additional_rotation(angle = 345) + + assert (myt2_b2.element_text.theme_element.properties["rotation"] < 360) and \ + (np.allclose(myt2_b2.element_text.theme_element.properties["rotation"], 0) or + np.allclose(myt2_b2.element_text.theme_element.properties["rotation"], 360)), \ + "_additional_rotation should keep angle betwen [0, 360), 360 strict" + + + # image regression for partially rotated object + with io.BytesIO() as fid2: + myt2.save(filename=fid2, _format = "png") + image_regression.check(fid2.getvalue(), diff_threshold=.1) + + + + def test_text__get_full_element_text(): """ test _get_full_element_text (static test - using _type text not cow_text, etc.) diff --git a/tests/test_text_elements/test_text__additional_rotation.png b/tests/test_text_elements/test_text__additional_rotation.png new file mode 100644 index 0000000000000000000000000000000000000000..970b40c0b6f18c765ca4362545ea5bc90159d118 GIT binary patch literal 1538 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 literal 0 HcmV?d00001 From 1a21c1e030f24ecdd7ce1182a5676d4b52c65ab9 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sat, 28 Jan 2023 14:39:47 -0800 Subject: [PATCH 11/13] minor rename of annotation test function to align with overall test function name styles --- tests/test_annotation_elements.py | 6 +++--- ...annotation__get_tag_and_location2_0_bottom_.png} | Bin ...t_annotation__get_tag_and_location2_0_left_.png} | Bin ..._annotation__get_tag_and_location2_0_right_.png} | Bin ...st_annotation__get_tag_and_location2_0_top_.png} | Bin ...annotation__get_tag_and_location2_1_bottom_.png} | Bin ...t_annotation__get_tag_and_location2_1_left_.png} | Bin ..._annotation__get_tag_and_location2_1_right_.png} | Bin ...st_annotation__get_tag_and_location2_1_top_.png} | Bin ...annotation__get_tag_and_location2_2_bottom_.png} | Bin ...t_annotation__get_tag_and_location2_2_left_.png} | Bin ..._annotation__get_tag_and_location2_2_right_.png} | Bin ...st_annotation__get_tag_and_location2_2_top_.png} | Bin 13 files changed, 3 insertions(+), 3 deletions(-) rename tests/test_annotation_elements/{test__get_tag_and_location2_0_bottom_.png => test_annotation__get_tag_and_location2_0_bottom_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_0_left_.png => test_annotation__get_tag_and_location2_0_left_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_0_right_.png => test_annotation__get_tag_and_location2_0_right_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_0_top_.png => test_annotation__get_tag_and_location2_0_top_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_1_bottom_.png => test_annotation__get_tag_and_location2_1_bottom_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_1_left_.png => test_annotation__get_tag_and_location2_1_left_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_1_right_.png => test_annotation__get_tag_and_location2_1_right_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_1_top_.png => test_annotation__get_tag_and_location2_1_top_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_2_bottom_.png => test_annotation__get_tag_and_location2_2_bottom_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_2_left_.png => test_annotation__get_tag_and_location2_2_left_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_2_right_.png => test_annotation__get_tag_and_location2_2_right_.png} (100%) rename tests/test_annotation_elements/{test__get_tag_and_location2_2_top_.png => test_annotation__get_tag_and_location2_2_top_.png} (100%) diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index 59694b5..ea5025f 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -1162,9 +1162,9 @@ def test_annotation__get_tag_and_location(): @pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) @pytest.mark.parametrize("ann_index", [0,1,2]) -def test__get_tag_and_location2(image_regression, location, ann_index): +def test_annotation__get_tag_and_location2(image_regression, location, ann_index): """ - regression tests for tag images for get_tag_and_location + regression tests for tag images for annotation's _get_tag_and_location Details ------- @@ -1210,7 +1210,7 @@ def test__get_tag_and_location2(image_regression, location, ann_index): -def test__get_titles_and_locations(): +def test_annotation__get_titles_and_locations(): # create a set of static combinations of # titles, subtitles, captions (in different locations) # identify expected locations and potentially try to create diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_bottom_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_bottom_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_0_bottom_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_bottom_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_left_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_left_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_0_left_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_left_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_right_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_right_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_0_right_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_right_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_0_top_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_top_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_0_top_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_0_top_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_1_bottom_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_bottom_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_1_bottom_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_bottom_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_1_left_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_left_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_1_left_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_left_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_1_right_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_right_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_1_right_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_right_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_1_top_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_top_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_1_top_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_1_top_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_bottom_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_bottom_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_2_bottom_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_bottom_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_left_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_left_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_2_left_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_left_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_right_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_right_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_2_right_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_right_.png diff --git a/tests/test_annotation_elements/test__get_tag_and_location2_2_top_.png b/tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_top_.png similarity index 100% rename from tests/test_annotation_elements/test__get_tag_and_location2_2_top_.png rename to tests/test_annotation_elements/test_annotation__get_tag_and_location2_2_top_.png From 411f2aa589cf8714cebaf41ef0c3aa9408d93745 Mon Sep 17 00:00:00 2001 From: benjaminleroy Date: Sun, 29 Jan 2023 12:41:52 -0800 Subject: [PATCH 12/13] completion of version update related annotation development, text and base not technically complete --- src/cowpatch/annotation_elements.py | 131 ++++-- src/cowpatch/base_elements.py | 18 +- src/cowpatch/text_elements.py | 20 +- tests/test_annotation_elements.py | 428 +++++++++++++++++- ...te_margin_sizes_static_bottom_caption_.yml | 8 + ...e_margin_sizes_static_bottom_subtitle_.yml | 8 + ...late_margin_sizes_static_bottom_title_.yml | 8 + ...late_margin_sizes_static_left_caption_.yml | 8 + ...ate_margin_sizes_static_left_subtitle_.yml | 8 + ...culate_margin_sizes_static_left_title_.yml | 8 + ...ate_margin_sizes_static_right_caption_.yml | 8 + ...te_margin_sizes_static_right_subtitle_.yml | 8 + ...ulate_margin_sizes_static_right_title_.yml | 8 + ...ulate_margin_sizes_static_top_caption_.yml | 8 + ...late_margin_sizes_static_top_subtitle_.yml | 8 + ...lculate_margin_sizes_static_top_title_.yml | 8 + ...tation__get_title_ir_0_caption_bottom_.png | Bin 0 -> 552 bytes ...notation__get_title_ir_0_caption_left_.png | Bin 0 -> 552 bytes ...otation__get_title_ir_0_caption_right_.png | Bin 0 -> 552 bytes ...nnotation__get_title_ir_0_caption_top_.png | Bin 0 -> 552 bytes ...ation__get_title_ir_0_subtitle_bottom_.png | Bin 0 -> 1284 bytes ...otation__get_title_ir_0_subtitle_left_.png | Bin 0 -> 1012 bytes ...tation__get_title_ir_0_subtitle_right_.png | Bin 0 -> 1147 bytes ...notation__get_title_ir_0_subtitle_top_.png | Bin 0 -> 1021 bytes ...notation__get_title_ir_0_title_bottom_.png | Bin 0 -> 1229 bytes ...annotation__get_title_ir_0_title_left_.png | Bin 0 -> 804 bytes ...nnotation__get_title_ir_0_title_right_.png | Bin 0 -> 956 bytes ..._annotation__get_title_ir_0_title_top_.png | Bin 0 -> 882 bytes ...tation__get_title_ir_1_caption_bottom_.png | Bin 0 -> 552 bytes ...notation__get_title_ir_1_caption_left_.png | Bin 0 -> 552 bytes ...otation__get_title_ir_1_caption_right_.png | Bin 0 -> 552 bytes ...nnotation__get_title_ir_1_caption_top_.png | Bin 0 -> 552 bytes ...ation__get_title_ir_1_subtitle_bottom_.png | Bin 0 -> 1072 bytes ...otation__get_title_ir_1_subtitle_left_.png | Bin 0 -> 794 bytes ...tation__get_title_ir_1_subtitle_right_.png | Bin 0 -> 847 bytes ...notation__get_title_ir_1_subtitle_top_.png | Bin 0 -> 804 bytes ...notation__get_title_ir_1_title_bottom_.png | Bin 0 -> 1292 bytes ...annotation__get_title_ir_1_title_left_.png | Bin 0 -> 943 bytes ...nnotation__get_title_ir_1_title_right_.png | Bin 0 -> 1049 bytes ..._annotation__get_title_ir_1_title_top_.png | Bin 0 -> 978 bytes ...tation__get_title_ir_2_caption_bottom_.png | Bin 0 -> 552 bytes ...notation__get_title_ir_2_caption_left_.png | Bin 0 -> 552 bytes ...otation__get_title_ir_2_caption_right_.png | Bin 0 -> 552 bytes ...nnotation__get_title_ir_2_caption_top_.png | Bin 0 -> 552 bytes ...ation__get_title_ir_2_subtitle_bottom_.png | Bin 0 -> 1374 bytes ...otation__get_title_ir_2_subtitle_left_.png | Bin 0 -> 1415 bytes ...tation__get_title_ir_2_subtitle_right_.png | Bin 0 -> 1415 bytes ...notation__get_title_ir_2_subtitle_top_.png | Bin 0 -> 1374 bytes ...notation__get_title_ir_2_title_bottom_.png | Bin 0 -> 1721 bytes ...annotation__get_title_ir_2_title_left_.png | Bin 0 -> 1734 bytes ...nnotation__get_title_ir_2_title_right_.png | Bin 0 -> 1734 bytes ..._annotation__get_title_ir_2_title_top_.png | Bin 0 -> 1721 bytes ...n__get_titles_and_locations_basic_full.png | Bin 0 -> 41087 bytes ...les_and_locations_basic_level_caption_.png | Bin 0 -> 12980 bytes ...es_and_locations_basic_level_subtitle_.png | Bin 0 -> 23206 bytes ...itles_and_locations_basic_level_title_.png | Bin 0 -> 25902 bytes ...locations_basic_single_caption_bottom_.png | Bin 0 -> 12980 bytes ...d_locations_basic_single_caption_left_.png | Bin 0 -> 12980 bytes ..._locations_basic_single_caption_right_.png | Bin 0 -> 12980 bytes ...nd_locations_basic_single_caption_top_.png | Bin 0 -> 12980 bytes ...ocations_basic_single_subtitle_bottom_.png | Bin 0 -> 15850 bytes ..._locations_basic_single_subtitle_left_.png | Bin 0 -> 16304 bytes ...locations_basic_single_subtitle_right_.png | Bin 0 -> 16338 bytes ...d_locations_basic_single_subtitle_top_.png | Bin 0 -> 15852 bytes ...d_locations_basic_single_title_bottom_.png | Bin 0 -> 16801 bytes ...and_locations_basic_single_title_left_.png | Bin 0 -> 17289 bytes ...nd_locations_basic_single_title_right_.png | Bin 0 -> 17221 bytes ..._and_locations_basic_single_title_top_.png | Bin 0 -> 17002 bytes tests/test_text_elements.py | 19 + 69 files changed, 650 insertions(+), 62 deletions(-) create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_caption_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_subtitle_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_title_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_caption_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_subtitle_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_title_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_caption_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_subtitle_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_title_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_caption_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_subtitle_.yml create mode 100644 tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_title_.yml create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_title_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_title_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_title_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_0_title_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_title_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_title_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_title_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_1_title_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_caption_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_caption_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_caption_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_caption_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_subtitle_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_subtitle_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_subtitle_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_subtitle_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_title_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_title_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_title_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_title_ir_2_title_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_full.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_caption_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_subtitle_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_title_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_top_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_bottom_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_left_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_right_.png create mode 100644 tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_top_.png diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 4d5bc60..094fe42 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -341,6 +341,37 @@ def _update_all_attributes(self, title=None, subtitle=None, caption=None, # self.tags_depth = len(self.tags_format) + def _get_title(self, _type=["title", "subtitle", "caption"][0], + location="top"): + """ + obtain title/subtitle/caption with proper rotation + + Arguments + --------- + _type : str + type of title/subtitle/caption we're trying to return + location : str + if _type = "caption", then this isn't looked at, else + selected from ["top", "bottom", "left", "right"] + + Returns + ------- + cow.text object + cow.text object that has the desired rotation for the location + """ + if _type == "title": + et = self.title.get(location) + elif _type == "subtitle": + et = self.subtitle.get(location) + else: + return self.caption + + if location in ["left","right"] and et is not None: + et = et._additional_rotation(angle=90) + + return et + + def _get_tag_full(self, index=(0,)): """ Create text of tag for given level and index (fully goes down) @@ -725,44 +756,58 @@ def _calculate_margin_sizes(self, to_inches=False): tuple of top left corner of inner image relative to title text """ min_inner_width = \ - np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.title.get("top"), - self.title.get("bottom"), - self.subtitle.get("top"), - self.subtitle.get("bottom")] + np.sum([t._min_size(to_inches=to_inches)[0] + for t in + [self._get_title(_type="title", location="top"), + self._get_title(_type="title", location="bottom"), + self._get_title(_type="subtitle", location="top"), + self._get_title(_type="subtitle", location="bottom")] if t is not None] + [0]) min_full_width = \ - np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.caption] + np.sum([t._min_size(to_inches=to_inches)[0] + for t in + [self._get_title(_type="caption")] if t is not None] + [0]) extra_used_width = \ - np.sum([t._min_size(to_inches=to_inches)[0] for t in [self.title.get("left"), - self.title.get("right"), - self.subtitle.get("left"), - self.subtitle.get("right")] + np.sum([t._min_size(to_inches=to_inches)[0] + for t in + [self._get_title(_type="title",location="left"), + self._get_title(_type="title",location="right"), + self._get_title(_type="subtitle", location="left"), + self._get_title(_type="subtitle", location="right")] if t is not None]+ [0]) min_inner_height = \ - np.sum([t._min_size(to_inches=to_inches)[1] for t in [self.title.get("left"), - self.title.get("right"), - self.subtitle.get("left"), - self.subtitle.get("right")] + np.sum([t._min_size(to_inches=to_inches)[1] + for t in + [self._get_title(_type="title",location="left"), + self._get_title(_type="title",location="right"), + self._get_title(_type="subtitle",location="left"), + self._get_title(_type="subtitle",location="right")] if t is not None] +[0]) extra_used_height = \ - np.sum([t._min_size(to_inches=to_inches)[1] for t in [self.title.get("top"), - self.title.get("bottom"), - self.subtitle.get("top"), - self.subtitle.get("bottom"), - self.caption] + np.sum([t._min_size(to_inches=to_inches)[1] + for t in + [self._get_title(_type="title",location="top"), + self._get_title(_type="title",location="bottom"), + self._get_title(_type="subtitle",location="top"), + self._get_title(_type="subtitle",location="bottom"), + self._get_title(_type="caption")] if t is not None] + [0]) top_left_loc = ( np.sum([t._min_size(to_inches=to_inches)[0] - for t in [self.title.get("left"), self.subtitle.get("left")] + for t in + [self._get_title(_type="title",location="left"), + self._get_title(_type="subtitle",location="left")] if t is not None] + [0]), np.sum([t._min_size(to_inches=to_inches)[1] - for t in [self.title.get("top"), self.subtitle.get("top")] + for t in + [self._get_title(_type="title",location="top"), + self._get_title(_type="subtitle",location="top")] if t is not None] + [0]) ) @@ -783,9 +828,9 @@ def _get_titles_and_locations(self, width, height): Arguments --------- width : float - width of overall image (in inches?) + width of overall image (in pt) height : float - height of overall image (in inches?) + height of overall image (in pt) Returns ------- @@ -794,7 +839,8 @@ def _get_titles_and_locations(self, width, height): and the image of the title itself. The list has entries for any titles, then any subtitles and then the caption (if any). Each entry is a tuple with (1) a tuple of the top left corner location - for the title and (2) the svg object of the title itself + for the title and (2) a tuple of (a) the svg object of the title + itself and (b) a tuple of the size of the svg object in pt Notes ----- @@ -844,19 +890,19 @@ def _get_titles_and_locations(self, width, height): # TODO: make sure the pt vs inch question is settled # minimum size of each object - title_min_size_dict = {key : [t.min_size() if t is not None else (0,0) - for t in [self.title.get(key), - self.subtitle.get(key)]] + title_min_size_dict = {key : [t._min_size() if t is not None else (0,0) + for t in [self._get_title(_type="title",location=key), + self._get_title(_type="subtitle",location=key)]] for key in ["top", "bottom", "left", "right"]} if self.caption is not None: - title_min_size_dict["caption"] = self.caption.min_size() + title_min_size_dict["caption"] = self._get_title(_type="caption")._min_size() else: title_min_size_dict["caption"] = (0,0) # shifts for top left positioning - shift_horizonal = { + shift_horizontal = { # same value ("title", "top") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), ("subtitle", "top") : np.sum([tu[0] for tu in title_min_size_dict["left"]]), @@ -871,7 +917,7 @@ def _get_titles_and_locations(self, width, height): ("subtitle", "right") : width - title_min_size_dict["right"][1][0] } - shift_horizonal = { + shift_vertical = { # same value ("title", "left") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), ("subtitle", "left") : np.sum([tu[1] for tu in title_min_size_dict["top"]]), @@ -882,16 +928,16 @@ def _get_titles_and_locations(self, width, height): ("title", "top") : 0, ("subtitle", "top") : title_min_size_dict["top"][0][1], - ("title", "bottom") : width - np.sum([tu[1] for tu in title_min_size_dict["right"]]) -\ + ("title", "bottom") : width - np.sum([tu[1] for tu in title_min_size_dict["bottom"]]) -\ title_min_size_dict["caption"][1], ("subtitle", "bottom") : width - title_min_size_dict["bottom"][1][1] -\ title_min_size_dict["caption"][1] } # sizes to create each title element with - inner_width = np.sum([tu[0] for tu in title_min_size_dict["left"] +\ + inner_width = width - np.sum([tu[0] for tu in title_min_size_dict["left"] +\ title_min_size_dict["right"]]) - inner_height = np.sum([tu[1] for tu in title_min_size_dict["top"] +\ + inner_height = height - np.sum([tu[1] for tu in title_min_size_dict["top"] +\ title_min_size_dict["bottom"]]) size_request = { @@ -910,23 +956,26 @@ def _get_titles_and_locations(self, width, height): out_list = [] out_list += [ ( (shift_horizontal[("title",key)], \ shift_vertical[("title",key)]), \ - self.title.get(key)._svg(width_pt = size_request[("title", key)][0], - height_pt = size_request[("title", key)][1]) + self._get_title(_type="title", location=key).\ + _svg(width_pt = size_request[("title", key)][0], + height_pt = size_request[("title", key)][1]) ) for key in ["top", "bottom", "left", "right"] - if self.title.get(key) is not None] + if self._get_title(_type="title",location=key) is not None] out_list += [ ( (shift_horizontal[("subtitle",key)], \ shift_vertical[("subtitle",key)]), \ - self.subtitle.get(key)._svg(width_pt = size_request[("subtitle", key)][0], - height_pt = size_request[("subtitle", key)][1]) + self._get_title(_type="subtitle",location=key).\ + _svg(width_pt = size_request[("subtitle", key)][0], + height_pt = size_request[("subtitle", key)][1]) ) for key in ["top", "bottom", "left", "right"] - if self.subtitle.get(key) is not None] + if self._get_title(_type="subtitle",location=key) is not None] out_list += [((shift_horizontal[key], shift_vertical[key]), \ - self.caption._svg(width_pt = size_request[key][0], \ - height_pt = size_request[key][1]) + self._get_title(_type="caption").\ + _svg(width_pt = size_request[key][0], \ + height_pt = size_request[key][1]) ) for key in ["caption"] if - self.caption is not None] + self._get_title(_type="caption") is not None] return out_list diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index c7cce7f..196a894 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -847,7 +847,7 @@ def _hierarchical_general_process(self, tl_loc = title_margin_sizes_dict["top_left_loc"] - if approach == "default-size": + if approach == "default-size": # then we want to track relative sizing width_inner, height_inner = \ to_inches(1, units="pt"), to_inches(1, units="pt") else: @@ -879,13 +879,13 @@ def _hierarchical_general_process(self, #### process titles/subtitles/captions titles_and_locs = \ - cur_annotation._get_titles_and_locations(width = width, - height = height) + cur_annotation._get_titles_and_locations( + width = from_inches(width, "pt"), + height = from_inches(height, "pt")) - for loc_tuple, title in titles_and_locs: + for loc_tuple, (title, _) in titles_and_locs: _add_to_base_image(base_image, title, loc_tuple) - #### sizes of inner grobs: if data_dict is None or data_dict.get("sizes") is None: sizes = self._hierarchical_general_process(width = 1, @@ -939,11 +939,6 @@ def _hierarchical_general_process(self, grob_tag_index = None current_index = () - # TODO: should it be index=grob_tag_index or index=current_index? - # due to the function _step_down_tags_info, I think we need to - # update the _calculate_tag_margin_sizes to actual depth - # (say length of current_index), but should only use - # the _step_down_tags_info tag_margin_dict = cur_annotation._calculate_tag_margin_sizes( fundamental=fundamental_tag, index=grob_tag_index, @@ -964,6 +959,7 @@ def _hierarchical_general_process(self, width=inner_area.width, height=inner_area.height, index=grob_tag_index, + full_index=current_index, fundamental=fundamental_tag) _add_to_base_image(base_image, tag_image, tag_loc) @@ -974,7 +970,7 @@ def _hierarchical_general_process(self, data_dict_pass_through = data_dict.copy() data_dict_pass_through["parent-index"] = current_index data_dict_pass_through["parent-guided-annotation-update"] = \ - cur_annotation._step_down_tags_info(parent_index = current_index) + cur_annotation._step_down_tags_info(parent_index=grob_tag_index) if approach == "default-size": default_size_prop = (inner_area.width, inner_area.height) diff --git a/src/cowpatch/text_elements.py b/src/cowpatch/text_elements.py index 651b0de..f166279 100644 --- a/src/cowpatch/text_elements.py +++ b/src/cowpatch/text_elements.py @@ -181,14 +181,21 @@ def _additional_rotation(self, angle=0): return new_text_object if new_text_object.element_text is None: - new_text_object += p9.element_text(angle = angle) + rotation = (((angle) / 360.) - + np.floor(((angle) / 360.))) * 360 + new_text_object += p9.element_text(angle=rotation) else: # grab the elment_text from text object et = new_text_object.element_text.theme_element - current_angle = et.properties["rotation"] - et.properties["rotation"] = \ - (((current_angle + angle) / 360.) - - np.floor(((current_angle + angle) / 360.))) * 360 + current_angle = et.properties.get("rotation") + if current_angle is not None: + et.properties["rotation"] = \ + (((current_angle + angle) / 360.) - + np.floor(((current_angle + angle) / 360.))) * 360 + else: # if no rotation has currently been set + et.properties["rotation"] = \ + (((angle) / 360.) - + np.floor(((angle) / 360.))) * 360 new_text_object += et @@ -433,6 +440,9 @@ def _svg(self, width_pt=None, height_pt=None, sizes=None, num_attempts=None): ------- svg_obj : svgutils.transform.SVGFigure svg representation of text with correct format and image size + (width_pt, height_pt) : tuple + tuple of width and height (in pt) of image out (given inputs + can be None) See also -------- diff --git a/tests/test_annotation_elements.py b/tests/test_annotation_elements.py index ea5025f..2b146de 100644 --- a/tests/test_annotation_elements.py +++ b/tests/test_annotation_elements.py @@ -1,6 +1,6 @@ import cowpatch as cow -from cowpatch.svg_utils import _save_svg_wrapper -from cowpatch.utils import to_inches +from cowpatch.svg_utils import _save_svg_wrapper, _add_to_base_image +from cowpatch.utils import to_inches, inherits import numpy as np import pandas as pd import copy @@ -12,6 +12,7 @@ from pytest_regressions import image_regression, data_regression import itertools import io +import svgutils.transform as sg def test_annotation__update_tdict_info(): """ @@ -570,10 +571,175 @@ def test_annotation__clean_up_attributes(): "_clean_up_attributes function " +\ "(key = %s)" % key +@pytest.mark.parametrize("location", ["top", "left", "right", "bottom"]) +@pytest.mark.parametrize("_type", ["title", "subtitle", "caption"]) +def test_annotation__get_title(location, _type): + """ + test annotation's _get_title function + """ + full_ann = cow.annotation(title = {"top": cow.text(label="top title", + element_text=p9.element_text(angle=0)), + "bottom": cow.text(label="bottom title", + element_text=p9.element_text(angle=0)), + "left": cow.text(label="left title", + element_text=p9.element_text(angle=0)), + "right": cow.text(label="right title", + element_text=p9.element_text(angle=0))}, + subtitle = {"top": cow.text(label="top subtitle", + element_text=p9.element_text(angle=0)), + "bottom": cow.text(label="bottom subtitle", + element_text=p9.element_text(angle=0)), + "left": cow.text(label="left subtitle", + element_text=p9.element_text(angle=0)), + "right": cow.text(label="right subtitle", + element_text=p9.element_text(angle=0))}, + caption = "caption", + tags = ("0",)) + if _type in ["title", "subtitle"]: + attributes_dict = dict(tags = ("0",)) + attributes_dict[_type] = {"top": "top level", + "bottom": "bottom level", + "left": "left level", + "right": "right level"} + all_single_level_ann = cow.annotation(**attributes_dict) + + attributes_dict2 = {_type: {location: "current (sub)title"} } + single_ann = cow.annotation(**attributes_dict2) + else: + all_single_level_ann = cow.annotation(caption = "caption", + tags = ("0",)) + single_ann = cow.annotation(caption = "caption") + + + # without value ---------- + no_ann = cow.annotation(tags = ("0",)) + + if _type in ["title", "subtitle"]: + n_element = no_ann._get_title(_type=_type, location=location) + + assert n_element is None, \ + "no element returned if none defined, type %s, loc %s" % (_type, location) + else: + n_element1 = no_ann._get_title(_type=_type, location=location) + + assert n_element1 is None, \ + "no element returned if none defined, type %s, loc %s" % (_type, location) + + n_element2 = no_ann._get_title(_type=_type) + + assert n_element2 is None, \ + "no element returned if none defined, type %s, loc: default" % (_type) + + # with values ---------- + et_full = full_ann._get_title(_type=_type, location=location) + et_level_full = all_single_level_ann._get_title(_type=_type, location=location) + et_single = single_ann._get_title(_type=_type, location=location) + + # label check + if _type in ["title", "subtitle"]: + assert et_full.label == ("%s %s" % (location, _type)), \ + "expected %s element's label grabbed doesn't match static type (full)" % _type + assert et_level_full.label == ("%s level" % (location)), \ + "expected %s element's label grabbed doesn't match static type (level full)" % _type + assert et_single.label == "current (sub)title", \ + "expected %s element's label grabbed doesn't match static type (single)" % _type + else: + assert et_full.label == ("%s" % (_type)), \ + "expected %s element's label grabbed doesn't match static type (full)" % _type + assert et_level_full.label == ("%s" % (_type)), \ + "expected %s element's label grabbed doesn't match static type (level full)" % _type + assert et_single.label == ("%s" % (_type)), \ + "expected %s element's label grabbed doesn't match static type (single)" % _type + # text._type check + for et_i, et in enumerate([et_full, et_level_full, et_single]): + assert et._type == "cow_" + _type, \ + ("expected %s element grabbed to inherit "+ + "text _type cow_%s, et_i: %i" % (_type, _type, et_i)) + + # rotation angle check + for et_i, et in enumerate([et_full, et_level_full, et_single]): + if _type in ["title", "subtitle"]: + if location in ["left", "right"]: + assert np.allclose(et.element_text.theme_element.properties["rotation"], 90), \ + ("if type: %s, expect location %s to "+ + "have 90 degree rotation (et_i %i)" % (_type, location, et_i)) + else: + # None if no element_text has been defined + assert (et.element_text is None) or \ + np.allclose(et.element_text.theme_element.properties["rotation"], 0), \ + ("if type: %s, expect location %s to "+ + "have 90 degree rotation (et_i %i)" % (_type, location, et_i)) + else: + # None if no element_text has been defined + assert (et.element_text is None) or \ + np.allclose(et.element_text.theme_element.properties["rotation"], 0), \ + ("if type: caption, expect location %s show not alter "+ + "rotation as caption is always on bottom (et_i %i)" % (location, et_i)) + + + # save image + +@pytest.mark.parametrize("location", ["top", "left", "right", "bottom"]) +@pytest.mark.parametrize("_type", ["title", "subtitle", "caption"]) +@pytest.mark.parametrize("et_i", [0,1,2]) +def test_annotation__get_title_ir(image_regression, location, _type, et_i): + """ + test annotation _get_title -- image regression tests + """ + + # data set-up --- + + full_ann = cow.annotation(title = {"top": "top title", + "bottom": "bottom title", + "left": "left title", + "right": "right title"}, + subtitle = {"top": "top subtitle", + "bottom": "bottom subtitle", + "left": "left subtitle", + "right": "right subtitle"}, + caption = "caption", + tags = ("0",)) + if _type in ["title", "subtitle"]: + attributes_dict = dict(tags = ("0",)) + attributes_dict[_type] = {"top": "top level", + "bottom": "bottom level", + "left": "left level", + "right": "right level"} + all_single_level_ann = cow.annotation(**attributes_dict) + + attributes_dict2 = {_type: {location: "current (sub)title"} } + single_ann = cow.annotation(**attributes_dict2) + else: + all_single_level_ann = cow.annotation(caption = "caption", + tags = ("0",)) + single_ann = cow.annotation(caption = "caption") + + + ann_options = [full_ann, all_single_level_ann, single_ann] + + + # regression test ---------- + ann = ann_options[et_i] + et = ann._get_title(_type=_type, location=location) + + with io.BytesIO() as fid2: + et.save(filename=fid2, _format = "png", verbose=False) + image_regression.check(fid2.getvalue(), diff_threshold=.1) + + + + @pytest.mark.parametrize("location", ["title", "subtitle", "caption"]) def test_annontation__calculate_margin_sizes_basic(location): """ test annotation's _calculate_margin_sizes, static / basic + + Details + ------- + loops over a single type (title, subtitle, caption) - which means + everything focuses on *top* functionality. Additionally, not a one + after actual numberical assessments, more comparisons with different + sizes for default cow.text types. """ a0 = cow.annotation(**{location:"example title"}) a0_size_dict = a0._calculate_margin_sizes(to_inches=False) @@ -639,6 +805,16 @@ def test_annontation__calculate_margin_sizes_basic(location): def test_annotation__calculate_margin_sizes_non_basic(types, location1, location2): """ test annnotation's _calculate_margin_sizes (non-basic) + + Details + ------- + types : tuple + of title, subtitle, caption (no dupplicates) + location1, location2 : str + different location decisions, can duplicate. + + The checks across these options don't examine static values but more + how alterations are reflected into the code """ # lets of left, right, top, bottom + some other option. # allow for overrides and combinations @@ -694,13 +870,69 @@ def test_annotation__calculate_margin_sizes_non_basic(types, location1, location else: top_side = 0 - # TODO this failed... assert a0_size_dict["top_left_loc"] == (left_side, top_side), \ ("expected image starting location doesn't match expectation "+\ "relative to text on top and left ({t1}:{l1}, {t2}:{l2}").\ format(t1=types[0], t2=types[1], l1=locations[0], l2=locations[1]) +@pytest.mark.parametrize("_type", ["title", "subtitle", "caption"]) +@pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) +def test_annotation__calculate_margin_sizes_static(data_regression, _type, location): + """ + test annotation's _calculate_margin_sizes - focuses on locational rotation + differences by using data_regression + + Details: + -------- + we also check the relative scaling of all attributes to ensure to_inches + parameter is working correctly + """ + if _type in ["title", "subtitle"]: + parameter_dict = {_type: + {location: "type: %s, location: %s" % (_type, location)}} + else: + parameter_dict = {_type: "type: %s, location: %s" % (_type, location)} + + ann = cow.annotation(**parameter_dict) + + + out_info = ann._calculate_margin_sizes(to_inches=False) + out_info_in = ann._calculate_margin_sizes(to_inches=True) + + # to_inches comparison ---------- + for key in out_info.keys(): + if key != "top_left_loc": + if out_info_in[key] == 0: + assert out_info_in[key] == out_info[key], \ + ("to_inches =T/F should be a expicit scaling of 72 between "+ + "inches and pt, type: %s, location %s, key: %s" % (_type, location, key)) + else: + assert np.allclose(out_info_in[key]*72, out_info[key]), \ + ("to_inches =T/F should be a expicit scaling of 72 between "+ + "inches and pt, type: %s, location %s, key: %s" % (_type, location, key)) + else: + for idx in [0,1]: + if out_info_in[key] == 0: + assert out_info_in[key][idx] == out_info[key][idx], \ + ("to_inches =T/F should be a expicit scaling of 72 between "+ + "inches and pt, type: %s, location %s, key: %s, idx: %i" % (_type, location, key, idx)) + else: + assert np.allclose(out_info_in[key][idx]*72, out_info[key][idx]), \ + ("to_inches =T/F should be a expicit scaling of 72 between "+ + "inches and pt, type: %s, location %s, key: %s, idx: %i" % (_type, location, key, idx)) + + data_dict_out = {} + for key in out_info.keys(): + if key != "top_left_loc": + data_dict_out[key] = str(out_info[key]) + else: + data_dict_out[key] = \ + {str(i): str(value) for i, value in enumerate(out_info[key])} + + data_regression.check(data_dict_out) + + @pytest.mark.parametrize("location", ["top", "bottom", "left", "right"]) def test_annotation__calculate_tag_margin_sizes(data_regression, location): """ @@ -1210,11 +1442,197 @@ def test_annotation__get_tag_and_location2(image_regression, location, ann_index -def test_annotation__get_titles_and_locations(): + +def _create_blank_image_with_titles_helper(out, width, height): + """ + takes in list from annotation's _get_titles_and_locations and + creates an image + + Arguments + --------- + out : list + list of output from annotation's _get_titles_and_locations + width : float + width (in pt) used to generate `out` + height : float + height (in pt) used to generate `out` + + Returns + ------- + svg object with titles/subtitles/captions inserted + """ + base_image = sg.SVGFigure() + base_image.set_size((str(width)+"pt", str(height)+"pt")) + # add a view box... (set_size doesn't correctly update this...) + base_image.root.set("viewBox", "0 0 %s %s" % (str(width), str(height))) + + for loc_tuple, (svg_obj, _) in out: + _add_to_base_image(base_image, svg_obj, loc_tuple) + + return base_image + + +def test_annotation__get_titles_and_locations_basic_full(image_regression): + + + full_ann = cow.annotation(title = {"top": "top title", + "bottom": "bottom title", + "left": "left title", + "right": "right title"}, + subtitle = {"top": "top subtitle", + "bottom": "bottom subtitle", + "left": "left subtitle", + "right": "right subtitle"}, + caption = "caption", + tags = ("0",)) + + width_pt = 400 + height_pt = 400 + + out_full = full_ann._get_titles_and_locations(width=width_pt, + height=height_pt) + + # basic correct outcome sizes ---------- + assert (len(out_full) == 9) and \ + np.all([len(x) == 2 for x in out_full]) and \ + np.all([len(x[1]) == 2 for x in out_full]) and \ + np.all([inherits(x[1][0], sg.SVGFigure) for x in out_full]) and \ + np.all([inherits(x[1][1], tuple) for x in out_full]) and \ + np.all([inherits(x[0], tuple) for x in out_full]), \ + ("if all title/subtitle/caption element is used " + + "outcome of _get_titles_and_locations structure " + + "should be length 9, tuples of correct structure") + + full_svg = _create_blank_image_with_titles_helper(out_full, + width=width_pt, + height=height_pt) + + with io.BytesIO() as fid2: + _save_svg_wrapper(full_svg, fid2, + width=to_inches(width_pt, "pt"), + height=to_inches(height_pt, "pt"), + _format="png", verbose=False) + image_regression.check(fid2.getvalue(), diff_threshold=.1) + + + +@pytest.mark.parametrize("_type", ["title", "subtitle", "caption"]) +def test_annotation__get_titles_and_locations_basic_level(image_regression,_type): # create a set of static combinations of # titles, subtitles, captions (in different locations) # identify expected locations and potentially try to create # svg objects of the image themselves to compare the output too - raise ValueError("Not Tested") + + if _type in ["title", "subtitle"]: + attributes_dict = dict(tags = ("0",)) + attributes_dict[_type] = {"top": "top level", + "bottom": "bottom level", + "left": "left level", + "right": "right level"} + all_single_level_ann = cow.annotation(**attributes_dict) + else: + all_single_level_ann = cow.annotation(caption = "caption", + tags = ("0",)) + + + width_pt = 400 + height_pt = 400 + + out_level = all_single_level_ann._get_titles_and_locations(width=width_pt, + height=height_pt) + + + # basic correct outcome sizes ---------- + if _type in ["title", "subtitle"]: + assert (len(out_level) == 4) and \ + np.all([len(x) == 2 for x in out_level]) and \ + np.all([len(x[1]) == 2 for x in out_level]) and \ + np.all([inherits(x[1][0], sg.SVGFigure) for x in out_level]) and \ + np.all([inherits(x[1][1], tuple) for x in out_level]) and \ + np.all([inherits(x[0], tuple) for x in out_level]), \ + ("if all %s element is used " + + "outcome of _get_titles_and_locations structure " + + "should be length 9, tuples of correct structure") % (_type) + else: + assert (len(out_level) == 1) and \ + np.all([len(x) == 2 for x in out_level]) and \ + np.all([len(x[1]) == 2 for x in out_level]) and \ + np.all([inherits(x[1][0], sg.SVGFigure) for x in out_level]) and \ + np.all([inherits(x[1][1], tuple) for x in out_level]) and \ + np.all([inherits(x[0], tuple) for x in out_level]), \ + ("if only %s element is used " + + "outcome of _get_titles_and_locations structure " + + "should be length 9, tuples of correct structure") % (_type) + + + level_svg = _create_blank_image_with_titles_helper(out_level, + width=width_pt, + height=height_pt) + + with io.BytesIO() as fid2: + _save_svg_wrapper(level_svg, fid2, + width=to_inches(width_pt, "pt"), + height=to_inches(height_pt, "pt"), + _format="png", verbose=False) + image_regression.check(fid2.getvalue(), diff_threshold=.1) + + +@pytest.mark.parametrize("location", ["top", "left", "right", "bottom"]) +@pytest.mark.parametrize("_type", ["title", "subtitle", "caption"]) +def test_annotation__get_titles_and_locations_basic_single(image_regression, + location, _type): + # create a set of static combinations of + # titles, subtitles, captions (in different locations) + # identify expected locations and potentially try to create + # svg objects of the image themselves to compare the output too + + if _type in ["title", "subtitle"]: + attributes_dict2 = {_type: {location: "current (sub)title"} } + single_ann = cow.annotation(**attributes_dict2) + else: + single_ann = cow.annotation(caption = "caption") + + + width_pt = 400 + height_pt = 400 + + out_single = single_ann._get_titles_and_locations(width = width_pt, + height = height_pt) + + + # basic correct outcome sizes ---------- + if _type in ["title", "subtitle"]: + assert (len(out_single) == 1) and \ + np.all([len(x) == 2 for x in out_single]) and \ + np.all([len(x[1]) == 2 for x in out_single]) and \ + np.all([inherits(x[1][0], sg.SVGFigure) for x in out_single]) and \ + np.all([inherits(x[1][1], tuple) for x in out_single]) and \ + np.all([inherits(x[0], tuple) for x in out_single]), \ + ("if single %s, %s element is used " + + "outcome of _get_titles_and_locations structure " + + "should be length 9, tuples of correct structure") % (_type, location) + else: + assert (len(out_single) == 1) and \ + np.all([len(x) == 2 for x in out_single]) and \ + np.all([len(x[1]) == 2 for x in out_single]) and \ + np.all([inherits(x[1][0], sg.SVGFigure) for x in out_single]) and \ + np.all([inherits(x[1][1], tuple) for x in out_single]) and \ + np.all([inherits(x[0], tuple) for x in out_single]), \ + ("if only %s, (pointless location: %s) element is used " + + "outcome of _get_titles_and_locations structure " + + "should be length 9, tuples of correct structure") % (_type, location) + + + single_svg = _create_blank_image_with_titles_helper(out_single, + width=width_pt, + height=height_pt) + + with io.BytesIO() as fid2: + _save_svg_wrapper(single_svg, fid2, + width=to_inches(width_pt, "pt"), + height=to_inches(height_pt, "pt"), + _format="png", verbose=False) + image_regression.check(fid2.getvalue(), diff_threshold=.1) + diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_caption_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_caption_.yml new file mode 100644 index 0000000..1c94501 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_caption_.yml @@ -0,0 +1,8 @@ +extra_used_height: '9.75' +extra_used_width: '0' +min_full_width: '137.25' +min_inner_height: '0' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_subtitle_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_subtitle_.yml new file mode 100644 index 0000000..ee910a2 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_subtitle_.yml @@ -0,0 +1,8 @@ +extra_used_height: '11.25' +extra_used_width: '0' +min_full_width: '0' +min_inner_height: '0' +min_inner_width: '170.34375' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_title_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_title_.yml new file mode 100644 index 0000000..daa7564 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_bottom_title_.yml @@ -0,0 +1,8 @@ +extra_used_height: '13.5' +extra_used_width: '0' +min_full_width: '0' +min_inner_height: '0' +min_inner_width: '182.0625' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_caption_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_caption_.yml new file mode 100644 index 0000000..9b79e2a --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_caption_.yml @@ -0,0 +1,8 @@ +extra_used_height: '9.75' +extra_used_width: '0' +min_full_width: '119.625' +min_inner_height: '0' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_subtitle_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_subtitle_.yml new file mode 100644 index 0000000..9da60b3 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_subtitle_.yml @@ -0,0 +1,8 @@ +extra_used_height: '0' +extra_used_width: '11.250000000000009' +min_full_width: '0' +min_inner_height: '148.59375' +min_inner_width: '0' +top_left_loc: + '0': '11.250000000000009' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_title_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_title_.yml new file mode 100644 index 0000000..a2c070d --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_left_title_.yml @@ -0,0 +1,8 @@ +extra_used_height: '0' +extra_used_width: '13.50000000000001' +min_full_width: '0' +min_inner_height: '155.53125' +min_inner_width: '0' +top_left_loc: + '0': '13.50000000000001' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_caption_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_caption_.yml new file mode 100644 index 0000000..34070e7 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_caption_.yml @@ -0,0 +1,8 @@ +extra_used_height: '9.75' +extra_used_width: '0' +min_full_width: '126.28125' +min_inner_height: '0' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_subtitle_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_subtitle_.yml new file mode 100644 index 0000000..9865c9e --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_subtitle_.yml @@ -0,0 +1,8 @@ +extra_used_height: '0' +extra_used_width: '11.250000000000009' +min_full_width: '0' +min_inner_height: '156.75' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_title_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_title_.yml new file mode 100644 index 0000000..69f048f --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_right_title_.yml @@ -0,0 +1,8 @@ +extra_used_height: '0' +extra_used_width: '13.50000000000001' +min_full_width: '0' +min_inner_height: '165.28125' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_caption_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_caption_.yml new file mode 100644 index 0000000..d508a7a --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_caption_.yml @@ -0,0 +1,8 @@ +extra_used_height: '9.75' +extra_used_width: '0' +min_full_width: '119.71875' +min_inner_height: '0' +min_inner_width: '0' +top_left_loc: + '0': '0' + '1': '0' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_subtitle_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_subtitle_.yml new file mode 100644 index 0000000..67e41df --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_subtitle_.yml @@ -0,0 +1,8 @@ +extra_used_height: '11.25' +extra_used_width: '0' +min_full_width: '0' +min_inner_height: '0' +min_inner_width: '148.6875' +top_left_loc: + '0': '0' + '1': '11.25' diff --git a/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_title_.yml b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_title_.yml new file mode 100644 index 0000000..211e991 --- /dev/null +++ b/tests/test_annotation_elements/test_annotation__calculate_margin_sizes_static_top_title_.yml @@ -0,0 +1,8 @@ +extra_used_height: '13.5' +extra_used_width: '0' +min_full_width: '0' +min_inner_height: '0' +min_inner_width: '155.8125' +top_left_loc: + '0': '0' + '1': '13.5' diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_bottom_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_0_caption_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..c78790ae380594b7221a6b3a80c7963e77340f9f GIT binary patch literal 552 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 N002ovPDHLkV1j?HE@=P& literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_top_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_0_subtitle_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..4b56f97c102442964e9c907bc699b3ba8e5cd6a3 GIT binary patch literal 1021 zcmVp8N@292xMU1X6i7x!L{L<=SjN;JZAQ@sNtrre zQM5@|;YI~v2GUAIWhItYifPh@P5p81W3I2?@8^sT#6Vwoym!w%_uPAyd(L_EUG&G3 zIBp-;KZpr?;Te+l7~K=Wx1fC=5A|PR5dOxbz3|KT(6q3gU!;-HMLFlrg|uw2+}11=FZ8B#EG;N{YyCJ|+~XS5OG$~=(KcHj#P%xFu6kq)SG zpxM}1We%>v_a$1r$X`>^xX77nb)~C1tiZ}_-K!#U$|&c|4Y;wyQ_h*kqZ5-ep8oh0 z?R7O2IwdhK$Y?*|C6QlpEXD8Hc%i7Whg6aMHAdn-k<+Rfy*pTf-RZki815`x2JTHv z6v49vC#PquxMeN1j5$g~=8q-%rZkshb7@>e)yOKcSKwL^WgjG&cMIqB6qWX_SKX2O zgJy9fo-aLTptF|uHtWhYc{ObEZv{q_#)TqVYRG>gJ)rMdO#P?u%2!J>+-EAh74Ghw%hid)Wm1#%~D!Xu(nC<@R< z498&^-}EZ7+U%Jo+VGxm2%&sqm9;r4*`SQ&!dWva%+(i4t;W@&5Pc`|;yTP1e+q{S zrDqBQ-%}POs{RY2lFq`#B6(NB+{E{mh@f#e5jUjyHKH?ECfcQDT@|{FCQ&?J7ddUR zXy*#77Jo6%;0HXbazD8$*t3pM6MYq3^!i17(`T^DgMmPPiL4Udc~s0+${=NM>_jd z(W9OtoK#@3NH?d6cBa5%;x-hxP29#Yx?00sBA9Z0uNQ`I6?WN*cko;deY=evxV$$6 r^>x&$=KrII=nZ2o!soppr_bXb7VFQ@v}|{Z00000NkvXXu0mjf%unuu literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_0_title_bottom_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_0_title_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..7ebb9bdc1e7d4bf53f799f8aec865cb31aa0a074 GIT binary patch literal 1229 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 IM6N<$f&{a%a{vGU literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_bottom_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_1_caption_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..c78790ae380594b7221a6b3a80c7963e77340f9f GIT binary patch literal 552 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 YFS3=H_D>>LQ~&?~07*qoM6N<$g4?}>Q2+n{ literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_right_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..4953f5f1d550a590c3d8e46e481f5b80a57c96e1 GIT binary patch literal 847 zcmV-V1F-ywP)0009PNkl&JKy z=QX*cg`4mae#7s01&f+$vjlsSCbdi{ll&?s1~HY{ya|izNSv3q8@+Q`gtc&uZX$dp zD$0mR%}OlAeqry7ZZWtG-{)H8D(=>;7%vpg_hJ;Qi^T2hqMNZ!RE;A3N@thp7K|he_3-xD34c}@G zc|O{dp*C80uL1qtBK8sQJkWy~PBftXAH%RnOb2C{FeJ*RWDUH4rZ@x`-Z9aDXy$(%KT7Pw=cxCO2!Dhu3O{! zaHnW$r*WiYT`@?L!+2iIWR0*;cz5|Q!VdBw?tmEB=7byYA#PN2Tpko4`Ra-7w(xLW z^o8et15>K)Js}$5<5C>7GvV-F$p4!p3$AJPN)69 zi*e!E)x!uo$ji{E{;{z(cHu?UZMFw_l$^u^qK54kZMBGp@naS9GLeeJljYAgQb(g& ZI0Gysyv3;&YytoP002ovPDHLkV1ip9o|gas literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_top_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_1_subtitle_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..7eebc78d74916edd5ad353d8d0a133264a9bf015 GIT binary patch literal 804 zcmV+<1Ka$GP)GELM^G1SU>So`RnGhg3KArAJ3!#R8H+q(SMT8FN~I!x|@ zd;8%{3@HT5(0GfLID>_^_)d5be_?F5Bp8XFU7$#pq8p|NH7)HLT~KD zdYsSBcm-c!D4xPqY{bt0pwDPLi+UWydpMHjW+i>MmiCMj>C=?PcVGslruD68#-;LQ zb?AY+aTofgr+b=z9oqyej$#pZyL9-lgTRh9`|IjP1rA@@K4BdzPDi0 zveKFl@L~GC55Ea!6sX4^n3l#*CY{G*Yx>|6=BM%XoUf~)+#`Yw>oeJ}NtsEdwVQA` z8*jiU5v|VPVPRZr`YxV*ZbY?$k1;pf_ndIzwlsbmhXtdHGlq#kDFMqsrVR+=)N&4n7ra>Vz=91;1zK48fKv=NvB!Y8ii0 z)PlZ(>IDXBZ*2e4xQKXX42KIMMJ8(R>>OW+Jf0(p>u z1?X4d?VWgQtY91dw^-4^>%j^{8N9V{Grqwem?W;g66@f4e2IDJ!0lR3*o8PR{rxcB zPQPPt1BNHwOBrv!Z2k~DhhOkJ-oXhKTb{v9*d4DY*_k*u@i)gr%*NLkhqWtumK3Yv z9DF2v^fMmA;7sPQw4c2z^V`IpZ!PGD<5m2IxtJ_U%VN>gyeW7Crwhl=N{V(V7JoI# z--T0?A@ea+1Qrg&6G`qyaYbH`bRCs8(1|ND-gas3ULpt`xEN>Q%_QFhr9lU#;d_x3 zXJz({LOaGM`uIe@X$gE%t9iXJ0Sj>-M&NvrEI)~w1bu|x##i(nn)D9OXrq(P*DwZW zh&U_|zG~1?^BOe4`v7ZZ^sPm9y_nI4C)q0tqZ%FgW=AL54wX$@lX!hAy!S)~4#;S0 ziqrQ8HqPiBNl$)idy8N$#3hC9=kcou#G+6O-m!^xb|JfY(s^%2dq@PZPa!)&>}}oj zJpg~=(ZZ%SyoE1x-H?k6-pI-(9u%jlS?qEAh4l)#)$pBgaD!zD-j<1eXJyVwi8m~x zb>PE_>~r1dzbw%=sm#ey3*O_x@vBwjC*a%c`;avMl#JG#$h$Mz*d(`GMdzr*+dh4_ z1h1_S{#P{HAdS7U zaY02+T6}qRunQMqOSwjO_H;*IRD?KVV~{ACIkrg#%}n1ju)oNle6dxqt=3b}1fW)5I=kV`NaMDZIZZ=TJ8Az0J{Qf@ z-J*0X4&y{4b`l;C9rTr26Z4BSzAd`@B}SRSBMVx894)SipA+pF@vt6I%soo{d+q6r z_I;+O#CK^d|NGL8PsOufgTmHPx~`;^fr%n%%Z|4}SqrC&E_s-)?y~Ga9}&U4R6WUS z^|b{}!}2+v!Vx%8)Sda*CzI_hy3%>LTD)m45`}msHq2xPi0*I-F4y%`fSr@klSNaI zKNW_GD{orb_|eI(PFz>m-XWeOB_6_$75W&hO+a_3H7sp-A=y11$BD+|G!fX^)8O=^ zXD(juX00od%p}p^9DtK?o7UQ2?KY1W8NZi!P|w#D%p)p`^%w7)IZ5tM(TQzUkv$Mk zie{%>Bt?T`@T_P=+Hp|E8!D2dGyP_Xw`ZB1lhtd`Em}L@2E)=?gDSin{qPyiEa+

w)zst zE-Tg+o#B5G>#Dy~{|R~vpDykl+JheSpa(rzI{Xb?MeLC_*aA3MwD^ASx|0tFfGkbgY@0>0#~boO|xM_ndpUK~R6VT+Z5i{rB2?ueJ7C z^a%qvwT9j}e#;BzYW!}^lQgiUhD9IL(D|3(TD*>*us0*$fG03mh2&vzpkLx$Ok~E^ zrLhakNJjA&w&S>D!9;G(e%zl|%$s;Arg2{)EW$TfRYtT3lZlMjMjpotNA60r7b|r3 z_LFfsKJPHzvf8*fL_dcE*e1Nb6eo!^@l{g(9WUd03{|z_0>QgTMm{;M+e{Q zT+ zE>-a2_F9<@i!^&MKhQ}3&x@L8ILUStkSq`{@lkw~A8h8M@kG$TV(tA0OyO%hCW?wS zWcNIVKZ+)mVNlf38`AfoPODWRn$B3XOuVi=qaoUa>qL3CILXUg;E4GS>KJY}UtHDB zkj+~=50~H^+#qhI8(zeR-6o8wd24x836Vl{dLWmG3ph*kw)ZCas3+e-R|%)Q2Jea1Gm9A!Z>!6QR^bQT3#lmu zg;<_wLZp&~={sM2ED-Jb_hm$HB-sN6L<32_46k;v_!zb&f}MCq^!Z!yW|BW!g`~Oi z7IAyo_?p6_Vta*3ozM^td1A6^cOv^|F_2d56^9iBci$?uhiX`~yM|6R{0(PK^s;yN R^xpsg002ovPDHLkV1iW^!~*~T literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_1_title_right_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_1_title_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..8dfa44c2114acd6ffad0307339be2a2d2dcd95bb GIT binary patch literal 1049 zcmV+!1m^pRP)xH&WulB1W|6LW>GTv0%|n)ha$g(UmS-NkNNl zL~I2&f@lj`i!RiPEsFSzulnqRM%!r9#>KhQOm4o(_st|Ng8pHc`R;em|DHMboO92) z%n)kmZbNPl{>}%-+W0KXNjmXF8ybDwhRnYN19%Ca;-iM~#duU~QYlvA0Di?*>}+_q zP#F87GLn9=t zz9!n7g8BHo$@_!o7B+bUJ-E`ijb`I@j41ePMYv8Fd^fh>uSC3C?BHfPcpASa(j8ch zOWP&v!cyTwqlt8BKB=<;(O#?-e$<6UB1~Gsxp-1U{8%DegEOiK_h30TCE@+)_i`Lp zRfCH~NQ~i2JdC+jg&VO@sJ=*>9zAU|TlGZTfb+x--IB)VVGiD|LVlU?s2?>kXJ4Jh z`^!kWg$KWn3q@!&5_Mu1ZqEhtR6Cf+t`+Cm{_OoIzADlvC+ZW<_kDUUO8z6_keHHa z2TsPU0s_MD^YKF&(faiLnAp)qjcRGU2d_0*XaXKj1e@?8{=`FgFO9FMLQ-G(PW)_Q zaW$MYD$E#Dg+T|-La*5Jts)K&Eo#E$Tg5H~MaGO3qYt*B(dIT}{>Og@m*BIKIa@?| zI01P+>QbKA`xTtHIZ+w9ajYnHCZL{Mo=K#=X>4@?NvAl8_nD$!+owEG_lT14<|?WW zn4#oV#n^`yMLBym?h&<8pWN{ z%hg)$*+b%dKWe%f9b>!?X$dt^_BLV;?#G+hD9*4PZz*U;XY5=ny$mS8P#ZFb8M(jA z6TUPR#;^_V2%{I(nFm|Zs-F!aV-{kih_mZO028+pw}~S3Mw}+De-7ZOB9gO3ZGBFY zMXnHaQGJ0I#5Kqy+<_mPsVW-eQ4!p5Q_`v3Kxt`$?(h{4}^u z)YFZqcSR$@X7GrxXCdl!_HYthD{Af$lP_6b7A13242tUhm!$eOky_jC`42y8xHbKL zP8e~pg4{-Ykq_40y{3AIEXzqc#brvJ@PmfusW4PF;dpUf(em9#j#Xm2yIK{@Lu9*f zy3y%s)GIp1C9MkPXa7#o~ T!ZCp|00000NkvXXu0mjfs15!_ literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_1_title_top_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_1_title_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..035c3d2b2ff9f47c65a422aec139b24612fd5483 GIT binary patch literal 978 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 literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_2_title_right_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_2_title_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..c3fc7403881241d3e9e34f11d47217c6d9c14224 GIT binary patch literal 1734 zcmV;%208hOP)T$# zwfEV3J^pK*%N${-;{HOs9_L~P?@#xigVS+y8{rpXR}wITJ%zR_a1>`Y2{{G3aVIWF z1B-&5guQrIK5$-k{TeLB<+v5oS^Ht!i{(v%&cGz@uUYkf@YE(jdvF@Y()CQ%K7v*F zSCgPE>E6dG1btLI`sSJyKaS5Q0b8&M`>_#Q)BW$a5z<-Z)p*D;6_~-nG|5QWpeltV zj(W9tlqsPS2imS?U<_f=0OR)S)v>O9X#06c5%rna{BnAoEp zbZqU#>Gyf~s`AY|f(J#sYeX9ld3;e8X`g{hgib9H`?m{!6uxMxPT;6;>bI%i>}mWc zP1J++f`uK$Z}AbVEv#snP>U<^Md93La1+ic3fhM+;aPP{E?@{3;b*w9*s!dnfHKTx zY&q)q(R>jMo{pVjZ)#60#{%I>4(dL!!?;c`|Hc)~6XE9K^u0N)X0dQFm*E~^O8e_p zvrc#Jk>heiyI{1B^Mr$Tj;+ys^c&&4MRXV#0u%onu&V zr2O+t;Yt6752;AC`oXh>S9nR@&~NO&M}+I1klwGLdE!w=2Ng6bqRKbK$^M~fz=Y-# z`-PIt&h=;o-cd2h4&jTo>P_SSH<;f~=TYHCi?RK5K^|sAAXwi<&?RX=F???lBx{o( z8HWU8sl_|-aJIrJd@Eg#30M4qd|<9e3w0NsE)W6phIIWXwrf#8+KHut0u#=DTI^8= z3p7U>XQY8Y5RqgLep+ZB7LUHMNf5l-F}nN@-J$2Qbbn17K|~_FO0Oxb6ya;QXT)5h z=L7~FdjQ{-5z873izs}vj6+_EKZ{Mg6Q31@%on8@&la2eHldi4sqgivtqUg!_qj#r zNR=--fbS~t*<~DZFax8h^*<@Z+YXPnaX4 zY_r)X9^?2rekZ))!zG^{E}n;#lW_~q5~S)@K_GHWHm&9g;Zon+cKKEKpr}mLrSYZ6 zHTzz1ay|67UyO)i?q&V`Px-rcI&fF(i(NgJdPI# z8hFDX!k6GDj&XpZOW7cJ=E5c+^Ms8};Zx$hQc=(%;bt0B{IzKnYt!{>^*Xt`mFQWn z+2@E7>P_I%w@Zl^mvJ&fMuq!O@&XdQ^*TN+O<@i+->-|7VnMVO@b$$(x!6}`DQP|^Hfx< z`oZ~PFILS~5K$z!UC`s!$dB>7w5oIQ7xA*--zDQdp*xSO)Q1!ILbnMUL}_Q%$3G@K zFuAZ$R_>9QkS|`9|SR=yrv{1~cbe(^C=y87hkBZv>?fzggnQX$gE$6?`fbkZSXz=not=Y#C!9b{bol5iRkg5{R&zw zsLlg!CgQ@t-gL@OFEJph;2l@A)T3F6dO@`0EPadj<8W6*MVm%y@;65kb0k)=Wea za!@2Kd9`6gymyZCjbBd07*qoM6N<$f=e@F9RL6T literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_title_ir_2_title_top_.png b/tests/test_annotation_elements/test_annotation__get_title_ir_2_title_top_.png new file mode 100644 index 0000000000000000000000000000000000000000..7aef7f8ddc57529c408c88c9b0f9393996eaf090 GIT binary patch literal 1721 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 BqgVg{ literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_caption_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_caption_.png new file mode 100644 index 0000000000000000000000000000000000000000..693fd8e9bbb3ccda94d23499fa94f13dcd5298df GIT binary patch literal 12980 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 literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_title_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_level_title_.png new file mode 100644 index 0000000000000000000000000000000000000000..64f287af43db943aa3f673681a2cd4aceaf51e46 GIT binary patch literal 25902 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 literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_bottom_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_caption_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..693fd8e9bbb3ccda94d23499fa94f13dcd5298df GIT binary patch literal 12980 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 literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_right_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_subtitle_right_.png new file mode 100644 index 0000000000000000000000000000000000000000..737dc4968125446c293fcd92f89eb238031c28d0 GIT binary patch literal 16338 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 N0nebVN`Ljf`CqIAtrGwM literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_bottom_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_bottom_.png new file mode 100644 index 0000000000000000000000000000000000000000..6b11fa02d23629a4ee89dab99a660075f905eef7 GIT binary patch literal 16801 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 R=&!`pAhu5Ds%-pk{|`nS(UJfF literal 0 HcmV?d00001 diff --git a/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_left_.png b/tests/test_annotation_elements/test_annotation__get_titles_and_locations_basic_single_title_left_.png new file mode 100644 index 0000000000000000000000000000000000000000..430209846cb942aadc3d28cafb0ad884960f0fcb GIT binary patch literal 17289 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 Date: Sun, 12 Feb 2023 15:56:16 -0800 Subject: [PATCH 13/13] improvements and testing for bases' default-size and doable-size functionality - not complete for doable-size --- src/cowpatch/annotation_elements.py | 4 +- src/cowpatch/base_elements.py | 168 +++-- src/cowpatch/layout_elements.py | 12 +- src/cowpatch/utils.py | 12 +- tests/test_base_elements.py | 985 +++++++++++++++++++--------- tests/test_layout_elements.py | 20 +- tests/test_utils.py | 147 +++++ 7 files changed, 972 insertions(+), 376 deletions(-) diff --git a/src/cowpatch/annotation_elements.py b/src/cowpatch/annotation_elements.py index 094fe42..ee5b42e 100644 --- a/src/cowpatch/annotation_elements.py +++ b/src/cowpatch/annotation_elements.py @@ -590,7 +590,7 @@ def _calculate_tag_margin_sizes(self, index=0, full_index=None, if not inherits(full_index, tuple): full_index = (full_index, ) - if index != full_index[-1]: + if (index is not None) and (index != full_index[-1]): raise ValueError("structure between arguments `index` and "+ "`full_index` disagree.") @@ -1102,7 +1102,7 @@ def _step_down_tags_info(self, parent_index): def inheritance_type(self): - return self.tag_inherit + return self.tags_inherit def __add__(self, other): diff --git a/src/cowpatch/base_elements.py b/src/cowpatch/base_elements.py index 196a894..3cdaf22 100644 --- a/src/cowpatch/base_elements.py +++ b/src/cowpatch/base_elements.py @@ -8,13 +8,14 @@ _raw_gg_to_svg, _select_correcting_size_svg, \ _add_to_base_image, _uniquify_svg_safe from .utils import to_inches, from_inches, inherits_plotnine, inherits, \ - _flatten_nested_list + _flatten_nested_list, _overall_scale_recommendation_patch from .layout_elements import layout from .annotation_elements import annotation from .config import rcParams from .text_elements import text import copy +import pdb class patch: def __init__(self, *args, grobs=None): @@ -118,8 +119,8 @@ def __init__(self, *args, grobs=None): else: self.grobs = grobs - self.__layout = "patch" # this is different than None... - self.__annotation = None + self._layout = "patch" # this is different than None... + self._annotation = None @property def layout(self): @@ -128,17 +129,17 @@ def layout(self): object or the default ``layout`` if no layout has been explicitly defined """ - if self.__layout == "patch": + if self._layout == "patch": if len(self.grobs) < 4: return layout(nrow = len(self.grobs), ncol = 1) else: num_grobs = len(self.grobs) nrow = int(np.ceil(np.sqrt(num_grobs))) - ncol = int(np.ceil(len(self.grobs) / nrow)) + ncol = int(np.ceil(num_grobs / nrow)) return layout(nrow=nrow, ncol=ncol) else: - return self.__layout + return self._layout @property def annotation(self): @@ -152,18 +153,18 @@ def annotation(self): if no annotation is provide, we do make sure that "tags_inherit='override'" """ - if self.__annotation is None: + if self._annotation is None: return annotation(tags_inherit="override") else: - return self.__annotation + return self._annotation def _check_layout(self): """ checks layout if design matrix is fulled defined """ - if self.layout.num_grobs is not None: - if self.layout.num_grobs != len(self.grobs): + if (self.layout.num_grobs is not None) and \ + (self.layout.num_grobs != len(self.grobs)): raise AttributeError("layout's number of patches does not "+ "matches number of patches in arangement") @@ -222,7 +223,7 @@ def __add__(self, other): elif inherits(other, layout): # combine with layout ------------- object_copy = copy.deepcopy(self) - object_copy.__layout = other + object_copy._layout = other return object_copy elif inherits(other, annotation): @@ -231,20 +232,17 @@ def __add__(self, other): if self.annotation is None: other_copy = copy.deepcopy(other) other_copy._clean_up_attributes() - object_copy.__annotation = other_copy + object_copy._annotation = other_copy else: final_copy = copy.deepcopy(self.annotation + other) final_copy._clean_up_attributes() - object_copy.__annotation = final_copy + object_copy._annotation = final_copy return object_copy def __mul__(self, other): raise ValueError("currently not implimented *") - def __and__(self, other): - raise ValueError("currently not implimented &") - def _get_grob_tag_ordering(self, cur_annotation=None): """ @@ -299,6 +297,7 @@ def _get_grob_tag_ordering(self, cur_annotation=None): return out_array + # def _svg(self, width_pt, height_pt, sizes=None, num_attempts=None): # """ # Internal function to create an svg representation of the patch @@ -724,6 +723,95 @@ def _get_grob_tag_ordering(self, cur_annotation=None): # return max_scaling + def _svg(self, width=None, height=None, data_dict=None): + return self._hierarchical_general_process(width, height, data_dict, + approach="create") + + def _default_size(self, data_dict=None): + """ + calculate the default size for this patch arrangement based on + cow.rcParams that define minimum sizing of figures. This also takes + into account the true required sizes to present titles & tags + + Arguments + --------- + data_dict : dictionary + This dictionary is used to pass information from parents to + children to help with the overall calculation of the full default + size. + + Specifically, `data_dict` can have an attribute + `"default-size-proportion"`. If this attribute exists, it should + be a tuple with values between (0,1]. These values capture the + relative size of this child's shape to the overall parent we + are getting the default size for. + + Additionally, this dictionary can store things for the tag creation + and sizing, including the attribute + `"parent-guided-annotation-update"`. This is the parent's + annotation object. If the current level's `annotation` has + `tags_inherit="override"` the annotation tag structure will be + taken from the parent. + + Returns + ------- + default_size : tuple + default size of the image (in inches) to respect minimum sizing + desires as defined by cow.rcParams & title & tag sizes. + + Details + ------- + *For code explorers*: this function wraps around + `_hierarchical_general_process`. To understand the underlying code + see that function (and potentially read the docstring before you do + a deep dive). + + """ + return self._hierarchical_general_process(width=None, + height=None, + data_dict=data_dict, + approach="default-size") + + def _doable_size(self, width=None, height=None, data_dict=None): + """ + calculates a doable size for all grobs, including nested grobs + within inner patch objects & assesses "doablity" at the give size + + Arguments + --------- + width : float + inches (TODO - write description) + height : float + inches (TODO - write description) + data_dict : dict + dictionary of inner storage (TODO - write description) + + Additionally, this dictionary can store things for the tag creation + and sizing, including the attribute + `"parent-guided-annotation-update"`. This is the parent's + annotation object. If the current level's `annotation` has + `tags_inherit="override"` the annotation tag structure will be + taken from the parent. + + Returns + ------- + sizes_list : (potentially nested) list + if doablity == 1, then this provides tuples of sizes (in pt) for + each element base element, which nested structure for patch + wrapping. This doesn't contain sizing for titles & tags but does + take them into consideration. + + if doablity == 1, TODO - write description + doability: boolean + 0/1 boolean if requested width and height are doable for the + overarching image (TODO: make this a true boolean?) + + + """ + return self._hierarchical_general_process(width, height, data_dict, + approach="size") + + def _hierarchical_general_process(self, width=None, height=None, data_dict=None, approach=["size", "create", "default-size"][0]): @@ -734,9 +822,9 @@ def _hierarchical_general_process(self, Arguments --------- width: float - probably inches based - not 100% sure + outcome svg's width requested (in inches). Can be None. height: float - probably inches based - not 100% sure + outcome svg's height requested (in inches). Can be None. data_dict : dictionary dictionary of data to pass to children patches approach : str @@ -758,7 +846,7 @@ def _hierarchical_general_process(self, minimum width and height of a images (as stored in rcParams) and for all titles, subtitles, captions, and tags to have enough space to be correctly presented. For the - arguments, (width, height) are ___ and the data_dict may have the + arguments, (width, height) are not at all, and the data_dict may have the attribute "default-size-proportion" which should be a tuple of positive floats (less than or equal to 1) that captures the relative size of this patch to the overall. @@ -777,6 +865,8 @@ def _hierarchical_general_process(self, include an attribute "parent-guided-annotation-update" which contains an annotation object that the parent is providing. + - data_dict["parent-guided-annotation-update"] + """ # initialization section ---------------------------------------------- @@ -787,7 +877,7 @@ def _hierarchical_general_process(self, data_dict.get("parent-guided-annotation-update") is not None: # Note: this addition allows for the keeping of titles but the - # update of tags + # update of tags (as only the tag information is passed) if cur_annotation.inheritance_type() == "override": cur_annotation += data_dict["parent-guided-annotation-update"] @@ -799,12 +889,6 @@ def _hierarchical_general_process(self, ### default size estimation if approach == "default-size": - if (width is not None and width > 1) or \ - (height is not None and height > 1): - raise ValueError("if using approach \"default-size\", "+\ - "height and width should be greater or "+\ - "equal to one.") - # TODO: why do we care they are greater than 1? if data_dict is None or \ data_dict.get("default-size-proportion") is None: @@ -822,7 +906,7 @@ def _hierarchical_general_process(self, "be a 2 length tuple of non-negative floats (less " +\ "than or equal to 1") - overall_default_size = (0,0) + overall_default_size = (0,0) # starting storage for overall_default_size else: # using default sizings if width is None or height is None: default_width, default_height = \ @@ -920,8 +1004,6 @@ def _hierarchical_general_process(self, if True: # all approaches inner_area = areas[p_idx] - # TODO: start here - # (1/16) what if within this space we check more things? if tag_index_array[p_idx] is not None: grob_tag_index = tag_index_array[p_idx] @@ -947,6 +1029,7 @@ def _hierarchical_general_process(self, if approach in ["create", "size"]: + # in pt... grob_width, grob_height = \ inner_area.width - tag_margin_dict["extra_used_width"],\ inner_area.height - tag_margin_dict["extra_used_height"] @@ -966,21 +1049,26 @@ def _hierarchical_general_process(self, ## grob processing if inherits(image, patch): - ### default sizing + data_dict_pass_through = data_dict.copy() data_dict_pass_through["parent-index"] = current_index data_dict_pass_through["parent-guided-annotation-update"] = \ cur_annotation._step_down_tags_info(parent_index=grob_tag_index) + ### default sizing if approach == "default-size": + # (TODO: remove comment once done with pt vs in stuff) + # no need to look at width & height as (relative to PT & IN) + # is actually in the "pure-relative" format (relative to + # overall size of 1 x 1) default_size_prop = (inner_area.width, inner_area.height) data_dict_pass_through["default-size-proportion"] = \ default_size_prop - default_inner_size = self.grobs[p_idx]._hierarchical_general_process( data_dict=data_dict_pass_through, approach="default-size") + # ^same as calling ._default_size(data_dict=data_dict_pass_through) ### sizing estimation if approach == "size": data_dict_pass_through["size-node-level"] = \ @@ -988,8 +1076,8 @@ def _hierarchical_general_process(self, inner_sizes, inner_size_multiplier = \ image._hierarchical_general_process( - width=grob_width, - height=grob_height, + width=to_inches(grob_width, "pt"), + height=to_inches(grob_height, "pt"), data_dict=data_dict_pass_through, approach="size") sizes_list.append(inner_sizes) @@ -1050,7 +1138,7 @@ def _hierarchical_general_process(self, elif inherits(image, text): ### default sizing if approach == "default-size": - m_w,m_h = self.grobs[p_idx]._min_size() + m_w,m_h = self.grobs[p_idx]._min_size(to_inches=True) default_inner_size = ( np.max([rcParams["base_aspect_ratio"] *\ @@ -1059,7 +1147,8 @@ def _hierarchical_general_process(self, ) ### sizing estimation if approach == "size": - m_w, m_h = image._min_size() + m_w, m_h = image._min_size(to_inches=False) # PT vs IN + #pdb.set_trace() if m_w > grob_width or m_h > grob_height: # track needed size change inner_size_multiplier = np.max([m_w/grob_width, m_h/grob_height]) @@ -1093,6 +1182,7 @@ def _hierarchical_general_process(self, default_inner_size[0] + tag_margin_dict["extra_used_width"], default_inner_size[1] + tag_margin_dict["extra_used_height"] ) + overall_default_size = ( np.max([1/inner_area.width * grob_default_size[0], overall_default_size[0]]), @@ -1124,8 +1214,9 @@ def _hierarchical_general_process(self, ### sizing estimation if approach == "size": if not np.allclose(size_multiplier, np.ones(len(size_multiplier))): # track needed size change + pdb.set_trace() _, inner_size_multiplier = \ - _overall_scale_recommendation_patch(interior_image_scalings, + _overall_scale_recommendation_patch(size_multiplier, text_inner_size=( np.max([title_margin_sizes_dict["min_inner_width"], title_margin_sizes_dict["min_full_width"] - \ @@ -1138,14 +1229,15 @@ def _hierarchical_general_process(self, ### repeat # if sizing incorrect and num-attempts haven't run out, # repeat process with updated sizing - if data_dict["size-num-attempts"] > 0 and \ - data_dict["size-node-level"] == 0: + if data_dict["size-node-level"] == 0: return self._hierarchical_general_process(self, width=inner_size_multiplier*width, height=inner_size_multiplier*height, data_dict={"size-num-attempts": data_dict["size-num-attempts"]-1}, approach="size") + else: + return (-1,-1), inner_size_multiplier else: # collect sizes return sizes_list, 1 diff --git a/src/cowpatch/layout_elements.py b/src/cowpatch/layout_elements.py index f742313..6feef42 100644 --- a/src/cowpatch/layout_elements.py +++ b/src/cowpatch/layout_elements.py @@ -624,8 +624,16 @@ def __eq__(self, value): return design_logic and \ self.ncol == value.ncol and \ self.nrow == value.nrow and \ - np.unique(self.rel_heights/value.rel_heights).shape[0] == 1 and \ - np.unique(self.rel_widths/value.rel_widths).shape[0] == 1 + ( + ((self.rel_heights is None) and (value.rel_heights is None)) + or + (np.unique(self.rel_heights/value.rel_heights).shape[0] == 1) + ) and \ + ( + ((self.rel_widths is None) and (value.rel_widths is None)) + or + (np.unique(self.rel_widths/value.rel_widths).shape[0] == 1) + ) class area: diff --git a/src/cowpatch/utils.py b/src/cowpatch/utils.py index 8f74a3b..a423856 100644 --- a/src/cowpatch/utils.py +++ b/src/cowpatch/utils.py @@ -342,22 +342,22 @@ def _overall_scale_recommendation_single(image_new_size, Notes ----- - This function assumes ate least one of the new image_new_size values are + This function assumes at least one of the new image_new_size values are greater than the original requested size (original_overall_size - text_extra_sizes) """ - min_inner_size_needed = (np.max([image_new_size[i], text_inner_size[i]]) - for i in [0,1]) + min_inner_size_needed = tuple([np.max([image_new_size[i], text_inner_size[i]]) + for i in [0,1]]) min_overall_size_needed = tuple(np.array(min_inner_size_needed) +\ np.array(text_extra_size)) - size_ratio = orginal_overall_size[0] / orginal_overall_size[0] + size_ratio = original_overall_size[0] / original_overall_size[1] out_array = np.zeros(2) - if min_overall_size_needed[1] < 1/size_ratio * min_overall_size_needed[0]: + if min_overall_size_needed[1] > 1/size_ratio * min_overall_size_needed[0]: out_array[1] = min_overall_size_needed[1] out_array[0] = size_ratio * min_overall_size_needed[1] else: @@ -397,7 +397,7 @@ def _overall_scale_recommendation_patch(interior_image_scalings, Notes ----- - This function assumes ate least one of the new image_new_size values are + This function assumes at least one of the new image_new_size values are greater than the original requested size (original_overall_size - text_extra_sizes) """ diff --git a/tests/test_base_elements.py b/tests/test_base_elements.py index d701c24..e729788 100644 --- a/tests/test_base_elements.py +++ b/tests/test_base_elements.py @@ -4,9 +4,12 @@ import numpy as np import cowpatch as cow from cowpatch.utils import inherits, _flatten_nested_list, \ - _transform_size_to_pt + _transform_size_to_pt, to_inches +from cowpatch.config import rcParams + import pytest import io +import itertools import plotnine as p9 import plotnine.data as p9_data @@ -64,6 +67,158 @@ def test_patch__init__(): assert len(mypatch_args2.grobs) == 3, \ "grobs can be passed through the grobs parameter indirectly" +def test_patch_layout(): + """ + tests when default layout appears and that it's correct + + static tests + """ + + p0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.facet_wrap("cyl") +\ + p9.labs(title = 'Plot 0') + + p1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 1') + + p2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 2') + + + # 3 elements + base = cow.patch(grobs=[p0,p1,p2]) + out_layout = base.layout + + assert base._layout == "patch" and \ + out_layout == cow.layout(nrow=3, ncol=1), \ + "if only 3 grobs & no layout, expect presented in a single column display" + + base1 = base + cow.layout(nrow=2, ncol=2) + out_layout1 = base1.layout + + assert base1._layout == out_layout1 and \ + out_layout1 == cow.layout(nrow=2, ncol=2), \ + "layout added to patch should be the one returned (complete layout)" + + + base2 = base + cow.layout(nrow=2) + out_layout2 = base2.layout + + assert base2._layout == out_layout2 and \ + out_layout2 == cow.layout(nrow=2), \ + "layout added to patch should be the one returned (partical layout)" + + # 4 elements + base = cow.patch(grobs=[p0,p1,p2,p0]) + out_layout = base.layout + + + assert base._layout == "patch" and \ + out_layout == cow.layout(nrow=2, ncol=2), \ + "if only 4 grobs & no layout, expect presented in a 2 x 2 display" + + + # > 4 elements + ngrobs = np.random.choice(np.arange(5,31)) + nrow = int(np.ceil(np.sqrt(ngrobs))) + ncol = int(np.ceil(ngrobs / nrow)) + base_large = cow.patch(grobs=[p0 for i in range(ngrobs)]) + out_layout_large = base_large.layout + + assert base_large._layout == "patch" and \ + out_layout_large == cow.layout(nrow=nrow, ncol=ncol) and \ + nrow*ncol >= ngrobs, \ + ("if only %i grobs & no layout, expect presented in a "+ + "sqrt(n) x sqrt(n)-ish display") % ngrobs + +def test_patch_annotation(): + """ + tests when default annotation appears and that it's correct + + static tests + """ + + p0 = p9.ggplot(p9_data.mpg) +\ + p9.geom_bar(p9.aes(x="hwy")) +\ + p9.facet_wrap("cyl") +\ + p9.labs(title = 'Plot 0') + + p1 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ + p9.labs(title = 'Plot 1') + + p2 = p9.ggplot(p9_data.mpg) +\ + p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ + p9.labs(title = 'Plot 2') + + + # none, 3 grobs + base = cow.patch(grobs=[p0,p1,p2]) + out_ann = base.annotation + + assert base._annotation == None and \ + out_ann == cow.annotation(tags_inherit="override"), \ + "if no annotation set, basic one defined (base has grobs)" + + # none, no grobs + base0 = cow.patch() + out_ann0 = base0.annotation + + + assert base0._annotation == None and \ + out_ann0 == cow.annotation(tags_inherit="override"), \ + "if no annotation set, basic one defined (base has no grobs)" + + + # with annotation object + for title, subtitle, caption in \ + itertools.product(["title", cow.text("title"), {"bottom":"title"}, + {"bottom": cow.text("title")}, + {"left": 'ltitle', "bottom":"btitle"}, + {"left": cow.text('ltitle'), "bottom": cow.text("btitle")}, + None], + ["subtitle", cow.text("subtitle"), {"bottom":"subtitle"}, + {"bottom": cow.text("subtitle")}, + {"left": 'lsubtitle', "bottom":"bsubtitle"}, + {"left": cow.text('lsubtitle'), "bottom": cow.text("bsubtitle")}, + None], + [None, "caption", cow.text("caption")]): + + base_full1 = base + cow.annotation(title=title,subtitle=subtitle, + caption=caption) + out_ann_full1 = base_full1.annotation + + assert base_full1._annotation == out_ann_full1 and \ + out_ann_full1 == cow.annotation(title=title,subtitle=subtitle, + caption=caption), \ + ("annotation properties shouldn't be updated by patch, \n" + + "title: {title}, subtitle: {subtitle}, caption: {caption}".\ + format(title=title,subtitle=subtitle,caption=caption)) + + for tags, tags_loc, tags_inherit in \ + itertools.product( + [('1',), ("0", "a"), ["banana", "apple"], + (["banana", "apple"], "a") , None], + ["top", "bottom", "left", "right", None], + ["fix", "override"]): + + base_full2 = base + cow.annotation(tags=tags, + tags_loc=tags_loc, + tags_inherit=tags_inherit) + out_annotation_full2 = base_full2.annotation + + assert base_full2._annotation == out_annotation_full2 and \ + out_annotation_full2 == cow.annotation(tags=tags, + tags_loc=tags_loc, + tags_inherit=tags_inherit), \ + ("annotation properties shouldn't be updated by patch, \n" + + "tags: {tags}, tags_loc: {tags_loc}, tags_inherit: {tags_inherit}".\ + format(tags=tags,tags_loc=tags_loc, tags_inherit=tags_inherit)) + + def test_patch__get_grob_tag_ordering(): """ test patch's internal _get_grob_tag_ordering @@ -395,7 +550,8 @@ def test_patch__get_grob_tag_ordering2(): "if annotation object itelf is missing, we expect the "+\ "_get_grob_tag_ordering to return an empty array" -def test_patch__estimate_default_min_desired_size_NoAnnotation(): + +def test_patch__default_size_NoAnnotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ p9.labs(title = 'Plot 0') @@ -421,7 +577,7 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): rel_heights = [1,2]) sug_width, sug_height = \ - vis1._hierarchical_general_process(approach="default-size") + vis1._default_size() assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images @@ -444,7 +600,7 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): sug_width_n, sug_height_n = \ - vis_nested._hierarchical_general_process(approach="default-size") + vis_nested._default_size() assert np.allclose(sug_width_n, (2 * # 1/ rel width of smallest width of images @@ -459,10 +615,7 @@ def test_patch__estimate_default_min_desired_size_NoAnnotation(): "suggested height incorrectly sizes the smallest height of the images "+\ "(v2 - nested, no annotation)" -# TODO: test when some plots have tags, others don't -# TODO: this test (below) still needs to be examined -# See "TODO FIX" inside plot -def test_patch__estimate_default_min_desired_size_Annotation(): +def test_patch__default_size_Annotation(): g0 = p9.ggplot(p9_data.mpg) +\ p9.geom_bar(p9.aes(x="hwy")) +\ p9.labs(title = 'Plot 0') @@ -483,7 +636,7 @@ def test_patch__estimate_default_min_desired_size_Annotation(): cow.annotation(title = "My title") sug_width, sug_height = \ - vis1._hierarchical_general_process(approach="default-size") + vis1._default_size() assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images @@ -509,7 +662,7 @@ def test_patch__estimate_default_min_desired_size_Annotation(): sug_width_n, sug_height_n = \ - vis_nested._hierarchical_general_process(approach="default-size") + vis_nested._default_size() assert np.allclose(sug_width_n, (2 * # 1/ rel width of smallest width of images @@ -527,7 +680,7 @@ def test_patch__estimate_default_min_desired_size_Annotation(): "suggested height incorrectly sizes the smallest height of the images "+\ "(v2 - nested, annotation)" - # tag nested option ----------- + # tag nested option (explicit tags_inherit="override")----------- vis_nested_tag = cow.patch(g0,cow.patch(g1,g2)+\ cow.layout(ncol=1, rel_heights = [1,2]) +\ cow.annotation(tags_inherit="override")) +\ @@ -537,18 +690,15 @@ def test_patch__estimate_default_min_desired_size_Annotation(): tags_loc="top") sug_width_nt, sug_height_nt = \ - vis_nested_tag._hierarchical_general_process(approach="default-size") + vis_nested_tag._default_size() assert np.allclose(sug_width_nt, (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ "suggested width incorrectly sizes the smallest width of the images "+\ - "(v2 - nested + tagged, annotation)" + "(v2 - nested + tagged, annotation - explicit tags_inherit=\"override\")" - # TODO: this looks like the tag structure isn't being correctly taken into acount - # it looks like it's not stepping down correctly (tags_format versus index) - # probably associated with _step_down_tags_info assert np.allclose(sug_height_nt, (3 * # 1/ rel width of smallest width of images (and include the caption and 1 tag) (cow.rcParams["base_height"] +\ @@ -557,375 +707,590 @@ def test_patch__estimate_default_min_desired_size_Annotation(): cow.text("My caption", _type="cow_caption").\ _min_size(to_inches=True)[1])), \ "suggested height incorrectly sizes the smallest height of the images "+\ - "(v2 - nested + tagged, annotation)" + "(v2 - nested + tagged, annotation - explicit tags_inherit=\"override\")" + + # tag nested option (implicit tags_inherit="override")----------- + vis_nested_tag_i = cow.patch(g0,cow.patch(g1,g2)+\ + cow.layout(ncol=1, rel_heights = [1,2])) +\ + cow.layout(nrow=1) +\ + cow.annotation(caption = "My caption") +\ + cow.annotation(tags=("0", "a"), tags_format=("Fig {0}", "Fig {0}.{1}"), + tags_loc="top") + + sug_width_nt_i, sug_height_nt_i = \ + vis_nested_tag_i._default_size() + + assert np.allclose(sug_width_nt_i, + (2 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"] * + cow.rcParams["base_aspect_ratio"])), \ + "suggested width incorrectly sizes the smallest width of the images "+\ + "(v2 - nested + tagged, annotation - implicit tags_inherit=\"override\")" + + assert np.allclose(sug_height_nt_i, + (3 * # 1/ rel width of smallest width of images (and include the caption and 1 tag) + (cow.rcParams["base_height"] +\ + cow.text("Fig 01ab", _type="cow_tag").\ + _min_size(to_inches=True)[1]) +\ + cow.text("My caption", _type="cow_caption").\ + _min_size(to_inches=True)[1])), \ + "suggested height incorrectly sizes the smallest height of the images "+\ + "(v2 - nested + tagged, annotation - implicit tags_inherit=\"override\")" -def test_patch__default_size__both_none(): +def test_patch_default_size_TextNoAnnotation(): """ - this test passes none for both parameters + test of patch's _default_size + + Static test to ensure sizes of text objects minimum sizing correctly + alter default_size output if needed. Also, actual examples ensure that + there any error in `_min_size` call relative to `to_inches` parameter + is not occuring. + + Note: + this test doesn't combine text objects with annotation (so it fits + in the NoAnnotation approach). For now we're not going to test that + option. """ - g0 = p9.ggplot(p9_data.mpg) +\ - p9.geom_bar(p9.aes(x="hwy")) +\ - p9.labs(title = 'Plot 0') + # basic option (text fits in desired box) ---------- - g1 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - p9.labs(title = 'Plot 1') + t0 = cow.text("An approximate answer to the right problem\n"+ + "is worth a good deal more than an exact\n"+ + "answer to an approximate problem. ~John Tukey") +\ + p9.element_text(size = 15) - g2 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - p9.labs(title = 'Plot 2') + t1 = cow.text("If I can’t picture it,\n"+ + "I can’t understand it. ~Albert Einstein") +\ + p9.element_text(size = 17, ha="left") - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ - p9.facet_wrap("class") + t2 = cow.text("Tables usually outperform graphics in\n"+ + "reporting on small data sets of 20\n"+ + "numbers or less. ~John Tukey") +\ + p9.element_text(size=12, ha="left") - # basic option ---------- - vis1 = cow.patch(g0,g1,g2) +\ + vis1 = cow.patch(t0, t1, t2) +\ cow.layout(design = np.array([[0,1], [0,2]]), rel_heights = [1,2]) - out_w, out_h = vis1._default_size(height=None,width=None) + sug_width, sug_height = vis1._default_size() - assert np.allclose(out_w, + assert np.allclose(sug_width, (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ - "_default_size incorrectly connects with _size_dive output - width (v1)" + ("when text size elements in image *don't require* larger-than rcParams "+ + "sizing, width sizing should match rcParam related expectation "+ + "(no annotation)") - assert np.allclose(out_h, + assert np.allclose(sug_height, (3 * # 1/ rel width of smallest width of images cow.rcParams["base_height"])), \ - "_default_size incorrectly connects with _size_dive output - height (v1)" + ("when text size elements in image *don't require* larger-than rcParams "+ + "sizing, height sizing should match rcParam related expectation "+ + "(no annotation)") + + m_w0, m_h0 = t0._min_size(to_inches=True) + m_w1, m_h1 = t1._min_size(to_inches=True) + m_w2, m_h2 = t2._min_size(to_inches=True) + + assert ((sug_height >= m_h0) and + (2/3 * sug_height >= m_h2) and + (1/3 * sug_height >= m_h1)) and \ + ((1/2 * sug_width >= m_w0) and + (1/2 * sug_width >= m_w2) and + (1/2 * sug_width >= m_w1)), \ + ("when text size elements in image *don't require* larger-than rcParams "+ + "the min size of text objects should be less than or equal to allocated "+ + "sizing for the image (no annotation)") + + + # second option (text doesn't fits in desired box) ---------- + + t0_2 = cow.text("An approximate answer to the right problem\n"+ + "is worth a good deal more than an exact\n"+ + "answer to an approximate problem. ~John Tukey") +\ + p9.element_text(size = 25) + + t1_2 = cow.text("If I can’t picture it,\n"+ + "I can’t understand it. ~Albert Einstein") +\ + p9.element_text(size = 20, ha="left") + + t2_2 = cow.text("Tables usually outperform graphics in\n"+ + "reporting on small data sets of 20\n"+ + "numbers or less. ~John Tukey") +\ + p9.element_text(size=25, ha="left") + + vis2 = cow.patch(t0_2, t1_2, t2_2) +\ + cow.layout(design = np.array([[0,1], + [0,2]]), + rel_heights = [1,2]) - # nested option -------- - vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ - cow.layout(ncol=1, rel_heights = [1,2])) +\ - cow.layout(nrow=1) + sug_width_2, sug_height_2 = vis2._default_size() - out_w_n, out_h_n = vis_nested._default_size(height=None,width=None) - - assert np.allclose(out_w_n, + assert (sug_width_2 >= (2 * # 1/ rel width of smallest width of images cow.rcParams["base_height"] * cow.rcParams["base_aspect_ratio"])), \ - "_default_size incorrectly connects with _size_dive output - width (v2-nested)" + ("when text size elements in image *require* larger-than rcParams "+ + "sizing, width sizing be greater or equal to rcParam related expectation "+ + "(no annotation)") - assert np.allclose(out_h_n, + assert (sug_height_2 >= (3 * # 1/ rel width of smallest width of images cow.rcParams["base_height"])), \ - "_default_size incorrectly connects with _size_dive output - height (v2-nested)" + ("when text size elements in image *require* larger-than rcParams "+ + "sizing, height sizing be greater or equal to rcParam related expectation "+ + "(no annotation)") + + m_w0_2, m_h0_2 = t0_2._min_size(to_inches=True) + m_w1_2, m_h1_2 = t1_2._min_size(to_inches=True) + m_w2_2, m_h2_2 = t2_2._min_size(to_inches=True) + + assert ((sug_height_2 >= m_h0_2) and + (2/3 * sug_height_2 >= m_h2_2) and + (1/3 * sug_height_2 >= m_h1_2)) and \ + ((1/2 * sug_width_2 >= m_w0_2) and + (1/2 * sug_width_2 >= m_w2_2) and + (1/2 * sug_width_2 >= m_w1_2)), \ + ("when text size elements in image *require* larger-than rcParams "+ + "the min size of text objects should be less than or equal to allocated "+ + "sizing for the image (no annotation)") + + assert ((2 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"] * + cow.rcParams["base_aspect_ratio"]) < + np.max([2 * m_w0_2, 2 * m_w1_2, 2 * m_w2_2])) or \ + ((3 * # 1/ rel width of smallest width of images + cow.rcParams["base_height"]) < + np.max([m_h0_2, 3/2 * m_h2_2, 3 * m_h1_2])), \ + ("inner test to, ensuring that second example provides case where "+ + "text size element in image *require* larger-than rcParams "+ + "default sizing (specifically width, but code allows for either)") -def test_patch__default_size__both_not_none(height,width): - g0 = p9.ggplot(p9_data.mpg) +\ - p9.geom_bar(p9.aes(x="hwy")) +\ - p9.labs(title = 'Plot 0') - g1 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - p9.labs(title = 'Plot 1') - g2 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - p9.labs(title = 'Plot 2') - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ - p9.facet_wrap("class") - # basic option ---------- - vis1 = cow.patch(g0,g1,g2) +\ +def test_patch__doable_size_SimpleText(): + """ + testing patch's _doable_size functionality + + test focuses on only text objects that would correctly fit inside + the suggested size + """ + # overall tests should deal with (1) annotations (title & tags) + # (2) nested & not nested (3) vary with too small and acceptably sized + # figures. + + # 1. test only text attributes (start with default_size) + # should see sizing directly relate to default_size + + # basic option (text fits in desired box) ---------- + + t0 = cow.text("An approximate answer to the right problem\n"+ + "is worth a good deal more than an exact\n"+ + "answer to an approximate problem. ~John Tukey") +\ + p9.element_text(size = 15) + + t1 = cow.text("If I can’t picture it,\n"+ + "I can’t understand it. ~Albert Einstein") +\ + p9.element_text(size = 17, ha="left") + + t2 = cow.text("Tables usually outperform graphics in\n"+ + "reporting on small data sets of 20\n"+ + "numbers or less. ~John Tukey") +\ + p9.element_text(size=12, ha="left") + + t3 = cow.text(label="Pardon my French.\n"+ + "~Non-French speaking person") +\ + p9.element_text(size = 20) + + vis1 = cow.patch(t0, t2, t3) +\ cow.layout(design = np.array([[0,1], [0,2]]), rel_heights = [1,2]) - out_w, out_h = vis1._default_size(height=height,width=width) - assert out_w == width and out_h == height, \ - "if height and width are provided, they shouldn't be changed by "+\ - "default size function (v1 - no nesting)" + sug_width, sug_height = vis1._default_size() - # nested option -------- - vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ - cow.layout(ncol=1, rel_heights = [1,2])) +\ - cow.layout(nrow=1) + sizes_list, doable = vis1._doable_size(width=sug_width, height=sug_height) - out_w_n, out_h_n = vis_nested._default_size(height=height,width=width) - assert out_w_n == width and out_h_n == height, \ - "if height and width are provided, they shouldn't be changed by "+\ - "default size function (v2 - nesting)" + sizes_list_in = np.array([[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list]) -@given(st.floats(min_value=.5, max_value=49)) -def test_patch__default_size__one_none(height_or_width): - g0 = p9.ggplot(p9_data.mpg) +\ - p9.geom_bar(p9.aes(x="hwy")) +\ - p9.labs(title = 'Plot 0') + assert doable, \ + ("if patch's width & height for doable_size is much higher than "+ + "minimum size possible, _doable_size should return doable") - g1 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - p9.labs(title = 'Plot 1') + assert np.allclose([sug_width/2]*3, sizes_list_in[:,0]) and \ + np.allclose(sizes_list_in[:,0].min(), + rcParams["base_height"] * rcParams["base_aspect_ratio"]) and \ + ("if static patch uses default size, expected width sizing "+ + "should be met (both relative to rcParams & structure mult)") - g2 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - p9.labs(title = 'Plot 2') + assert np.allclose(sug_height * np.array([1,1/3,2/3]), sizes_list_in[:,1]) and \ + np.allclose(sizes_list_in[:,1].min(), + rcParams["base_height"]) and \ + ("if static patch uses default size, expected height sizing "+ + "should be met (both relative to rcParams & structure mult)") - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ - p9.facet_wrap("class") + # non-minimum _default_sizing used: - # basic option ---------- - vis1 = cow.patch(g0,g1,g2) +\ - cow.layout(design = np.array([[0,1], - [0,2]]), - rel_heights = [1,2]) + sug_width2, sug_height2 = 15,30 + sizes_list2, doable2 = vis1._doable_size(width=sug_width2, height=sug_height2) - default_w, default_h = vis1._default_size(None,None) - static_aspect_ratio = default_h / default_w + sizes_list_in2 = np.array([[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list2]) - # provide width ---- - out_w, out_h = vis1._default_size(height=None,width=height_or_width) - assert np.allclose(out_w, height_or_width) and \ - np.allclose(out_h, height_or_width * static_aspect_ratio), \ - "if *only width* is provided, suggested height is relative to aspect "+\ - "ratio that would be suggested if neither provided (v1)" + assert doable2, \ + ("if patch's width & height for doable_size is much higher than "+ + "minimum size possible, size > _doable_size should return doable") - # provide height ---- - out_w, out_h = vis1._default_size(height=height_or_width,width=None) - assert np.allclose(out_h, height_or_width) and \ - np.allclose(out_w, height_or_width / static_aspect_ratio), \ - "if *only height* is provided, suggested width is relative to aspect "+\ - "ratio that would be suggested if neither provided (v1)" + assert np.allclose([sug_width2/2]*3, sizes_list_in2[:,0]) and \ + (sizes_list_in2[:,0].min() > + rcParams["base_height"] * rcParams["base_aspect_ratio"]) and \ + ("if static patch uses sizes > default size, expected width sizing "+ + "should be met relative to input sizing") - # nested option -------- - vis_nested = cow.patch(g0,cow.patch(g1,g2)+\ - cow.layout(ncol=1, rel_heights = [1,2])) +\ - cow.layout(nrow=1) + assert np.allclose(sug_height2 * np.array([1,1/3,2/3]), sizes_list_in2[:,1]) and \ + (sizes_list_in2[:,1].min() > + rcParams["base_height"]) and \ + ("if static patch uses sizes > default size, expected height sizing "+ + "should be met relative to input sizing") - default_w_n, default_h_n = vis_nested._default_size(None,None) - static_aspect_ratio_n = default_h_n / default_w_n - # provide width ---- - out_w, out_h = vis_nested._default_size(height=None,width=height_or_width) - assert np.allclose(out_w, height_or_width) and \ - np.allclose(out_h, height_or_width * static_aspect_ratio_n), \ - "if *only width* is provided, suggested height is relative to aspect "+\ - "ratio that would be suggested if neither provided (v1)" - # provide height ---- - out_w, out_h = vis_nested._default_size(height=height_or_width,width=None) - assert np.allclose(out_h, height_or_width) and \ - np.allclose(out_w, height_or_width / static_aspect_ratio_n), \ - "if *only height* is provided, suggested width is relative to aspect "+\ - "ratio that would be suggested if neither provided (v1)" + # nested structure, _default_sizing used: + vis1_nested = cow.patch(grobs = [t0, + cow.patch(t2, t3) + cow.layout(design = np.array([[0],[1],[1]]))]) +\ + cow.layout(design = np.array([[0,1]])) -def test_patch__svg_get_sizes(): - g0 = p9.ggplot(p9_data.mpg) +\ - p9.geom_bar(p9.aes(x="hwy")) +\ - p9.labs(title = 'Plot 0') - g1 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - p9.labs(title = 'Plot 1') + sug_width_n, sug_height_n = vis1_nested._default_size() - g2 = p9.ggplot(p9_data.mpg) +\ - p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - p9.labs(title = 'Plot 2') + sizes_list_n, doable_n = vis1_nested.\ + _doable_size(width=sug_width_n, height=sug_height_n) - g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - "suv", - "pickup"])]) +\ - p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ - p9.facet_wrap("class") + sizes_list_n_in = np.array([[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list_n[:1]] + + [[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list_n[1]]) + assert doable_n, \ + ("if patch's width & height for doable_size is much higher than "+ + "minimum size possible, _doable_size should return doable - nested") - # basic option ---------- - vis1 = cow.patch(g0,g1,g2) +\ + assert np.allclose([sug_width_n/2]*3, sizes_list_n_in[:,0]) and \ + np.allclose(sizes_list_n_in[:,0].min(), + rcParams["base_height"] * rcParams["base_aspect_ratio"]) and \ + ("if static patch uses default size, expected width sizing "+ + "should be met (both relative to rcParams & structure mult) - nested") + + assert np.allclose(sug_height_n * np.array([1,1/3,2/3]), + sizes_list_n_in[:,1]) and \ + np.allclose(sizes_list_n_in[:,1].min(), + rcParams["base_height"]) and \ + ("if static patch uses default size, expected height sizing "+ + "should be met (both relative to rcParams & structure mult) - nested") + + + # nested structure, non-minimum _default_sizing used: + + + sug_width2, sug_height2 = 15,30 + sizes_list_n2, doable_n2 = vis1_nested.\ + _doable_size(width=sug_width2, height=sug_height2) + + sizes_list_n_in2 = np.array([[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list_n2[:1]] + + [[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list_n2[1]]) + + assert doable_n2, \ + ("if patch's width & height for doable_size is much higher than "+ + "minimum size possible, size > _doable_size should return doable - nested") + + assert np.allclose([sug_width2/2]*3, sizes_list_n_in2[:,0]) and \ + (sizes_list_n_in2[:,0].min() > + rcParams["base_height"] * rcParams["base_aspect_ratio"]) and \ + ("if static patch uses sizes > default size, expected width sizing "+ + "should be met relative to input sizing - nested") + + assert np.allclose(sug_height2 * np.array([1,1/3,2/3]), + sizes_list_n_in2[:,1]) and \ + (sizes_list_n_in2[:,1].min() > + rcParams["base_height"]) and \ + ("if static patch uses sizes > default size, expected height sizing "+ + "should be met relative to input sizing - nested") + + + + + # second option (text doesn't fits in desired box) ---------- + + t0_2 = cow.text("An approximate answer to the right problem\n"+ + "is worth a good deal more than an exact\n"+ + "answer to an approximate problem. ~John Tukey") +\ + p9.element_text(size = 25) + + t1_2 = cow.text("If I can’t picture it,\n"+ + "I can’t understand it. ~Albert Einstein") +\ + p9.element_text(size = 20, ha="left") + + t2_2 = cow.text("Tables usually outperform graphics in\n"+ + "reporting on small data sets of 20\n"+ + "numbers or less. ~John Tukey") +\ + p9.element_text(size=25, ha="left") + + vis_nf = cow.patch(t0_2, t1_2, t2_2) +\ cow.layout(design = np.array([[0,1], [0,2]]), - rel_heights = [4,1]) + rel_heights = [1,2]) - # successful sizings ---- - sizes, logics = vis1._svg_get_sizes(width_pt = 20 * 72, - height_pt = 20 * 72) + sug_width_nf = 2 * rcParams["base_height"] * rcParams["base_aspect_ratio"] + sug_height_nf = 3 * rcParams["base_height"] - requested_sizes = [(10,20), (10,16), (10,4)] + sug_width_nf_min, sug_height_nf_min = vis_nf._default_size() - assert np.all(logics), \ - "expected all plotnine objects to be able to be sized correctly "+\ - "in very large output (v1)" + assert (sug_width_nf < sug_width_nf_min) and \ + (sug_height_nf <= sug_height_nf_min), \ + ("test structure (that rcParams default size is too small for " + + "figure creation) was incorrectly defined (needed width is larger)") - assert type(sizes) is list and \ - np.all([len(s) == 2 and type(s) is tuple for s in sizes]), \ - "expected structure of sizes list is incorrect (v1)" + sizes_list_nf, doable_nf = vis_nf.\ + _doable_size(width=sug_width_nf, height=sug_height_nf, + data_dict = {"size-num-attempts": 1}) - assert np.all([2/3 < (sizes[s_idx][0]/requested_sizes[s_idx][0]) < 1.5 and \ - 2/3 < (sizes[s_idx][1]/requested_sizes[s_idx][1]) < 1.5 - for s_idx in [0,1,2]]), \ - "suggested sizing in sizes isn't too extreme relative to true "+\ - "requested sizes- this is just a sanity check, "+\ - "not a robust test (v1)" + sizes_list_nf_in = np.array([[to_inches(x[0], "pt"), to_inches(x[1], "pt")] + for x in sizes_list_nf]) - # failed sizings ------ - sizes_f, logics_f = vis1._svg_get_sizes(width_pt = 10 * 72, - height_pt = 10 * 72) + vis_nf._doable_size(width=sug_width_nf_min, height=sug_height_nf_min) - requested_sizes_f = [(5,10), (5,8), (5,2)] # final one should fail... - assert not np.all(logics_f) and (logics_f == [True, True, False]), \ - "expected not all plotnine objects to be able to be sized correctly "+\ - "in small output (v1.1 - failed)" - assert type(sizes_f) is list and \ - np.all([len(s) == 2 and type(s) is tuple for s in sizes_f]), \ - "expected structure of sizes list is incorrect (v1.1 - failed)" + # nested - assert np.all([2/3 < (sizes_f[s_idx][0]/requested_sizes_f[s_idx][0]) < 1.5 and \ - 2/3 < (sizes_f[s_idx][1]/requested_sizes_f[s_idx][1]) < 1.5 - for s_idx in [0,1]]), \ - "suggested sizing in sizes (that didn't fail) isn't too extreme "+\ - "relative to true "+\ - "requested sizes- this is just a sanity check, "+\ - "not a robust test (v1.1 - failed)" - assert sizes_f[2][0] < 1 and sizes_f[2][1] < 1, \ - "expected failed sizing (due to being too small, to return a scaling" +\ - "below 1 (note the correction to scaling should be 1/suggested scaling))," +\ - "(v1.1 - failed)" +def test_patch_doable_size_StaticNonSimple(): + """ + testing patch's _doable_size functionality - # nested option -------- - vis_nested = cow.patch(g0,cow.patch(g1, g2)+\ - cow.layout(ncol=1, rel_heights = [4,1])) +\ - cow.layout(nrow=1) + test focuses on varability in acutal requested size + based on plotnine's varying correct input size required + """ + # overall tests should deal with (1) annotations (title & tags) + # (2) nested & not nested (3) vary with too small and acceptably sized + # figures. + + # 2. write a second test with ggplot images and save the size + # for a regression test (data_regression) - # successful sizings ---- - sizes_n, logics_n = vis_nested._svg_get_sizes(width_pt = 20 * 72, - height_pt = 20 * 72) - requested_sizes_n = [(10,20), (10,16), (10,4)] - - assert np.all(_flatten_nested_list(logics_n)), \ - "expected all plotnine objects to be able to be sized correctly "+\ - "in very large output (v2 - nested)" - - - assert type(sizes_n) is list and len(sizes_n) == 2 and \ - type(sizes_n[0]) is tuple and type(sizes_n[1]) is list and \ - len(sizes_n[0]) == 2 and len(sizes_n[1]) == 2 and \ - np.all([len(s) == 2 and type(s) is tuple for s in sizes_n[1]]), \ - "expected structure of sizes list is incorrect (v2 - nested)" - - sizes_n_flattened = _flatten_nested_list(sizes_n) - - assert np.all([2/3 < (sizes_n_flattened[s_idx][0]/requested_sizes[s_idx][0]) < 1.5 and \ - 2/3 < (sizes_n_flattened[s_idx][1]/requested_sizes[s_idx][1]) < 1.5 - for s_idx in [0,1,2]]), \ - "suggested sizing in sizes isn't too extreme relative to true "+\ - "requested sizes- this is just a sanity check, "+\ - "not a robust test (v2 - nested)" - assert np.allclose(sizes_n_flattened, sizes), \ - "expected nested and non-nested suggested sizes to be equal (v1 vs v2)" - # failed sizings ------ - sizes_f_n, logics_f_n = vis_nested._svg_get_sizes(width_pt = 10 * 72, - height_pt = 10 * 72) - requested_sizes_f = [(5,10), (5,8), (5,2)] # final one should fail ... - logic_f_n_flat = _flatten_nested_list(logics_f_n) - sizes_f_n_flat = _flatten_nested_list(sizes_f_n) +# def test_patch__svg_get_sizes(): +# g0 = p9.ggplot(p9_data.mpg) +\ +# p9.geom_bar(p9.aes(x="hwy")) +\ +# p9.labs(title = 'Plot 0') - assert not np.all(logic_f_n_flat) and \ - (logic_f_n_flat == [True, True, False]), \ - "expected not all plotnine objects to be able to be sized correctly "+\ - "in smaller output (v2.1 - nested, failed)" +# g1 = p9.ggplot(p9_data.mpg) +\ +# p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ +# p9.labs(title = 'Plot 1') - assert type(sizes_f_n) is list and len(sizes_f_n) == 2 and \ - type(sizes_f_n[0]) is tuple and type(sizes_f_n[1]) is list and \ - len(sizes_f_n[0]) == 2 and len(sizes_f_n[1]) == 2 and \ - np.all([len(s) == 2 and type(s) is tuple for s in sizes_f_n[1]]), \ - "expected structure of sizes list is incorrect (v2.1 - nested, failed)" +# g2 = p9.ggplot(p9_data.mpg) +\ +# p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ +# p9.labs(title = 'Plot 2') - assert np.all([2/3 < (sizes_f_n_flat[s_idx][0]/requested_sizes_f[s_idx][0]) < 1.5 and \ - 2/3 < (sizes_f_n_flat[s_idx][1]/requested_sizes_f[s_idx][1]) < 1.5 - for s_idx in [0,1]]), \ - "suggested sizing in sizes (that didn't fail) isn't too extreme "+\ - "relative to true "+\ - "requested sizes- this is just a sanity check, "+\ - "not a robust test (v2.1 - nested, failed)" +# g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", +# "suv", +# "pickup"])]) +\ +# p9.geom_histogram(p9.aes(x="hwy"),bins=10) +\ +# p9.facet_wrap("class") - assert sizes_f_n_flat[2][0] < 1 and sizes_f_n_flat[2][1] < 1, \ - "expected failed sizing (due to being too small, to return a scaling" +\ - "below 1 (note the correction to scaling should be 1/suggested scaling))," +\ - "(v2.1 - nested, failed)" - assert np.allclose(sizes_f_n_flat, sizes_f), \ - "expected nested and non-nested suggested sizes to be equal (v1.1 vs v2.1 - failed)" +# # basic option ---------- +# vis1 = cow.patch(g0,g1,g2) +\ +# cow.layout(design = np.array([[0,1], +# [0,2]]), +# rel_heights = [4,1]) -@given(st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49), - st.floats(min_value=.5, max_value=49)) -def test_patch__process_sizes(w1,h1,w2,h2,w3,h3): - # default patch (not needed) - empty_patch = cow.patch() +# # successful sizings ---- +# sizes, logics = vis1._svg_get_sizes(width_pt = 20 * 72, +# height_pt = 20 * 72) - # not nested ------- - sizes = [(w1,h1),(w2,h2),(w3,h3)] +# requested_sizes = [(10,20), (10,16), (10,4)] - # all true --- - logics = [True, True, True] +# assert np.all(logics), \ +# "expected all plotnine objects to be able to be sized correctly "+\ +# "in very large output (v1)" - out_s = empty_patch._process_sizes(sizes = sizes, logics = logics) - assert out_s == sizes, \ - "expected sizes to return if all logics true" +# assert type(sizes) is list and \ +# np.all([len(s) == 2 and type(s) is tuple for s in sizes]), \ +# "expected structure of sizes list is incorrect (v1)" - # not all true ---- - logics_f = [True, True, False] - out_s1 = empty_patch._process_sizes(sizes = sizes, logics = logics_f) +# assert np.all([2/3 < (sizes[s_idx][0]/requested_sizes[s_idx][0]) < 1.5 and \ +# 2/3 < (sizes[s_idx][1]/requested_sizes[s_idx][1]) < 1.5 +# for s_idx in [0,1,2]]), \ +# "suggested sizing in sizes isn't too extreme relative to true "+\ +# "requested sizes- this is just a sanity check, "+\ +# "not a robust test (v1)" - assert np.allclose(out_s1, 1/np.min(sizes[2])), \ - "expected max_scaling should be the max of 1/width_scale and "+\ - "1/height_scale assoicated with failed plot(s) (v1.1 - 1 plot failed)" +# # failed sizings ------ +# sizes_f, logics_f = vis1._svg_get_sizes(width_pt = 10 * 72, +# height_pt = 10 * 72) + +# requested_sizes_f = [(5,10), (5,8), (5,2)] # final one should fail... + +# assert not np.all(logics_f) and (logics_f == [True, True, False]), \ +# "expected not all plotnine objects to be able to be sized correctly "+\ +# "in small output (v1.1 - failed)" + +# assert type(sizes_f) is list and \ +# np.all([len(s) == 2 and type(s) is tuple for s in sizes_f]), \ +# "expected structure of sizes list is incorrect (v1.1 - failed)" + +# assert np.all([2/3 < (sizes_f[s_idx][0]/requested_sizes_f[s_idx][0]) < 1.5 and \ +# 2/3 < (sizes_f[s_idx][1]/requested_sizes_f[s_idx][1]) < 1.5 +# for s_idx in [0,1]]), \ +# "suggested sizing in sizes (that didn't fail) isn't too extreme "+\ +# "relative to true "+\ +# "requested sizes- this is just a sanity check, "+\ +# "not a robust test (v1.1 - failed)" + +# assert sizes_f[2][0] < 1 and sizes_f[2][1] < 1, \ +# "expected failed sizing (due to being too small, to return a scaling" +\ +# "below 1 (note the correction to scaling should be 1/suggested scaling))," +\ +# "(v1.1 - failed)" - logics_f2 = [True, False, False] - out_s2 = empty_patch._process_sizes(sizes = sizes, logics = logics_f2) +# # nested option -------- +# vis_nested = cow.patch(g0,cow.patch(g1, g2)+\ +# cow.layout(ncol=1, rel_heights = [4,1])) +\ +# cow.layout(nrow=1) - assert np.allclose(out_s2, 1/np.min([w2,h2,w3,h3])), \ - "expected max_scaling should be the max of 1/width_scale and "+\ - "1/height_scale assoicated with failed plot(s) (v1.2 - 2 plot failed)" +# # successful sizings ---- +# sizes_n, logics_n = vis_nested._svg_get_sizes(width_pt = 20 * 72, +# height_pt = 20 * 72) +# requested_sizes_n = [(10,20), (10,16), (10,4)] - # nested --------- - sizes_n = [(w1,h1),[(w2,h2),(w3,h3)]] +# assert np.all(_flatten_nested_list(logics_n)), \ +# "expected all plotnine objects to be able to be sized correctly "+\ +# "in very large output (v2 - nested)" - # all true --- - logics_n = [True, [True, True]] - out_s_n = empty_patch._process_sizes(sizes = sizes_n, logics = logics_n) - assert out_s_n == sizes_n, \ - "expected unflatted sizes to return if all logics true (v2 - nested)" - # not all true ---- - logics_n_f = [True, [True, False]] - out_s1 = empty_patch._process_sizes(sizes = sizes_n, logics = logics_n_f) - - assert np.allclose(out_s1, 1/np.min(sizes_n[1][1])), \ - "expected max_scaling should be the max of 1/width_scale and "+\ - "1/height_scale assoicated with failed plot(s) (v2.1 - 1 plot failed)" +# assert type(sizes_n) is list and len(sizes_n) == 2 and \ +# type(sizes_n[0]) is tuple and type(sizes_n[1]) is list and \ +# len(sizes_n[0]) == 2 and len(sizes_n[1]) == 2 and \ +# np.all([len(s) == 2 and type(s) is tuple for s in sizes_n[1]]), \ +# "expected structure of sizes list is incorrect (v2 - nested)" + +# sizes_n_flattened = _flatten_nested_list(sizes_n) + +# assert np.all([2/3 < (sizes_n_flattened[s_idx][0]/requested_sizes[s_idx][0]) < 1.5 and \ +# 2/3 < (sizes_n_flattened[s_idx][1]/requested_sizes[s_idx][1]) < 1.5 +# for s_idx in [0,1,2]]), \ +# "suggested sizing in sizes isn't too extreme relative to true "+\ +# "requested sizes- this is just a sanity check, "+\ +# "not a robust test (v2 - nested)" + +# assert np.allclose(sizes_n_flattened, sizes), \ +# "expected nested and non-nested suggested sizes to be equal (v1 vs v2)" + +# # failed sizings ------ +# sizes_f_n, logics_f_n = vis_nested._svg_get_sizes(width_pt = 10 * 72, +# height_pt = 10 * 72) + +# requested_sizes_f = [(5,10), (5,8), (5,2)] # final one should fail ... + +# logic_f_n_flat = _flatten_nested_list(logics_f_n) +# sizes_f_n_flat = _flatten_nested_list(sizes_f_n) + +# assert not np.all(logic_f_n_flat) and \ +# (logic_f_n_flat == [True, True, False]), \ +# "expected not all plotnine objects to be able to be sized correctly "+\ +# "in smaller output (v2.1 - nested, failed)" + +# assert type(sizes_f_n) is list and len(sizes_f_n) == 2 and \ +# type(sizes_f_n[0]) is tuple and type(sizes_f_n[1]) is list and \ +# len(sizes_f_n[0]) == 2 and len(sizes_f_n[1]) == 2 and \ +# np.all([len(s) == 2 and type(s) is tuple for s in sizes_f_n[1]]), \ +# "expected structure of sizes list is incorrect (v2.1 - nested, failed)" + +# assert np.all([2/3 < (sizes_f_n_flat[s_idx][0]/requested_sizes_f[s_idx][0]) < 1.5 and \ +# 2/3 < (sizes_f_n_flat[s_idx][1]/requested_sizes_f[s_idx][1]) < 1.5 +# for s_idx in [0,1]]), \ +# "suggested sizing in sizes (that didn't fail) isn't too extreme "+\ +# "relative to true "+\ +# "requested sizes- this is just a sanity check, "+\ +# "not a robust test (v2.1 - nested, failed)" + +# assert sizes_f_n_flat[2][0] < 1 and sizes_f_n_flat[2][1] < 1, \ +# "expected failed sizing (due to being too small, to return a scaling" +\ +# "below 1 (note the correction to scaling should be 1/suggested scaling))," +\ +# "(v2.1 - nested, failed)" + +# assert np.allclose(sizes_f_n_flat, sizes_f), \ +# "expected nested and non-nested suggested sizes to be equal (v1.1 vs v2.1 - failed)" + +# @given(st.floats(min_value=.5, max_value=49), +# st.floats(min_value=.5, max_value=49), +# st.floats(min_value=.5, max_value=49), +# st.floats(min_value=.5, max_value=49), +# st.floats(min_value=.5, max_value=49), +# st.floats(min_value=.5, max_value=49)) +# def test_patch__process_sizes(w1,h1,w2,h2,w3,h3): +# # default patch (not needed) +# empty_patch = cow.patch() + +# # not nested ------- +# sizes = [(w1,h1),(w2,h2),(w3,h3)] + +# # all true --- +# logics = [True, True, True] + +# out_s = empty_patch._process_sizes(sizes = sizes, logics = logics) +# assert out_s == sizes, \ +# "expected sizes to return if all logics true" + +# # not all true ---- +# logics_f = [True, True, False] +# out_s1 = empty_patch._process_sizes(sizes = sizes, logics = logics_f) + +# assert np.allclose(out_s1, 1/np.min(sizes[2])), \ +# "expected max_scaling should be the max of 1/width_scale and "+\ +# "1/height_scale assoicated with failed plot(s) (v1.1 - 1 plot failed)" + +# logics_f2 = [True, False, False] +# out_s2 = empty_patch._process_sizes(sizes = sizes, logics = logics_f2) - logics_f2 = [True, [False, False]] - out_s2 = empty_patch._process_sizes(sizes = sizes, logics = logics_f2) - - assert np.allclose(out_s2, 1/np.min([w2,h2,w3,h3])), \ - "expected max_scaling should be the max of 1/width_scale and "+\ - "1/height_scale assoicated with failed plot(s) (v2.2 - 2 plot failed)" +# assert np.allclose(out_s2, 1/np.min([w2,h2,w3,h3])), \ +# "expected max_scaling should be the max of 1/width_scale and "+\ +# "1/height_scale assoicated with failed plot(s) (v1.2 - 2 plot failed)" + +# # nested --------- +# sizes_n = [(w1,h1),[(w2,h2),(w3,h3)]] + +# # all true --- +# logics_n = [True, [True, True]] +# out_s_n = empty_patch._process_sizes(sizes = sizes_n, logics = logics_n) +# assert out_s_n == sizes_n, \ +# "expected unflatted sizes to return if all logics true (v2 - nested)" + +# # not all true ---- +# logics_n_f = [True, [True, False]] +# out_s1 = empty_patch._process_sizes(sizes = sizes_n, logics = logics_n_f) + +# assert np.allclose(out_s1, 1/np.min(sizes_n[1][1])), \ +# "expected max_scaling should be the max of 1/width_scale and "+\ +# "1/height_scale assoicated with failed plot(s) (v2.1 - 1 plot failed)" + +# logics_f2 = [True, [False, False]] +# out_s2 = empty_patch._process_sizes(sizes = sizes, logics = logics_f2) + +# assert np.allclose(out_s2, 1/np.min([w2,h2,w3,h3])), \ +# "expected max_scaling should be the max of 1/width_scale and "+\ +# "1/height_scale assoicated with failed plot(s) (v2.2 - 2 plot failed)" # global savings and showing and creating ------ @@ -1168,40 +1533,7 @@ def test_patch__repr__(capsys): " # grobs and layout" -# grammar ----------- - -def test_patch__and__(image_regression): - # # creation of some some ggplot objects - # g0 = p9.ggplot(p9_data.mpg) +\ - # p9.geom_bar(p9.aes(x="hwy")) +\ - # p9.labs(title = 'Plot 0') - - # g1 = p9.ggplot(p9_data.mpg) +\ - # p9.geom_point(p9.aes(x="hwy", y = "displ")) +\ - # p9.labs(title = 'Plot 1') - - # g2 = p9.ggplot(p9_data.mpg) +\ - # p9.geom_point(p9.aes(x="hwy", y = "displ", color="class")) +\ - # p9.labs(title = 'Plot 2') - - # g3 = p9.ggplot(p9_data.mpg[p9_data.mpg["class"].isin(["compact", - # "suv", - # "pickup"])]) +\ - # p9.geom_histogram(p9.aes(x="hwy")) +\ - # p9.facet_wrap("class") - - # g0p = cow.patch(g0) - # g1p = cow.patch(g1) - # g2p = cow.patch(g2) - # g3p = cow.patch(g3) - - - # g01 = g0p + g1p - # g02 = g0p + g2p - # g012 = g0p + g1p + g2p - # g012_2 = g01 + g2p - pass - +# uniquify ----------- def test_patch_svg_uniquify1(image_regression): """ @@ -1267,7 +1599,6 @@ def test_patch_svg_uniquify2(image_regression): image_regression.check(fid.getvalue(), diff_threshold=.1) - def test_patch_svg_uniquify3(image_regression): """ test 3: patch _svg creation use of svg_utils._unquify_svg_str diff --git a/tests/test_layout_elements.py b/tests/test_layout_elements.py index 3487d2b..e33c863 100644 --- a/tests/test_layout_elements.py +++ b/tests/test_layout_elements.py @@ -230,7 +230,7 @@ def test_layout__yokogaki_ordering(): assert np.all(yorder2 == np.array([0,2,3,1])), \ "yokogaki ordering with second simple example failed (2)" -def test_layout__eq__(): +def test_layout__not_eq__(): """ test layout equal for things not equal... (basic test) """ @@ -250,6 +250,24 @@ def test_layout__eq__(): assert l1 != "example string", \ "layout doesn't equal a string..." +def test_layout__eq__(): + """ + test for equal layout even if not complete (nrow, ncol) + """ + l1 = cow.layout(nrow = 1) + l2 = cow.layout(nrow=1) + + assert l1 == l2, \ + "expected equality of layout even if not complete (nrow, but no ncol)" + + l1 = cow.layout(ncol = 1) + l2 = cow.layout(ncol=1) + + assert l1 == l2, \ + "expected equality of layout even if not complete (ncol, but no nrow)" + + + def test_layout__element_locations2(): """ test assumes default relative widths and heights diff --git a/tests/test_utils.py b/tests/test_utils.py index 5c52810..390deaf 100644 --- a/tests/test_utils.py +++ b/tests/test_utils.py @@ -171,3 +171,150 @@ def test__flatten_nested_list(): x3_flat = cowpatch.utils._flatten_nested_list(x3) assert x3_flat == x3, \ "_flatten_nested_list failed to flatten correctly (v3 - no nesting)" + + +@given(st.integers(min_value=1, max_value=100000), + st.floats(min_value = 1.001, max_value=5), + st.floats(min_value = .2, max_value=.9), + st.floats(min_value = 5, max_value=25), + st.floats(min_value = 5, max_value=25)) +def test__overall_scale_recommendation_patch_NoAnnotation(seed, + upscale, + lesser_upscale, + width, height): + """ + basic test _overall_scale_recommendation_patch, without annotation + """ + np.random.seed(seed) + size_multiplier = list(np.array([1, upscale, 1, + (upscale * lesser_upscale + (1-lesser_upscale)) + ])[np.random.choice(4,4, replace=False)]) + text_inner_size = (0,0) + text_extra_size = (0,0) + original_overall_size = (width, height) + + out = cowpatch.utils._overall_scale_recommendation_patch(size_multiplier, + text_inner_size, + text_extra_size, + original_overall_size) + + assert np.allclose(out[0], + np.array([width,height])*upscale) and \ + np.allclose(out[1], upscale) and \ + np.allclose(np.max(size_multiplier), upscale), \ + "overall upscaling matches needs of largest upscale grob" + + +@given(st.integers(min_value=1, max_value=100000), + st.floats(min_value = 1.001, max_value=5), + st.floats(min_value = .2, max_value=.9), + st.floats(min_value = 5, max_value=25), + st.floats(min_value = 5, max_value=25), + + st.floats(min_value = .1, max_value=10), + st.floats(min_value = .1, max_value=10), + st.floats(min_value = .1, max_value=2), + st.floats(min_value = .1, max_value=2)) +def test__overall_scale_recommendation_patch_Annotation(seed, + upscale, + lesser_upscale, + width, height, + text_inner_w, + text_inner_h, + text_extra_w, + text_extra_h): + """ + basic test _overall_scale_recommendation_patch, with annotation + + solid checks with/without extra_size, with/without inner_size + not really any checks on both non-zero + """ + np.random.seed(seed) + size_multiplier = list(np.array([1, upscale, 1, + (upscale * lesser_upscale + (1-lesser_upscale)) + ])[np.random.choice(4,4, replace=False)]) + text_inner_size = (text_inner_w,text_inner_h) + text_extra_size = (text_extra_w,text_extra_h) + original_overall_size = (width, height) + + out_no_ann = cowpatch.utils._overall_scale_recommendation_patch(size_multiplier, + (0,0), + (0,0), + original_overall_size) + + + # both non-zero --------------- + + out = cowpatch.utils._overall_scale_recommendation_patch(size_multiplier, + text_inner_size, + text_extra_size, + original_overall_size) + + + # no extra size ------------------ + + out_no_extra_size = cowpatch.utils._overall_scale_recommendation_patch(size_multiplier, + text_inner_size, + (0,0), + original_overall_size) + + # text_inner_size < original_overall_size, and text_extra_size = (0,0)) + if (text_inner_size[0] < width) and (text_inner_size[1] < height): + assert np.allclose(out_no_extra_size[0], np.array([width,height])*upscale) and \ + np.allclose(out_no_extra_size[1], upscale) and \ + np.allclose(np.max(size_multiplier), upscale), \ + ("overall upscaling matches needs of largest upscale grob " + + "(text_inner_size < original_overall_size, and " + + "text_extra_size = (0,0))") + elif (text_inner_size[0] > out_no_ann[0][0]) or \ + (text_inner_size[1] > out_no_ann[0][1]): + # text inner size is larger than normal upscale required + assert out_no_extra_size[1] > np.max(size_multiplier), \ + ("if text_inner_size is larger than the base scale-up sizing " + + "the scaling will be larger then for just the default") + + if text_inner_size[0] > out_no_ann[0][0]: + assert np.allclose(text_inner_size[0], out_no_extra_size[0][0]), \ + ("if text_inner_size is larger than the base scale-up sizing " + + "outputted sizing should be relative to it (width)") + + if text_inner_size[1] > out_no_ann[0][1]: + assert np.allclose(text_inner_size[1], out_no_extra_size[0][1]), \ + ("if text_inner_size is larger than the base scale-up sizing " + + "outputted sizing should be relative to it (height)") + + # no inner size ------------------ + + # TODO: see comments + # Comment: TODO: look at the equation on overleaf (page 2 & 3) and + # inspect the ipad notes on why we thought it was $<$ not $>$ for the + # logic.) + + out_no_inner_size = cowpatch.utils._overall_scale_recommendation_patch(size_multiplier, + (0,0), + text_extra_size, + original_overall_size) + + # minimum size of height and width + expected_size_out_no_inner_size_no_ratio = \ + (np.array(original_overall_size) - np.array(text_extra_size)) *\ + np.max(size_multiplier) + np.array(text_extra_size) + + scaling = np.max([expected_size_out_no_inner_size_no_ratio[i]/original_overall_size[i] + for i in [0,1]]) + + # calculation to get height and width that obey min sizing and keep height/width ratio + # TODO: the idea that you can use an argmin isn't correct - this is relative ratio even now. + min_val = np.argmin(expected_size_out_no_inner_size_no_ratio) + expected_size_out_no_inner_size =\ + expected_size_out_no_inner_size_no_ratio[min_val] *\ + [np.array([1, height/width]), np.array([width,height,1])][min_val] + + + assert np.allclose(expected_size_out_no_inner_size, out_no_inner_size[0]) and \ + np.allclose(scaling, out_no_inner_size[1]), \ + ("expected (with inner_size = 0), that extra size impacted both the "+ + "initial scaling estimate and the final estimated size") + + +