diff --git a/doc/_embedded_plots/grouped_bar.py b/doc/_embedded_plots/grouped_bar.py new file mode 100644 index 000000000000..f02e269328d2 --- /dev/null +++ b/doc/_embedded_plots/grouped_bar.py @@ -0,0 +1,15 @@ +import matplotlib.pyplot as plt + +categories = ['A', 'B'] +data0 = [1.0, 3.0] +data1 = [1.4, 3.4] +data2 = [1.8, 3.8] + +fig, ax = plt.subplots(figsize=(4, 2.2)) +ax.grouped_bar( + [data0, data1, data2], + tick_labels=categories, + labels=['dataset 0', 'dataset 1', 'dataset 2'], + colors=['#1f77b4', '#58a1cf', '#abd0e6'], +) +ax.legend() diff --git a/doc/api/axes_api.rst b/doc/api/axes_api.rst index 4bbcbe081194..b742ce9b7a55 100644 --- a/doc/api/axes_api.rst +++ b/doc/api/axes_api.rst @@ -67,6 +67,7 @@ Basic Axes.bar Axes.barh Axes.bar_label + Axes.grouped_bar Axes.stem Axes.eventplot diff --git a/doc/api/bezier_api.rst b/doc/api/bezier_api.rst index b3764ad04b5a..45019153fa63 100644 --- a/doc/api/bezier_api.rst +++ b/doc/api/bezier_api.rst @@ -5,4 +5,5 @@ .. automodule:: matplotlib.bezier :members: :undoc-members: + :special-members: __call__ :show-inheritance: diff --git a/doc/api/next_api_changes/deprecations/30070-OG.rst b/doc/api/next_api_changes/deprecations/30070-OG.rst new file mode 100644 index 000000000000..98786bcfa1d2 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30070-OG.rst @@ -0,0 +1,4 @@ +``BezierSegment.point_at_t`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +... is deprecated. Instead, it is possible to call the BezierSegment with an argument. diff --git a/doc/api/next_api_changes/deprecations/30088-AL.rst b/doc/api/next_api_changes/deprecations/30088-AL.rst new file mode 100644 index 000000000000..ae1338da7f85 --- /dev/null +++ b/doc/api/next_api_changes/deprecations/30088-AL.rst @@ -0,0 +1,4 @@ +*fontfile* parameter of ``PdfFile.createType1Descriptor`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ +This parameter is deprecated; all relevant pieces of information are now +directly extracted from the *t1font* argument. diff --git a/doc/api/next_api_changes/removals/30004-DS.rst b/doc/api/next_api_changes/removals/30004-DS.rst new file mode 100644 index 000000000000..f5fdf214366c --- /dev/null +++ b/doc/api/next_api_changes/removals/30004-DS.rst @@ -0,0 +1,10 @@ +``apply_theta_transforms`` option in ``PolarTransform`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Applying theta transforms in `~matplotlib.projections.polar.PolarTransform` and +`~matplotlib.projections.polar.InvertedPolarTransform` has been removed, and +the ``apply_theta_transforms`` keyword argument removed from both classes. + +If you need to retain the behaviour where theta values +are transformed, chain the ``PolarTransform`` with a `~matplotlib.transforms.Affine2D` +transform that performs the theta shift and/or sign shift. diff --git a/doc/api/next_api_changes/removals/xxxxxx-DS.rst b/doc/api/next_api_changes/removals/xxxxxx-DS.rst deleted file mode 100644 index 8ae7919afa31..000000000000 --- a/doc/api/next_api_changes/removals/xxxxxx-DS.rst +++ /dev/null @@ -1,4 +0,0 @@ -``backend_ps.get_bbox_header`` -~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ - -... is removed, as it is considered an internal helper. diff --git a/doc/api/pyplot_summary.rst b/doc/api/pyplot_summary.rst index cdd57bfe6276..c4a860fd2590 100644 --- a/doc/api/pyplot_summary.rst +++ b/doc/api/pyplot_summary.rst @@ -60,6 +60,7 @@ Basic bar barh bar_label + grouped_bar stem eventplot pie diff --git a/doc/users/next_whats_new/grouped_bar.rst b/doc/users/next_whats_new/grouped_bar.rst new file mode 100644 index 000000000000..af57c71b8a3a --- /dev/null +++ b/doc/users/next_whats_new/grouped_bar.rst @@ -0,0 +1,26 @@ +Grouped bar charts +------------------ + +The new method `~.Axes.grouped_bar()` simplifies the creation of grouped bar charts +significantly. It supports different input data types (lists of datasets, dicts of +datasets, data in 2D arrays, pandas DataFrames), and allows for easy customization +of placement via controllable distances between bars and between bar groups. + +Example: + +.. plot:: + :include-source: true + :alt: Diagram of a grouped bar chart of 3 datasets with 2 categories. + + import matplotlib.pyplot as plt + + categories = ['A', 'B'] + datasets = { + 'dataset 0': [1, 11], + 'dataset 1': [3, 13], + 'dataset 2': [5, 15], + } + + fig, ax = plt.subplots() + ax.grouped_bar(datasets, tick_labels=categories) + ax.legend() diff --git a/galleries/examples/axisartist/demo_axis_direction.py b/galleries/examples/axisartist/demo_axis_direction.py index 8c57b6c5a351..9540599c6a7b 100644 --- a/galleries/examples/axisartist/demo_axis_direction.py +++ b/galleries/examples/axisartist/demo_axis_direction.py @@ -22,7 +22,7 @@ def setup_axes(fig, rect): grid_helper = GridHelperCurveLinear( ( Affine2D().scale(np.pi/180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False) + PolarAxes.PolarTransform() ), extreme_finder=angle_helper.ExtremeFinderCycle( 20, 20, diff --git a/galleries/examples/axisartist/demo_curvelinear_grid.py b/galleries/examples/axisartist/demo_curvelinear_grid.py index 40853dee12cb..fb1fbdd011ce 100644 --- a/galleries/examples/axisartist/demo_curvelinear_grid.py +++ b/galleries/examples/axisartist/demo_curvelinear_grid.py @@ -54,8 +54,7 @@ def curvelinear_test2(fig): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180, 1) + PolarAxes.PolarTransform() # Polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes # (min, max of the coordinate within the view). diff --git a/galleries/examples/axisartist/demo_floating_axes.py b/galleries/examples/axisartist/demo_floating_axes.py index 632f6d237aa6..add03e266d3e 100644 --- a/galleries/examples/axisartist/demo_floating_axes.py +++ b/galleries/examples/axisartist/demo_floating_axes.py @@ -54,7 +54,7 @@ def setup_axes2(fig, rect): With custom locator and formatter. Note that the extreme values are swapped. """ - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) + tr = PolarAxes.PolarTransform() pi = np.pi angle_ticks = [(0, r"$0$"), @@ -99,8 +99,7 @@ def setup_axes3(fig, rect): # scale degree to radians tr_scale = Affine2D().scale(np.pi/180., 1.) - tr = tr_rotate + tr_scale + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = tr_rotate + tr_scale + PolarAxes.PolarTransform() grid_locator1 = angle_helper.LocatorHMS(4) tick_formatter1 = angle_helper.FormatterHMS() diff --git a/galleries/examples/axisartist/demo_floating_axis.py b/galleries/examples/axisartist/demo_floating_axis.py index 5296b682367b..0894bf8f4ce1 100644 --- a/galleries/examples/axisartist/demo_floating_axis.py +++ b/galleries/examples/axisartist/demo_floating_axis.py @@ -22,8 +22,7 @@ def curvelinear_test2(fig): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, diff --git a/galleries/examples/axisartist/simple_axis_pad.py b/galleries/examples/axisartist/simple_axis_pad.py index 95f30ce1ffbc..f40a1aa9f273 100644 --- a/galleries/examples/axisartist/simple_axis_pad.py +++ b/galleries/examples/axisartist/simple_axis_pad.py @@ -21,8 +21,7 @@ def setup_axes(fig, rect): """Polar projection, but in a rectangular box.""" # see demo_curvelinear_grid.py for details - tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform( - apply_theta_transforms=False) + tr = Affine2D().scale(np.pi/180., 1.) + PolarAxes.PolarTransform() extreme_finder = angle_helper.ExtremeFinderCycle(20, 20, lon_cycle=360, diff --git a/galleries/examples/lines_bars_and_markers/barchart.py b/galleries/examples/lines_bars_and_markers/barchart.py index f2157a89c0cd..dbb0f5bbbadd 100644 --- a/galleries/examples/lines_bars_and_markers/barchart.py +++ b/galleries/examples/lines_bars_and_markers/barchart.py @@ -10,7 +10,6 @@ # data from https://allisonhorst.github.io/palmerpenguins/ import matplotlib.pyplot as plt -import numpy as np species = ("Adelie", "Chinstrap", "Gentoo") penguin_means = { @@ -19,22 +18,15 @@ 'Flipper Length': (189.95, 195.82, 217.19), } -x = np.arange(len(species)) # the label locations -width = 0.25 # the width of the bars -multiplier = 0 - fig, ax = plt.subplots(layout='constrained') -for attribute, measurement in penguin_means.items(): - offset = width * multiplier - rects = ax.bar(x + offset, measurement, width, label=attribute) - ax.bar_label(rects, padding=3) - multiplier += 1 +res = ax.grouped_bar(penguin_means, tick_labels=species, group_spacing=1) +for container in res.bar_containers: + ax.bar_label(container, padding=3) -# Add some text for labels, title and custom x-axis tick labels, etc. +# Add some text for labels, title, etc. ax.set_ylabel('Length (mm)') ax.set_title('Penguin attributes by species') -ax.set_xticks(x + width, species) ax.legend(loc='upper left', ncols=3) ax.set_ylim(0, 250) diff --git a/lib/matplotlib/_afm.py b/lib/matplotlib/_afm.py index 558efe16392f..9094206c2d7c 100644 --- a/lib/matplotlib/_afm.py +++ b/lib/matplotlib/_afm.py @@ -1,5 +1,5 @@ """ -A python interface to Adobe Font Metrics Files. +A Python interface to Adobe Font Metrics Files. Although a number of other Python implementations exist, and may be more complete than this, it was decided not to go with them because they were @@ -16,19 +16,11 @@ >>> from pathlib import Path >>> afm_path = Path(mpl.get_data_path(), 'fonts', 'afm', 'ptmr8a.afm') >>> ->>> from matplotlib.afm import AFM +>>> from matplotlib._afm import AFM >>> with afm_path.open('rb') as fh: ... afm = AFM(fh) ->>> afm.string_width_height('What the heck?') -(6220.0, 694) >>> afm.get_fontname() 'Times-Roman' ->>> afm.get_kern_dist('A', 'f') -0 ->>> afm.get_kern_dist('A', 'y') --92.0 ->>> afm.get_bbox_char('!') -[130, -9, 238, 676] As in the Adobe Font Metrics File Format Specification, all dimensions are given in units of 1/1000 of the scale factor (point size) of the font @@ -87,20 +79,23 @@ def _to_bool(s): def _parse_header(fh): """ - Read the font metrics header (up to the char metrics) and returns - a dictionary mapping *key* to *val*. *val* will be converted to the - appropriate python type as necessary; e.g.: + Read the font metrics header (up to the char metrics). - * 'False'->False - * '0'->0 - * '-168 -218 1000 898'-> [-168, -218, 1000, 898] + Returns + ------- + dict + A dictionary mapping *key* to *val*. Dictionary keys are: - Dictionary keys are + StartFontMetrics, FontName, FullName, FamilyName, Weight, ItalicAngle, + IsFixedPitch, FontBBox, UnderlinePosition, UnderlineThickness, Version, + Notice, EncodingScheme, CapHeight, XHeight, Ascender, Descender, + StartCharMetrics - StartFontMetrics, FontName, FullName, FamilyName, Weight, - ItalicAngle, IsFixedPitch, FontBBox, UnderlinePosition, - UnderlineThickness, Version, Notice, EncodingScheme, CapHeight, - XHeight, Ascender, Descender, StartCharMetrics + *val* will be converted to the appropriate Python type as necessary, e.g.,: + + * 'False' -> False + * '0' -> 0 + * '-168 -218 1000 898' -> [-168, -218, 1000, 898] """ header_converters = { b'StartFontMetrics': _to_float, @@ -185,11 +180,9 @@ def _parse_header(fh): def _parse_char_metrics(fh): """ - Parse the given filehandle for character metrics information and return - the information as dicts. + Parse the given filehandle for character metrics information. - It is assumed that the file cursor is on the line behind - 'StartCharMetrics'. + It is assumed that the file cursor is on the line behind 'StartCharMetrics'. Returns ------- @@ -239,14 +232,15 @@ def _parse_char_metrics(fh): def _parse_kern_pairs(fh): """ - Return a kern pairs dictionary; keys are (*char1*, *char2*) tuples and - values are the kern pair value. For example, a kern pairs line like - ``KPX A y -50`` - - will be represented as:: + Return a kern pairs dictionary. - d[ ('A', 'y') ] = -50 + Returns + ------- + dict + Keys are (*char1*, *char2*) tuples and values are the kern pair value. For + example, a kern pairs line like ``KPX A y -50`` will be represented as:: + d['A', 'y'] = -50 """ line = next(fh) @@ -279,8 +273,7 @@ def _parse_kern_pairs(fh): def _parse_composites(fh): """ - Parse the given filehandle for composites information return them as a - dict. + Parse the given filehandle for composites information. It is assumed that the file cursor is on the line behind 'StartComposites'. @@ -363,36 +356,6 @@ def __init__(self, fh): self._metrics, self._metrics_by_name = _parse_char_metrics(fh) self._kern, self._composite = _parse_optional(fh) - def get_bbox_char(self, c, isord=False): - if not isord: - c = ord(c) - return self._metrics[c].bbox - - def string_width_height(self, s): - """ - Return the string width (including kerning) and string height - as a (*w*, *h*) tuple. - """ - if not len(s): - return 0, 0 - total_width = 0 - namelast = None - miny = 1e9 - maxy = 0 - for c in s: - if c == '\n': - continue - wx, name, bbox = self._metrics[ord(c)] - - total_width += wx + self._kern.get((namelast, name), 0) - l, b, w, h = bbox - miny = min(miny, b) - maxy = max(maxy, b + h) - - namelast = name - - return total_width, maxy - miny - def get_str_bbox_and_descent(self, s): """Return the string bounding box and the maximal descent.""" if not len(s): @@ -423,45 +386,29 @@ def get_str_bbox_and_descent(self, s): return left, miny, total_width, maxy - miny, -miny - def get_str_bbox(self, s): - """Return the string bounding box.""" - return self.get_str_bbox_and_descent(s)[:4] - - def get_name_char(self, c, isord=False): - """Get the name of the character, i.e., ';' is 'semicolon'.""" - if not isord: - c = ord(c) - return self._metrics[c].name + def get_glyph_name(self, glyph_ind): # For consistency with FT2Font. + """Get the name of the glyph, i.e., ord(';') is 'semicolon'.""" + return self._metrics[glyph_ind].name - def get_width_char(self, c, isord=False): + def get_char_index(self, c): # For consistency with FT2Font. """ - Get the width of the character from the character metric WX field. + Return the glyph index corresponding to a character code point. + + Note, for AFM fonts, we treat the glyph index the same as the codepoint. """ - if not isord: - c = ord(c) + return c + + def get_width_char(self, c): + """Get the width of the character code from the character metric WX field.""" return self._metrics[c].width def get_width_from_char_name(self, name): """Get the width of the character from a type1 character name.""" return self._metrics_by_name[name].width - def get_height_char(self, c, isord=False): - """Get the bounding box (ink) height of character *c* (space is 0).""" - if not isord: - c = ord(c) - return self._metrics[c].bbox[-1] - - def get_kern_dist(self, c1, c2): - """ - Return the kerning pair distance (possibly 0) for chars *c1* and *c2*. - """ - name1, name2 = self.get_name_char(c1), self.get_name_char(c2) - return self.get_kern_dist_from_name(name1, name2) - def get_kern_dist_from_name(self, name1, name2): """ - Return the kerning pair distance (possibly 0) for chars - *name1* and *name2*. + Return the kerning pair distance (possibly 0) for chars *name1* and *name2*. """ return self._kern.get((name1, name2), 0) @@ -493,7 +440,7 @@ def get_familyname(self): return re.sub(extras, '', name) @property - def family_name(self): + def family_name(self): # For consistency with FT2Font. """The font family name, e.g., 'Times'.""" return self.get_familyname() @@ -516,17 +463,3 @@ def get_xheight(self): def get_underline_thickness(self): """Return the underline thickness as float.""" return self._header[b'UnderlineThickness'] - - def get_horizontal_stem_width(self): - """ - Return the standard horizontal stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdHW', None) - - def get_vertical_stem_width(self): - """ - Return the standard vertical stem width as float, or *None* if - not specified in AFM file. - """ - return self._header.get(b'StdVW', None) diff --git a/lib/matplotlib/_mathtext.py b/lib/matplotlib/_mathtext.py index a528a65ca3cb..19ddbb6d0883 100644 --- a/lib/matplotlib/_mathtext.py +++ b/lib/matplotlib/_mathtext.py @@ -2524,10 +2524,10 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: if len(new_children): # remove last kern if (isinstance(new_children[-1], Kern) and - hasattr(new_children[-2], '_metrics')): + isinstance(new_children[-2], Char)): new_children = new_children[:-1] last_char = new_children[-1] - if hasattr(last_char, '_metrics'): + if isinstance(last_char, Char): last_char.width = last_char._metrics.advance # create new Hlist without kerning nucleus = Hlist(new_children, do_kern=False) @@ -2603,7 +2603,7 @@ def subsuper(self, s: str, loc: int, toks: ParseResults) -> T.Any: # Do we need to add a space after the nucleus? # To find out, check the flag set by operatorname - spaced_nucleus = [nucleus, x] + spaced_nucleus: list[Node] = [nucleus, x] if self._in_subscript_or_superscript: spaced_nucleus += [self._make_space(self._space_widths[r'\,'])] self._in_subscript_or_superscript = False diff --git a/lib/matplotlib/_type1font.py b/lib/matplotlib/_type1font.py index b3e08f52c035..032b6a42ea63 100644 --- a/lib/matplotlib/_type1font.py +++ b/lib/matplotlib/_type1font.py @@ -579,6 +579,16 @@ def _parse(self): extras = ('(?i)([ -](regular|plain|italic|oblique|(semi)?bold|' '(ultra)?light|extra|condensed))+$') prop['FamilyName'] = re.sub(extras, '', prop['FullName']) + + # Parse FontBBox + toks = [*_tokenize(prop['FontBBox'].encode('ascii'), True)] + if ([tok.kind for tok in toks] + != ['delimiter', 'number', 'number', 'number', 'number', 'delimiter'] + or toks[-1].raw != toks[0].opposite()): + raise RuntimeError( + f"FontBBox should be a size-4 array, was {prop['FontBBox']}") + prop['FontBBox'] = [tok.value() for tok in toks[1:-1]] + # Decrypt the encrypted parts ndiscard = prop.get('lenIV', 4) cs = prop['CharStrings'] diff --git a/lib/matplotlib/axes/_axes.py b/lib/matplotlib/axes/_axes.py index e480f8f29598..1ca2630e7166 100644 --- a/lib/matplotlib/axes/_axes.py +++ b/lib/matplotlib/axes/_axes.py @@ -64,6 +64,23 @@ def _make_axes_method(func): return func +class _GroupedBarReturn: + """ + A provisional result object for `.Axes.grouped_bar`. + + This is a placeholder for a future better return type. We try to build in + backward compatibility / migration possibilities. + + The only public interfaces are the ``bar_containers`` attribute and the + ``remove()`` method. + """ + def __init__(self, bar_containers): + self.bar_containers = bar_containers + + def remove(self): + [b.remove() for b in self.bars] + + @_docstring.interpd class Axes(_AxesBase): """ @@ -2414,6 +2431,7 @@ def bar(self, x, height, width=0.8, bottom=None, *, align="center", See Also -------- barh : Plot a horizontal bar plot. + grouped_bar : Plot multiple datasets as grouped bar plot. Notes ----- @@ -3014,6 +3032,310 @@ def broken_barh(self, xranges, yrange, **kwargs): return col + @_docstring.interpd + def grouped_bar(self, heights, *, positions=None, group_spacing=1.5, bar_spacing=0, + tick_labels=None, labels=None, orientation="vertical", colors=None, + **kwargs): + """ + Make a grouped bar plot. + + .. versionadded:: 3.11 + + This function is new in v3.11, and the API is still provisional. + We may still fine-tune some aspects based on user-feedback. + + Grouped bar charts visualize a collection of multiple categorical datasets. + A categorical dataset is a mapping *name* -> *value*. The values of the + dataset are represented by a sequence of bars of the same color. + In a grouped bar chart, the bars of all datasets are grouped together by + category. The category names are drawn as tick labels next to the bar group. + Each dataset has a distinct bar color, and can optionally get a label that + is used for the legend. + + Example: + + .. code-block:: python + + grouped_bar([dataset_1, dataset_2, dataset_3], + tick_labels=['A', 'B'], + labels=['dataset 1', 'dataset 2', 'dataset 3']) + + .. plot:: _embedded_plots/grouped_bar.py + + Parameters + ---------- + heights : list of array-like or dict of array-like or 2D array \ +or pandas.DataFrame + The heights for all x and groups. One of: + + - list of array-like: A list of datasets, each dataset must have + the same number of elements. + + .. code-block:: none + + # category_A, category_B + dataset_0 = [ds0_A, ds0_B] + dataset_1 = [ds1_A, ds1_B] + dataset_2 = [ds2_A, ds2_B] + + Example call:: + + grouped_bar([dataset_0, dataset_1, dataset_2]) + + - dict of array-like: A mapping from names to datasets. Each dataset + (dict value) must have the same number of elements. + + Example call: + + .. code-block:: python + + data_dict = {'ds0': dataset_0, 'ds1': dataset_1, 'ds2': dataset_2} + grouped_bar(data_dict) + + The names are used as *labels*, i.e. this is equivalent to + + .. code-block:: python + + grouped_bar(data_dict.values(), labels=data_dict.keys()) + + When using a dict input, you must not pass *labels* explicitly. + + - a 2D array: The rows are the categories, the columns are the different + datasets. + + .. code-block:: none + + dataset_0 dataset_1 dataset_2 + category_A ds0_a ds1_a ds2_a + category_B ds0_b ds1_b ds2_b + + Example call: + + .. code-block:: python + + categories = ["A", "B"] + dataset_labels = ["dataset_0", "dataset_1", "dataset_2"] + array = np.random.random((2, 3)) + grouped_bar(array, tick_labels=categories, labels=dataset_labels) + + - a `pandas.DataFrame`. + + The index is used for the categories, the columns are used for the + datasets. + + .. code-block:: python + + df = pd.DataFrame( + np.random.random((2, 3)), + index=["A", "B"], + columns=["dataset_0", "dataset_1", "dataset_2"] + ) + grouped_bar(df) + + i.e. this is equivalent to + + .. code-block:: + + grouped_bar(df.to_numpy(), tick_labels=df.index, labels=df.columns) + + Note that ``grouped_bar(df)`` produces a structurally equivalent plot like + ``df.plot.bar()``. + + positions : array-like, optional + The center positions of the bar groups. The values have to be equidistant. + If not given, a sequence of integer positions 0, 1, 2, ... is used. + + tick_labels : list of str, optional + The category labels, which are placed on ticks at the center *positions* + of the bar groups. If not set, the axis ticks (positions and labels) are + left unchanged. + + labels : list of str, optional + The labels of the datasets, i.e. the bars within one group. + These will show up in the legend. + + group_spacing : float, default: 1.5 + The space between two bar groups as multiples of bar width. + + The default value of 1.5 thus means that there's a gap of + 1.5 bar widths between bar groups. + + bar_spacing : float, default: 0 + The space between bars as multiples of bar width. + + orientation : {"vertical", "horizontal"}, default: "vertical" + The direction of the bars. + + colors : list of :mpltype:`color`, optional + A sequence of colors to be cycled through and used to color bars + of the different datasets. The sequence need not be exactly the + same length as the number of provided y, in which case the colors + will repeat from the beginning. + + If not specified, the colors from the Axes property cycle will be used. + + **kwargs : `.Rectangle` properties + + %(Rectangle:kwdoc)s + + Returns + ------- + _GroupedBarReturn + + A provisional result object. This will be refined in the future. + For now, the guaranteed API on the returned object is limited to + + - the attribute ``bar_containers``, which is a list of + `.BarContainer`, i.e. the results of the individual `~.Axes.bar` + calls for each dataset. + + - a ``remove()`` method, that remove all bars from the Axes. + See also `.Artist.remove()`. + + See Also + -------- + bar : A lower-level API for bar plots, with more degrees of freedom like + individual bar sizes and colors. + + Notes + ----- + For a better understanding, we compare the `~.Axes.grouped_bar` API with + those of `~.Axes.bar` and `~.Axes.boxplot`. + + **Comparison to bar()** + + `~.Axes.grouped_bar` intentionally deviates from the `~.Axes.bar` API in some + aspects. ``bar(x, y)`` is a lower-level API and places bars with height *y* + at explicit positions *x*. It also allows to specify individual bar widths + and colors. This kind of detailed control and flexibility is difficult to + manage and often not needed when plotting multiple datasets as a grouped bar + plot. Therefore, ``grouped_bar`` focusses on the abstraction of bar plots + as visualization of categorical data. + + The following examples may help to transfer from ``bar`` to + ``grouped_bar``. + + Positions are de-emphasized due to categories, and default to integer values. + If you have used ``range(N)`` as positions, you can leave that value out:: + + bar(range(N), heights) + grouped_bar([heights]) + + If needed, positions can be passed as keyword arguments:: + + bar(x, heights) + grouped_bar([heights], positions=x) + + To place category labels in `~.Axes.bar` you could use the argument + *tick_label* or use a list of category names as *x*. + `~.Axes.grouped_bar` expects them in the argument *tick_labels*:: + + bar(range(N), heights, tick_label=["A", "B"]) + bar(["A", "B"], heights) + grouped_bar([heights], tick_labels=["A", "B"]) + + Dataset labels, which are shown in the legend, are still passed via the + *label* parameter:: + + bar(..., label="dataset") + grouped_bar(..., label=["dataset"]) + + **Comparison to boxplot()** + + Both, `~.Axes.grouped_bar` and `~.Axes.boxplot` visualize categorical data + from multiple datasets. The basic API on *tick_labels* and *positions* + is the same, so that you can easily switch between plotting all + individual values as `~.Axes.grouped_bar` or the statistical distribution + per category as `~.Axes.boxplot`:: + + grouped_bar(values, positions=..., tick_labels=...) + boxplot(values, positions=..., tick_labels=...) + + """ + if cbook._is_pandas_dataframe(heights): + if labels is None: + labels = heights.columns.tolist() + if tick_labels is None: + tick_labels = heights.index.tolist() + heights = heights.to_numpy().T + elif hasattr(heights, 'keys'): # dict + if labels is not None: + raise ValueError( + "'labels' cannot be used if 'heights' are a mapping") + labels = heights.keys() + heights = list(heights.values()) + elif hasattr(heights, 'shape'): # numpy array + heights = heights.T + + num_datasets = len(heights) + num_groups = len(next(iter(heights))) # inferred from first dataset + + # validate that all datasets have the same length, i.e. num_groups + # - can be skipped if heights is an array + if not hasattr(heights, 'shape'): + for i, dataset in enumerate(heights): + if len(dataset) != num_groups: + raise ValueError( + "'heights' contains datasets with different number of " + f"elements. dataset 0 has {num_groups} elements but " + f"dataset {i} has {len(dataset)} elements." + ) + + if positions is None: + group_centers = np.arange(num_groups) + group_distance = 1 + else: + group_centers = np.asanyarray(positions) + if len(group_centers) > 1: + d = np.diff(group_centers) + if not np.allclose(d, d.mean()): + raise ValueError("'positions' must be equidistant") + group_distance = d[0] + else: + group_distance = 1 + + _api.check_in_list(["vertical", "horizontal"], orientation=orientation) + + if colors is None: + colors = itertools.cycle([None]) + else: + # Note: This is equivalent to the behavior in stackplot + # TODO: do we want to be more restrictive and check lengths? + colors = itertools.cycle(colors) + + bar_width = (group_distance / + (num_datasets + (num_datasets - 1) * bar_spacing + group_spacing)) + bar_spacing_abs = bar_spacing * bar_width + margin_abs = 0.5 * group_spacing * bar_width + + if labels is None: + labels = [None] * num_datasets + else: + assert len(labels) == num_datasets + + # place the bars, but only use numerical positions, categorical tick labels + # are handled separately below + bar_containers = [] + for i, (hs, label, color) in enumerate( + zip(heights, labels, colors)): + lefts = (group_centers - 0.5 * group_distance + margin_abs + + i * (bar_width + bar_spacing_abs)) + if orientation == "vertical": + bc = self.bar(lefts, hs, width=bar_width, align="edge", + label=label, color=color, **kwargs) + else: + bc = self.barh(lefts, hs, height=bar_width, align="edge", + label=label, color=color, **kwargs) + bar_containers.append(bc) + + if tick_labels is not None: + if orientation == "vertical": + self.xaxis.set_ticks(group_centers, labels=tick_labels) + else: + self.yaxis.set_ticks(group_centers, labels=tick_labels) + + return _GroupedBarReturn(bar_containers) + @_preprocess_data() def stem(self, *args, linefmt=None, markerfmt=None, basefmt=None, bottom=0, label=None, orientation='vertical'): diff --git a/lib/matplotlib/axes/_axes.pyi b/lib/matplotlib/axes/_axes.pyi index c3eb28d2f095..f606a65753f4 100644 --- a/lib/matplotlib/axes/_axes.pyi +++ b/lib/matplotlib/axes/_axes.pyi @@ -37,6 +37,12 @@ from typing import Any, Literal, overload import numpy as np from numpy.typing import ArrayLike from matplotlib.typing import ColorType, MarkerType, LineStyleType +import pandas as pd + + +class _GroupedBarReturn: + def __init__(self, bar_containers: list[BarContainer]) -> None: ... + def remove(self) -> None: ... class Axes(_AxesBase): def get_title(self, loc: Literal["left", "center", "right"] = ...) -> str: ... @@ -265,6 +271,19 @@ class Axes(_AxesBase): data=..., **kwargs ) -> PolyCollection: ... + def grouped_bar( + self, + heights : Sequence[ArrayLike] | dict[str, ArrayLike] | np.ndarray | pd.DataFrame, + *, + positions : ArrayLike | None = ..., + tick_labels : Sequence[str] | None = ..., + labels : Sequence[str] | None = ..., + group_spacing : float | None = ..., + bar_spacing : float | None = ..., + orientation: Literal["vertical", "horizontal"] = ..., + colors: Iterable[ColorType] | None = ..., + **kwargs + ) -> list[BarContainer]: ... def stem( self, *args: ArrayLike | str, diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index 73cf8bc19be2..073ca05bc172 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -1034,14 +1034,15 @@ def _embedTeXFont(self, fontinfo): fontinfo.effects.get('extend', 1.0)) fontdesc = self._type1Descriptors.get((fontinfo.fontfile, effects)) if fontdesc is None: - fontdesc = self.createType1Descriptor(t1font, fontinfo.fontfile) + fontdesc = self.createType1Descriptor(t1font) self._type1Descriptors[(fontinfo.fontfile, effects)] = fontdesc fontdict['FontDescriptor'] = fontdesc self.writeObject(fontdictObject, fontdict) return fontdictObject - def createType1Descriptor(self, t1font, fontfile): + @_api.delete_parameter("3.11", "fontfile") + def createType1Descriptor(self, t1font, fontfile=None): # Create and write the font descriptor and the font file # of a Type-1 font fontdescObject = self.reserveObject('font descriptor') @@ -1076,16 +1077,14 @@ def createType1Descriptor(self, t1font, fontfile): if 0: flags |= 1 << 18 - ft2font = get_font(fontfile) - descriptor = { 'Type': Name('FontDescriptor'), 'FontName': Name(t1font.prop['FontName']), 'Flags': flags, - 'FontBBox': ft2font.bbox, + 'FontBBox': t1font.prop['FontBBox'], 'ItalicAngle': italic_angle, - 'Ascent': ft2font.ascender, - 'Descent': ft2font.descender, + 'Ascent': t1font.prop['FontBBox'][3], + 'Descent': t1font.prop['FontBBox'][1], 'CapHeight': 1000, # TODO: find this out 'XHeight': 500, # TODO: this one too 'FontFile': fontfileObject, @@ -1093,7 +1092,7 @@ def createType1Descriptor(self, t1font, fontfile): 'StemV': 50, # TODO # (see also revision 3874; but not all TeX distros have AFM files!) # 'FontWeight': a number where 400 = Regular, 700 = Bold - } + } self.writeObject(fontdescObject, descriptor) diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index c1f4348016bb..f6b8455a15a7 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -24,7 +24,6 @@ import matplotlib as mpl from matplotlib import _api, cbook, _path, _text_helpers -from matplotlib._afm import AFM from matplotlib.backend_bases import ( _Backend, FigureCanvasBase, FigureManagerBase, RendererBase) from matplotlib.cbook import is_writable_file_like, file_requires_unicode @@ -787,7 +786,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): width = font.get_width_from_char_name(name) except KeyError: name = 'question' - width = font.get_width_char('?') + width = font.get_width_char(ord('?')) kern = font.get_kern_dist_from_name(last_name, name) last_name = name thisx += kern * scale @@ -835,9 +834,7 @@ def draw_mathtext(self, gc, x, y, s, prop, angle): lastfont = font.postscript_name, fontsize self._pswriter.write( f"/{font.postscript_name} {fontsize} selectfont\n") - glyph_name = ( - font.get_name_char(chr(num)) if isinstance(font, AFM) else - font.get_glyph_name(font.get_char_index(num))) + glyph_name = font.get_glyph_name(font.get_char_index(num)) self._pswriter.write( f"{ox:g} {oy:g} moveto\n" f"/{glyph_name} glyphshow\n") diff --git a/lib/matplotlib/bezier.py b/lib/matplotlib/bezier.py index 42a6b478d729..b9b67c9a72d6 100644 --- a/lib/matplotlib/bezier.py +++ b/lib/matplotlib/bezier.py @@ -190,6 +190,9 @@ class BezierSegment: """ A d-dimensional Bézier segment. + A BezierSegment can be called with an argument, either a scalar or an array-like + object, to evaluate the curve at that/those location(s). + Parameters ---------- control_points : (N, d) array @@ -223,6 +226,8 @@ def __call__(self, t): return (np.power.outer(1 - t, self._orders[::-1]) * np.power.outer(t, self._orders)) @ self._px + @_api.deprecated( + "3.11", alternative="Call the BezierSegment object with an argument.") def point_at_t(self, t): """ Evaluate the curve at a single point, returning a tuple of *d* floats. @@ -336,10 +341,9 @@ def split_bezier_intersecting_with_closedpath( """ bz = BezierSegment(bezier) - bezier_point_at_t = bz.point_at_t t0, t1 = find_bezier_t_intersecting_with_closedpath( - bezier_point_at_t, inside_closedpath, tolerance=tolerance) + lambda t: tuple(bz(t)), inside_closedpath, tolerance=tolerance) _left, _right = split_de_casteljau(bezier, (t0 + t1) / 2.) return _left, _right diff --git a/lib/matplotlib/cbook.py b/lib/matplotlib/cbook.py index 10048f1be782..3100cc4da81d 100644 --- a/lib/matplotlib/cbook.py +++ b/lib/matplotlib/cbook.py @@ -2331,42 +2331,56 @@ def _picklable_class_constructor(mixin_class, fmt, attr_name, base_class): def _is_torch_array(x): - """Check if 'x' is a PyTorch Tensor.""" + """Return whether *x* is a PyTorch Tensor.""" try: - # we're intentionally not attempting to import torch. If somebody - # has created a torch array, torch should already be in sys.modules - return isinstance(x, sys.modules['torch'].Tensor) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import torch. If somebody + # has created a torch array, torch should already be in sys.modules. + tp = sys.modules.get("torch").Tensor + except AttributeError: + return False # Module not imported or a nonstandard module with no Tensor attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_jax_array(x): - """Check if 'x' is a JAX Array.""" + """Return whether *x* is a JAX Array.""" try: - # we're intentionally not attempting to import jax. If somebody - # has created a jax array, jax should already be in sys.modules - return isinstance(x, sys.modules['jax'].Array) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False + # We're intentionally not attempting to import jax. If somebody + # has created a jax array, jax should already be in sys.modules. + tp = sys.modules.get("jax").Array + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) + + +def _is_pandas_dataframe(x): + """Check if *x* is a Pandas DataFrame.""" + try: + # We're intentionally not attempting to import Pandas. If somebody + # has created a Pandas DataFrame, Pandas should already be in sys.modules. + tp = sys.modules.get("pandas").DataFrame + except AttributeError: + return False # Module not imported or a nonstandard module with no Array attr. + return (isinstance(tp, type) # Just in case it's a very nonstandard module. + and isinstance(x, tp)) def _is_tensorflow_array(x): - """Check if 'x' is a TensorFlow Tensor or Variable.""" + """Return whether *x* is a TensorFlow Tensor or Variable.""" try: - # we're intentionally not attempting to import TensorFlow. If somebody - # has created a TensorFlow array, TensorFlow should already be in sys.modules - # we use `is_tensor` to not depend on the class structure of TensorFlow - # arrays, as `tf.Variables` are not instances of `tf.Tensor` - # (they both convert the same way) - return isinstance(x, sys.modules['tensorflow'].is_tensor(x)) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions + # We're intentionally not attempting to import TensorFlow. If somebody + # has created a TensorFlow array, TensorFlow should already be in + # sys.modules we use `is_tensor` to not depend on the class structure + # of TensorFlow arrays, as `tf.Variables` are not instances of + # `tf.Tensor` (they both convert the same way). + is_tensor = sys.modules.get("tensorflow").is_tensor + except AttributeError: return False + try: + return is_tensor(x) + except Exception: + return False # Just in case it's a very nonstandard module. def _unpack_to_numpy(x): @@ -2421,15 +2435,3 @@ def _auto_format_str(fmt, value): return fmt % (value,) except (TypeError, ValueError): return fmt.format(value) - - -def _is_pandas_dataframe(x): - """Check if 'x' is a Pandas DataFrame.""" - try: - # we're intentionally not attempting to import Pandas. If somebody - # has created a Pandas DataFrame, Pandas should already be in sys.modules - return isinstance(x, sys.modules['pandas'].DataFrame) - except Exception: # TypeError, KeyError, AttributeError, maybe others? - # we're attempting to access attributes on imported modules which - # may have arbitrary user code, so we deliberately catch all exceptions - return False diff --git a/lib/matplotlib/projections/polar.py b/lib/matplotlib/projections/polar.py index 71224fb3affe..948b3a6e704f 100644 --- a/lib/matplotlib/projections/polar.py +++ b/lib/matplotlib/projections/polar.py @@ -15,20 +15,6 @@ from matplotlib.spines import Spine -def _apply_theta_transforms_warn(): - _api.warn_deprecated( - "3.9", - message=( - "Passing `apply_theta_transforms=True` (the default) " - "is deprecated since Matplotlib %(since)s. " - "Support for this will be removed in Matplotlib in %(removal)s. " - "To prevent this warning, set `apply_theta_transforms=False`, " - "and make sure to shift theta values before being passed to " - "this transform." - ) - ) - - class PolarTransform(mtransforms.Transform): r""" The base polar transform. @@ -48,8 +34,7 @@ class PolarTransform(mtransforms.Transform): input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, *, - apply_theta_transforms=True, scale_transform=None): + def __init__(self, axis=None, use_rmin=True, *, scale_transform=None): """ Parameters ---------- @@ -64,15 +49,12 @@ def __init__(self, axis=None, use_rmin=True, *, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms self._scale_transform = scale_transform - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin" + ) def _get_rorigin(self): # Get lower r limit after being scaled by the radial scale transform @@ -82,11 +64,6 @@ def _get_rorigin(self): def transform_non_affine(self, values): # docstring inherited theta, r = np.transpose(values) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta *= self._axis.get_theta_direction() - theta += self._axis.get_theta_offset() if self._use_rmin and self._axis is not None: r = (r - self._get_rorigin()) * self._axis.get_rsign() r = np.where(r >= 0, r, np.nan) @@ -148,10 +125,7 @@ def transform_path_non_affine(self, path): def inverted(self): # docstring inherited - return PolarAxes.InvertedPolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.InvertedPolarTransform(self._axis, self._use_rmin) class PolarAffine(mtransforms.Affine2DBase): @@ -209,8 +183,7 @@ class InvertedPolarTransform(mtransforms.Transform): """ input_dims = output_dims = 2 - def __init__(self, axis=None, use_rmin=True, - *, apply_theta_transforms=True): + def __init__(self, axis=None, use_rmin=True): """ Parameters ---------- @@ -225,26 +198,16 @@ def __init__(self, axis=None, use_rmin=True, super().__init__() self._axis = axis self._use_rmin = use_rmin - self._apply_theta_transforms = apply_theta_transforms - if apply_theta_transforms: - _apply_theta_transforms_warn() __str__ = mtransforms._make_str_method( "_axis", - use_rmin="_use_rmin", - apply_theta_transforms="_apply_theta_transforms") + use_rmin="_use_rmin") def transform_non_affine(self, values): # docstring inherited x, y = values.T r = np.hypot(x, y) theta = (np.arctan2(y, x) + 2 * np.pi) % (2 * np.pi) - # PolarAxes does not use the theta transforms here, but apply them for - # backwards-compatibility if not being used by it. - if self._apply_theta_transforms and self._axis is not None: - theta -= self._axis.get_theta_offset() - theta *= self._axis.get_theta_direction() - theta %= 2 * np.pi if self._use_rmin and self._axis is not None: r += self._axis.get_rorigin() r *= self._axis.get_rsign() @@ -252,10 +215,7 @@ def transform_non_affine(self, values): def inverted(self): # docstring inherited - return PolarAxes.PolarTransform( - self._axis, self._use_rmin, - apply_theta_transforms=self._apply_theta_transforms - ) + return PolarAxes.PolarTransform(self._axis, self._use_rmin) class ThetaFormatter(mticker.Formatter): @@ -895,7 +855,6 @@ def _set_lim_and_transforms(self): # data. This one is aware of rmin self.transProjection = self.PolarTransform( self, - apply_theta_transforms=False, scale_transform=self.transScale ) # Add dependency on rorigin. diff --git a/lib/matplotlib/projections/polar.pyi b/lib/matplotlib/projections/polar.pyi index de1cbc293900..fc1d508579b5 100644 --- a/lib/matplotlib/projections/polar.pyi +++ b/lib/matplotlib/projections/polar.pyi @@ -18,7 +18,6 @@ class PolarTransform(mtransforms.Transform): axis: PolarAxes | None = ..., use_rmin: bool = ..., *, - apply_theta_transforms: bool = ..., scale_transform: mtransforms.Transform | None = ..., ) -> None: ... def inverted(self) -> InvertedPolarTransform: ... @@ -35,8 +34,6 @@ class InvertedPolarTransform(mtransforms.Transform): self, axis: PolarAxes | None = ..., use_rmin: bool = ..., - *, - apply_theta_transforms: bool = ..., ) -> None: ... def inverted(self) -> PolarTransform: ... diff --git a/lib/matplotlib/pyplot.py b/lib/matplotlib/pyplot.py index 78fc962d9c5c..cf5c9b4b739f 100644 --- a/lib/matplotlib/pyplot.py +++ b/lib/matplotlib/pyplot.py @@ -93,6 +93,7 @@ import PIL.Image from numpy.typing import ArrayLike + import pandas as pd import matplotlib.axes import matplotlib.artist @@ -3404,6 +3405,33 @@ def grid( gca().grid(visible=visible, which=which, axis=axis, **kwargs) +# Autogenerated by boilerplate.py. Do not edit as changes will be lost. +@_copy_docstring_and_deprecators(Axes.grouped_bar) +def grouped_bar( + heights: Sequence[ArrayLike] | dict[str, ArrayLike] | np.ndarray | pd.DataFrame, + *, + positions: ArrayLike | None = None, + group_spacing: float | None = 1.5, + bar_spacing: float | None = 0, + tick_labels: Sequence[str] | None = None, + labels: Sequence[str] | None = None, + orientation: Literal["vertical", "horizontal"] = "vertical", + colors: Iterable[ColorType] | None = None, + **kwargs, +) -> list[BarContainer]: + return gca().grouped_bar( + heights, + positions=positions, + group_spacing=group_spacing, + bar_spacing=bar_spacing, + tick_labels=tick_labels, + labels=labels, + orientation=orientation, + colors=colors, + **kwargs, + ) + + # Autogenerated by boilerplate.py. Do not edit as changes will be lost. @_copy_docstring_and_deprecators(Axes.hexbin) def hexbin( diff --git a/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png b/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png new file mode 100644 index 000000000000..19d676a6b662 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_axes/grouped_bar.png differ diff --git a/lib/matplotlib/tests/test_afm.py b/lib/matplotlib/tests/test_afm.py index e5c6a83937cd..80cf8ac60feb 100644 --- a/lib/matplotlib/tests/test_afm.py +++ b/lib/matplotlib/tests/test_afm.py @@ -135,3 +135,11 @@ def test_malformed_header(afm_data, caplog): _afm._parse_header(fh) assert len(caplog.records) == 1 + + +def test_afm_kerning(): + fn = fm.findfont("Helvetica", fontext="afm") + with open(fn, 'rb') as fh: + afm = _afm.AFM(fh) + assert afm.get_kern_dist_from_name('A', 'V') == -70.0 + assert afm.get_kern_dist_from_name('V', 'A') == -80.0 diff --git a/lib/matplotlib/tests/test_axes.py b/lib/matplotlib/tests/test_axes.py index 9ac63239d483..e7158845307d 100644 --- a/lib/matplotlib/tests/test_axes.py +++ b/lib/matplotlib/tests/test_axes.py @@ -2166,6 +2166,91 @@ def test_bar_datetime_start(): assert isinstance(ax.xaxis.get_major_formatter(), mdates.AutoDateFormatter) +@image_comparison(["grouped_bar.png"], style="mpl20") +def test_grouped_bar(): + data = { + 'data1': [1, 2, 3], + 'data2': [1.2, 2.2, 3.2], + 'data3': [1.4, 2.4, 3.4], + } + + fig, ax = plt.subplots() + ax.grouped_bar(data, tick_labels=['A', 'B', 'C'], + group_spacing=0.5, bar_spacing=0.1, + colors=['#1f77b4', '#58a1cf', '#abd0e6']) + ax.set_yticks([]) + + +@check_figures_equal(extensions=["png"]) +def test_grouped_bar_list_of_datasets(fig_test, fig_ref): + categories = ['A', 'B'] + data1 = [1, 1.2] + data2 = [2, 2.4] + data3 = [3, 3.6] + + ax = fig_test.subplots() + ax.grouped_bar([data1, data2, data3], tick_labels=categories, + labels=["data1", "data2", "data3"]) + ax.legend() + + ax = fig_ref.subplots() + label_pos = np.array([0, 1]) + bar_width = 1 / (3 + 1.5) # 3 bars + 1.5 group_spacing + data_shift = -1 * bar_width + np.array([0, bar_width, 2 * bar_width]) + ax.bar(label_pos + data_shift[0], data1, width=bar_width, label="data1") + ax.bar(label_pos + data_shift[1], data2, width=bar_width, label="data2") + ax.bar(label_pos + data_shift[2], data3, width=bar_width, label="data3") + ax.set_xticks(label_pos, categories) + ax.legend() + + +@check_figures_equal(extensions=["png"]) +def test_grouped_bar_dict_of_datasets(fig_test, fig_ref): + categories = ['A', 'B'] + data_dict = dict(data1=[1, 1.2], data2=[2, 2.4], data3=[3, 3.6]) + + ax = fig_test.subplots() + ax.grouped_bar(data_dict, tick_labels=categories) + ax.legend() + + ax = fig_ref.subplots() + ax.grouped_bar(data_dict.values(), tick_labels=categories, labels=data_dict.keys()) + ax.legend() + + +@check_figures_equal(extensions=["png"]) +def test_grouped_bar_array(fig_test, fig_ref): + categories = ['A', 'B'] + array = np.array([[1, 2, 3], [1.2, 2.4, 3.6]]) + labels = ['data1', 'data2', 'data3'] + + ax = fig_test.subplots() + ax.grouped_bar(array, tick_labels=categories, labels=labels) + ax.legend() + + ax = fig_ref.subplots() + list_of_datasets = [column for column in array.T] + ax.grouped_bar(list_of_datasets, tick_labels=categories, labels=labels) + ax.legend() + + +@check_figures_equal(extensions=["png"]) +def test_grouped_bar_dataframe(fig_test, fig_ref, pd): + categories = ['A', 'B'] + labels = ['data1', 'data2', 'data3'] + df = pd.DataFrame([[1, 2, 3], [1.2, 2.4, 3.6]], + index=categories, columns=labels) + + ax = fig_test.subplots() + ax.grouped_bar(df) + ax.legend() + + ax = fig_ref.subplots() + list_of_datasets = [df[col].to_numpy() for col in df.columns] + ax.grouped_bar(list_of_datasets, tick_labels=categories, labels=labels) + ax.legend() + + def test_boxplot_dates_pandas(pd): # smoke test for boxplot and dates in pandas data = np.random.rand(5, 2) diff --git a/lib/matplotlib/tests/test_cbook.py b/lib/matplotlib/tests/test_cbook.py index 7cb057cf4723..9b97d8e7e231 100644 --- a/lib/matplotlib/tests/test_cbook.py +++ b/lib/matplotlib/tests/test_cbook.py @@ -1000,6 +1000,7 @@ def __array__(self): torch_tensor = torch.Tensor(data) result = cbook._unpack_to_numpy(torch_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1028,6 +1029,7 @@ def __array__(self): jax_array = jax.Array(data) result = cbook._unpack_to_numpy(jax_array) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. @@ -1057,6 +1059,7 @@ def __array__(self): tf_tensor = tensorflow.Tensor(data) result = cbook._unpack_to_numpy(tf_tensor) + assert isinstance(result, np.ndarray) # compare results, do not check for identity: the latter would fail # if not mocked, and the implementation does not guarantee it # is the same Python object, just the same values. diff --git a/lib/matplotlib/tests/test_text.py b/lib/matplotlib/tests/test_text.py index 79a9e2d66c46..407d7a96be4d 100644 --- a/lib/matplotlib/tests/test_text.py +++ b/lib/matplotlib/tests/test_text.py @@ -208,13 +208,6 @@ def test_antialiasing(): mpl.rcParams['text.antialiased'] = False # Should not affect existing text. -def test_afm_kerning(): - fn = mpl.font_manager.findfont("Helvetica", fontext="afm") - with open(fn, 'rb') as fh: - afm = mpl._afm.AFM(fh) - assert afm.string_width_height('VAVAVAVAVAVA') == (7174.0, 718) - - @image_comparison(['text_contains.png']) def test_contains(): fig = plt.figure() diff --git a/lib/matplotlib/tests/test_transforms.py b/lib/matplotlib/tests/test_transforms.py index 99647e99bbde..b4db34db5a91 100644 --- a/lib/matplotlib/tests/test_transforms.py +++ b/lib/matplotlib/tests/test_transforms.py @@ -891,8 +891,7 @@ def test_str_transform(): Affine2D().scale(1.0))), PolarTransform( PolarAxes(0.125,0.1;0.775x0.8), - use_rmin=True, - apply_theta_transforms=False)), + use_rmin=True)), CompositeGenericTransform( CompositeGenericTransform( PolarAffine( diff --git a/lib/matplotlib/text.py b/lib/matplotlib/text.py index 3b0de58814d9..acde4fb179a2 100644 --- a/lib/matplotlib/text.py +++ b/lib/matplotlib/text.py @@ -1553,9 +1553,7 @@ def _get_xy_transform(self, renderer, coords): return self.axes.transData elif coords == 'polar': from matplotlib.projections import PolarAxes - tr = PolarAxes.PolarTransform(apply_theta_transforms=False) - trans = tr + self.axes.transData - return trans + return PolarAxes.PolarTransform() + self.axes.transData try: bbox_name, unit = coords.split() diff --git a/lib/mpl_toolkits/axes_grid1/parasite_axes.py b/lib/mpl_toolkits/axes_grid1/parasite_axes.py index f7bc2df6d7e0..fbc6e8141272 100644 --- a/lib/mpl_toolkits/axes_grid1/parasite_axes.py +++ b/lib/mpl_toolkits/axes_grid1/parasite_axes.py @@ -25,6 +25,9 @@ def clear(self): self._parent_axes.callbacks._connect_picklable( "ylim_changed", self._sync_lims) + def get_axes_locator(self): + return self._parent_axes.get_axes_locator() + def pick(self, mouseevent): # This most likely goes to Artist.pick (depending on axes_class given # to the factory), which only handles pick events registered on the diff --git a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py index 496ce74d72c0..26f0aaa37de0 100644 --- a/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py +++ b/lib/mpl_toolkits/axes_grid1/tests/test_axes_grid1.py @@ -9,7 +9,7 @@ from matplotlib.backend_bases import MouseEvent from matplotlib.colors import LogNorm from matplotlib.patches import Circle, Ellipse -from matplotlib.transforms import Bbox, TransformedBbox +from matplotlib.transforms import Affine2D, Bbox, TransformedBbox from matplotlib.testing.decorators import ( check_figures_equal, image_comparison, remove_ticks_and_titles) @@ -26,6 +26,7 @@ from mpl_toolkits.axes_grid1.axes_rgb import RGBAxes from mpl_toolkits.axes_grid1.inset_locator import ( zoomed_inset_axes, mark_inset, inset_axes, BboxConnectorPatch) +from mpl_toolkits.axes_grid1.parasite_axes import HostAxes import mpl_toolkits.axes_grid1.mpl_axes import pytest @@ -467,6 +468,26 @@ def test_gettightbbox(): [-17.7, -13.9, 7.2, 5.4]) +def test_gettightbbox_parasite(): + fig = plt.figure() + + y0 = 0.3 + horiz = [Size.Scaled(1.0)] + vert = [Size.Scaled(1.0)] + ax0_div = Divider(fig, [0.1, y0, 0.8, 0.2], horiz, vert) + ax1_div = Divider(fig, [0.1, 0.5, 0.8, 0.4], horiz, vert) + + ax0 = fig.add_subplot( + xticks=[], yticks=[], axes_locator=ax0_div.new_locator(nx=0, ny=0)) + ax1 = fig.add_subplot( + axes_class=HostAxes, axes_locator=ax1_div.new_locator(nx=0, ny=0)) + aux_ax = ax1.get_aux_axes(Affine2D()) + + fig.canvas.draw() + rdr = fig.canvas.get_renderer() + assert rdr.get_canvas_width_height()[1] * y0 / fig.dpi == fig.get_tightbbox(rdr).y0 + + @pytest.mark.parametrize("click_on", ["big", "small"]) @pytest.mark.parametrize("big_on_axes,small_on_axes", [ ("gca", "gca"), diff --git a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py index 362384221bdd..feb667af013e 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py +++ b/lib/mpl_toolkits/axisartist/tests/test_floating_axes.py @@ -26,7 +26,7 @@ def test_curvelinear3(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(0, 360, 10, 3), @@ -75,7 +75,7 @@ def test_curvelinear4(): fig = plt.figure(figsize=(5, 5)) tr = (mtransforms.Affine2D().scale(np.pi / 180, 1) + - mprojections.PolarAxes.PolarTransform(apply_theta_transforms=False)) + mprojections.PolarAxes.PolarTransform()) grid_helper = GridHelperCurveLinear( tr, extremes=(120, 30, 10, 0), diff --git a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py index 1b266044bdd0..7d6554782fe6 100644 --- a/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py +++ b/lib/mpl_toolkits/axisartist/tests/test_grid_helper_curvelinear.py @@ -82,8 +82,7 @@ def test_polar_box(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes @@ -145,8 +144,7 @@ def test_axis_direction(): # PolarAxes.PolarTransform takes radian. However, we want our coordinate # system in degree - tr = (Affine2D().scale(np.pi / 180., 1.) + - PolarAxes.PolarTransform(apply_theta_transforms=False)) + tr = Affine2D().scale(np.pi / 180., 1.) + PolarAxes.PolarTransform() # polar projection, which involves cycle, and also has limits in # its coordinates, needs a special method to find the extremes diff --git a/src/ft2font.cpp b/src/ft2font.cpp index b2c2c0fa9bd1..bdfa2873ca80 100644 --- a/src/ft2font.cpp +++ b/src/ft2font.cpp @@ -43,26 +43,6 @@ FT_Library _ft2Library; -// FreeType error codes; loaded as per fterror.h. -static char const* ft_error_string(FT_Error error) { -#undef __FTERRORS_H__ -#define FT_ERROR_START_LIST switch (error) { -#define FT_ERRORDEF( e, v, s ) case v: return s; -#define FT_ERROR_END_LIST default: return NULL; } -#include FT_ERRORS_H -} - -void throw_ft_error(std::string message, FT_Error error) { - char const* s = ft_error_string(error); - std::ostringstream os(""); - if (s) { - os << message << " (" << s << "; error code 0x" << std::hex << error << ")"; - } else { // Should not occur, but don't add another error from failed lookup. - os << message << " (error code 0x" << std::hex << error << ")"; - } - throw std::runtime_error(os.str()); -} - FT2Image::FT2Image(unsigned long width, unsigned long height) : m_buffer((unsigned char *)calloc(width * height, 1)), m_width(width), m_height(height) { @@ -237,26 +217,16 @@ FT2Font::FT2Font(FT_Open_Args &open_args, kerning_factor(0) { clear(); - - FT_Error error = FT_Open_Face(_ft2Library, &open_args, 0, &face); - if (error) { - throw_ft_error("Can not load face", error); - } - - // set a default fontsize 12 pt at 72dpi - error = FT_Set_Char_Size(face, 12 * 64, 0, 72 * (unsigned int)hinting_factor, 72); - if (error) { - FT_Done_Face(face); - throw_ft_error("Could not set the fontsize", error); - } - + FT_CHECK(FT_Open_Face, _ft2Library, &open_args, 0, &face); if (open_args.stream != nullptr) { face->face_flags |= FT_FACE_FLAG_EXTERNAL_STREAM; } - - FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; - FT_Set_Transform(face, &transform, nullptr); - + try { + set_size(12., 72.); // Set a default fontsize 12 pt at 72dpi. + } catch (...) { + FT_Done_Face(face); + throw; + } // Set fallbacks std::copy(fallback_list.begin(), fallback_list.end(), std::back_inserter(fallbacks)); } @@ -293,11 +263,9 @@ void FT2Font::clear() void FT2Font::set_size(double ptsize, double dpi) { - FT_Error error = FT_Set_Char_Size( + FT_CHECK( + FT_Set_Char_Size, face, (FT_F26Dot6)(ptsize * 64), 0, (FT_UInt)(dpi * hinting_factor), (FT_UInt)dpi); - if (error) { - throw_ft_error("Could not set the fontsize", error); - } FT_Matrix transform = { 65536 / hinting_factor, 0, 0, 65536 }; FT_Set_Transform(face, &transform, nullptr); @@ -311,17 +279,12 @@ void FT2Font::set_charmap(int i) if (i >= face->num_charmaps) { throw std::runtime_error("i exceeds the available number of char maps"); } - FT_CharMap charmap = face->charmaps[i]; - if (FT_Error error = FT_Set_Charmap(face, charmap)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Set_Charmap, face, face->charmaps[i]); } void FT2Font::select_charmap(unsigned long i) { - if (FT_Error error = FT_Select_Charmap(face, (FT_Encoding)i)) { - throw_ft_error("Could not set the charmap", error); - } + FT_CHECK(FT_Select_Charmap, face, (FT_Encoding)i); } int FT2Font::get_kerning(FT_UInt left, FT_UInt right, FT_Kerning_Mode mode, @@ -477,10 +440,10 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool if (!was_found) { ft_glyph_warn(charcode, glyph_seen_fonts); if (charcode_error) { - throw_ft_error("Could not load charcode", charcode_error); + THROW_FT_ERROR("charcode loading", charcode_error); } else if (glyph_error) { - throw_ft_error("Could not load charcode", glyph_error); + THROW_FT_ERROR("charcode loading", glyph_error); } } else if (ft_object_with_glyph->warn_if_used) { ft_glyph_warn(charcode, glyph_seen_fonts); @@ -494,13 +457,9 @@ void FT2Font::load_char(long charcode, FT_Int32 flags, FT2Font *&ft_object, bool glyph_seen_fonts.insert((face != nullptr)?face->family_name: nullptr); ft_glyph_warn((FT_ULong)charcode, glyph_seen_fonts); } - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load charcode", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } } @@ -600,13 +559,9 @@ void FT2Font::load_glyph(FT_UInt glyph_index, void FT2Font::load_glyph(FT_UInt glyph_index, FT_Int32 flags) { - if (FT_Error error = FT_Load_Glyph(face, glyph_index, flags)) { - throw_ft_error("Could not load glyph", error); - } + FT_CHECK(FT_Load_Glyph, face, glyph_index, flags); FT_Glyph thisGlyph; - if (FT_Error error = FT_Get_Glyph(face->glyph, &thisGlyph)) { - throw_ft_error("Could not get glyph", error); - } + FT_CHECK(FT_Get_Glyph, face->glyph, &thisGlyph); glyphs.push_back(thisGlyph); } @@ -651,13 +606,10 @@ void FT2Font::draw_glyphs_to_bitmap(bool antialiased) image = py::array_t{{height, width}}; std::memset(image.mutable_data(0), 0, image.nbytes()); - for (auto & glyph : glyphs) { - FT_Error error = FT_Glyph_To_Bitmap( + for (auto & glyph: glyphs) { + FT_CHECK( + FT_Glyph_To_Bitmap, &glyph, antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, nullptr, 1); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyph; // now, draw to our target surface (convert position) @@ -681,16 +633,12 @@ void FT2Font::draw_glyph_to_bitmap( throw std::runtime_error("glyph num is out of range"); } - FT_Error error = FT_Glyph_To_Bitmap( - &glyphs[glyphInd], - antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, - &sub_offset, // additional translation - 1 // destroy image - ); - if (error) { - throw_ft_error("Could not convert glyph to bitmap", error); - } - + FT_CHECK( + FT_Glyph_To_Bitmap, + &glyphs[glyphInd], + antialiased ? FT_RENDER_MODE_NORMAL : FT_RENDER_MODE_MONO, + &sub_offset, // additional translation + 1); // destroy image FT_BitmapGlyph bitmap = (FT_BitmapGlyph)glyphs[glyphInd]; draw_bitmap(im, &bitmap->bitmap, x + bitmap->left, y); @@ -715,9 +663,7 @@ void FT2Font::get_glyph_name(unsigned int glyph_number, std::string &buffer, throw std::runtime_error("Failed to convert glyph to standard name"); } } else { - if (FT_Error error = FT_Get_Glyph_Name(face, glyph_number, buffer.data(), buffer.size())) { - throw_ft_error("Could not get glyph names", error); - } + FT_CHECK(FT_Get_Glyph_Name, face, glyph_number, buffer.data(), buffer.size()); auto len = buffer.find('\0'); if (len != buffer.npos) { buffer.resize(len); diff --git a/src/ft2font.h b/src/ft2font.h index 209581d8f362..8db0239ed4fd 100644 --- a/src/ft2font.h +++ b/src/ft2font.h @@ -26,12 +26,37 @@ extern "C" { #include namespace py = pybind11; -/* - By definition, FT_FIXED as 2 16bit values stored in a single long. - */ +// By definition, FT_FIXED as 2 16bit values stored in a single long. #define FIXED_MAJOR(val) (signed short)((val & 0xffff0000) >> 16) #define FIXED_MINOR(val) (unsigned short)(val & 0xffff) +// Error handling (error codes are loaded as described in fterror.h). +inline char const* ft_error_string(FT_Error error) { +#undef __FTERRORS_H__ +#define FT_ERROR_START_LIST switch (error) { +#define FT_ERRORDEF( e, v, s ) case v: return s; +#define FT_ERROR_END_LIST default: return NULL; } +#include FT_ERRORS_H +} + +// No more than 16 hex digits + "0x" + null byte for a 64-bit int error. +#define THROW_FT_ERROR(name, err) { \ + std::string path{__FILE__}; \ + char buf[20] = {0}; \ + snprintf(buf, sizeof buf, "%#04x", err); \ + throw std::runtime_error{ \ + name " (" \ + + path.substr(path.find_last_of("/\\") + 1) \ + + " line " + std::to_string(__LINE__) + ") failed with error " \ + + std::string{buf} + ": " + std::string{ft_error_string(err)}}; \ +} (void)0 + +#define FT_CHECK(func, ...) { \ + if (auto const& error_ = func(__VA_ARGS__)) { \ + THROW_FT_ERROR(#func, error_); \ + } \ +} (void)0 + // the FreeType string rendered into a width, height buffer class FT2Image { diff --git a/tools/boilerplate.py b/tools/boilerplate.py index f018dfc887c8..11ec15ac1c44 100644 --- a/tools/boilerplate.py +++ b/tools/boilerplate.py @@ -238,6 +238,7 @@ def boilerplate_gen(): 'fill_between', 'fill_betweenx', 'grid', + 'grouped_bar', 'hexbin', 'hist', 'stairs',