Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 68 additions & 42 deletions lib/matplotlib/backends/backend_agg.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
"""

from contextlib import nullcontext
from math import radians, cos, sin
import math

import numpy as np
from PIL import features
Expand All @@ -32,7 +32,7 @@
from matplotlib.backend_bases import (
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
from matplotlib.font_manager import fontManager as _fontManager, get_font
from matplotlib.ft2font import LoadFlags
from matplotlib.ft2font import LoadFlags, RenderMode
from matplotlib.mathtext import MathTextParser
from matplotlib.path import Path
from matplotlib.transforms import Bbox, BboxBase
Expand Down Expand Up @@ -71,7 +71,7 @@ def __init__(self, width, height, dpi):
self._filter_renderers = []

self._update_methods()
self.mathtext_parser = MathTextParser('agg')
self.mathtext_parser = MathTextParser('path')

self.bbox = Bbox.from_bounds(0, 0, self.width, self.height)

Expand Down Expand Up @@ -173,48 +173,75 @@ def draw_path(self, gc, path, transform, rgbFace=None):

def draw_mathtext(self, gc, x, y, s, prop, angle):
"""Draw mathtext using :mod:`matplotlib.mathtext`."""
ox, oy, width, height, descent, font_image = \
self.mathtext_parser.parse(s, self.dpi, prop,
antialiased=gc.get_antialiased())

xd = descent * sin(radians(angle))
yd = descent * cos(radians(angle))
x = round(x + ox + xd)
y = round(y - oy + yd)
self._renderer.draw_text_image(font_image, x, y + 1, angle, gc)
# y is downwards.
parse = self.mathtext_parser.parse(
s, self.dpi, prop, antialiased=gc.get_antialiased())
cos = math.cos(math.radians(angle))
sin = math.sin(math.radians(angle))
for font, size, _char, glyph_index, dx, dy in parse.glyphs: # dy is upwards.
font.set_size(size, self.dpi)
hf = font._hinting_factor
font._set_transform(
[[round(0x10000 * cos / hf), round(0x10000 * -sin)],
[round(0x10000 * sin / hf), round(0x10000 * cos)]],
[round(0x40 * (x + dx * cos - dy * sin)),
# FreeType's y is upwards.
round(0x40 * (self.height - y + dx * sin + dy * cos))]
)
bitmap = font._render_glyph(
glyph_index, get_hinting_flag(),
RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO)
buffer = np.asarray(bitmap.buffer)
if not gc.get_antialiased():
buffer *= 0xff
# draw_text_image's y is downwards & the bitmap bottom side.
self._renderer.draw_text_image(
buffer,
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
0, gc)
rgba = gc.get_rgb()
if len(rgba) == 3 or gc.get_forced_alpha():
rgba = rgba[:3] + (gc.get_alpha(),)
gc1 = self.new_gc()
gc1.set_linewidth(0)
gc1.set_snap(gc.get_snap())
for dx, dy, w, h in parse.rects: # dy is upwards & the rect top side.
if gc1.get_snap() in [None, True]:
# Prevent thin bars from disappearing by growing symmetrically.
if w < 1:
dx -= (1 - w) / 2
w = 1
if h < 1:
dy -= (1 - h) / 2
h = 1
path = Path._create_closed(
[(dx, dy), (dx + w, dy), (dx + w, dy + h), (dx, dy + h)])
self._renderer.draw_path(
gc1, path,
mpl.transforms.Affine2D()
.rotate_deg(angle).translate(x, self.height - y),
rgba)
gc1.restore()

def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# docstring inherited
if ismath:
return self.draw_mathtext(gc, x, y, s, prop, angle)
font = self._prepare_font(prop)
# We pass '0' for angle here, since it will be rotated (in raster
# space) in the following call to draw_text_image).
font.set_text(s, 0, flags=get_hinting_flag(),
font.set_text(s, angle, flags=get_hinting_flag(),
features=mtext.get_fontfeatures() if mtext is not None else None,
language=mtext.get_language() if mtext is not None else None)
font.draw_glyphs_to_bitmap(
antialiased=gc.get_antialiased())
d = font.get_descent() / 64.0
# The descent needs to be adjusted for the angle.
xo, yo = font.get_bitmap_offset()
xo /= 64.0
yo /= 64.0

rad = radians(angle)
xd = d * sin(rad)
yd = d * cos(rad)
# Rotating the offset vector ensures text rotates around the anchor point.
# Without this, rotated text offsets incorrectly, causing a horizontal shift.
# Applying the 2D rotation matrix.
rotated_xo = xo * cos(rad) - yo * sin(rad)
rotated_yo = xo * sin(rad) + yo * cos(rad)
# Subtract rotated_yo to account for the inverted y-axis in computer graphics,
# compared to the mathematical convention.
x = round(x + rotated_xo + xd)
y = round(y - rotated_yo + yd)

self._renderer.draw_text_image(font, x, y + 1, angle, gc)
for bitmap in font._render_glyphs(
x, self.height - y,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this were trimmed to integers (int(x), int(self.height - y)) then for boxarrow_test_image, all but the first letter in each string remain the same as the old images. I guess keeping the fractional part (i.e., as its written now) gives us the proper sub-pixel positioning?

Copy link
Contributor Author

@anntzer anntzer Oct 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's the intent, at least. Looks like something similar to #30059 (comment) was happening as well?

RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO,
):
buffer = bitmap.buffer
if not gc.get_antialiased():
buffer *= 0xff
self._renderer.draw_text_image(
buffer,
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
0, gc)

def get_text_width_height_descent(self, s, prop, ismath):
# docstring inherited
Expand All @@ -224,9 +251,8 @@ def get_text_width_height_descent(self, s, prop, ismath):
return super().get_text_width_height_descent(s, prop, ismath)

if ismath:
ox, oy, width, height, descent, font_image = \
self.mathtext_parser.parse(s, self.dpi, prop)
return width, height, descent
parse = self.mathtext_parser.parse(s, self.dpi, prop)
return parse.width, parse.height, parse.depth

font = self._prepare_font(prop)
font.set_text(s, 0.0, flags=get_hinting_flag())
Expand All @@ -248,8 +274,8 @@ def draw_tex(self, gc, x, y, s, prop, angle, *, mtext=None):
Z = np.array(Z * 255.0, np.uint8)

w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX")
xd = d * sin(radians(angle))
yd = d * cos(radians(angle))
xd = d * math.sin(math.radians(angle))
yd = d * math.cos(math.radians(angle))
x = round(x + xd)
y = round(y + yd)
self._renderer.draw_text_image(Z, x, y, angle, gc)
Expand Down
6 changes: 5 additions & 1 deletion lib/matplotlib/font_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -1712,7 +1712,7 @@ def get_font(font_filepaths, hinting_factor=None):

hinting_factor = mpl._val_or_rc(hinting_factor, 'text.hinting_factor')

return _get_font(
font = _get_font(
# must be a tuple to be cached
paths,
hinting_factor,
Expand All @@ -1721,6 +1721,10 @@ def get_font(font_filepaths, hinting_factor=None):
thread_id=threading.get_ident(),
enable_last_resort=mpl.rcParams['font.enable_last_resort'],
)
# Ensure the transform is always consistent.
font._set_transform([[round(0x10000 / font._hinting_factor), 0], [0, 0x10000]],
[0, 0])
return font


def _load_fontmanager(*, try_read_cache=True):
Expand Down
8 changes: 8 additions & 0 deletions lib/matplotlib/ft2font.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ class LoadFlags(Flag):
TARGET_LCD = cast(int, ...)
TARGET_LCD_V = cast(int, ...)

class RenderMode(Enum):
NORMAL = cast(int, ...)
LIGHT = cast(int, ...)
MONO = cast(int, ...)
LCD = cast(int, ...)
LCD_V = cast(int, ...)
SDF = cast(int, ...)

class StyleFlags(Flag):
NORMAL = cast(int, ...)
ITALIC = cast(int, ...)
Expand Down
2 changes: 1 addition & 1 deletion lib/matplotlib/tests/test_axes.py
Original file line number Diff line number Diff line change
Expand Up @@ -6446,7 +6446,7 @@ def test_pie_linewidth_0():
plt.axis('equal')


@image_comparison(['pie_center_radius.png'], style='mpl20', tol=0.01)
@image_comparison(['pie_center_radius.png'], style='mpl20', tol=0.011)
def test_pie_center_radius():
# The slices will be ordered and plotted counter-clockwise.
labels = 'Frogs', 'Hogs', 'Dogs', 'Logs'
Expand Down
1 change: 1 addition & 0 deletions lib/matplotlib/text.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""
Classes for including text in a figure.
"""
Expand Down Expand Up @@ -871,6 +871,7 @@
gc.set_alpha(self.get_alpha())
gc.set_url(self._url)
gc.set_antialiased(self._antialiased)
gc.set_snap(self.get_snap())
self._set_gc_clip(gc)

angle = self.get_rotation()
Expand Down
11 changes: 11 additions & 0 deletions src/ft2font.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -283,6 +283,17 @@ void FT2Font::set_size(double ptsize, double dpi)
}
}

void FT2Font::_set_transform(
std::array<std::array<FT_Fixed, 2>, 2> matrix, std::array<FT_Fixed, 2> delta)
{
FT_Matrix m = {matrix[0][0], matrix[0][1], matrix[1][0], matrix[1][1]};
FT_Vector d = {delta[0], delta[1]};
FT_Set_Transform(face, &m, &d);
for (auto & fallback : fallbacks) {
fallback->_set_transform(matrix, delta);
}
}

void FT2Font::set_charmap(int i)
{
if (i >= face->num_charmaps) {
Expand Down
7 changes: 7 additions & 0 deletions src/ft2font.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@

extern "C" {
#include <ft2build.h>
#include FT_BITMAP_H
#include FT_FREETYPE_H
#include FT_GLYPH_H
#include FT_OUTLINE_H
Expand Down Expand Up @@ -111,6 +112,8 @@ class FT2Font
void close();
void clear();
void set_size(double ptsize, double dpi);
void _set_transform(
std::array<std::array<FT_Fixed, 2>, 2> matrix, std::array<FT_Fixed, 2> delta);
void set_charmap(int i);
void select_charmap(unsigned long i);
std::vector<raqm_glyph_t> layout(std::u32string_view text, FT_Int32 flags,
Expand Down Expand Up @@ -155,6 +158,10 @@ class FT2Font
{
return image;
}
std::vector<FT_Glyph> &get_glyphs()
{
return glyphs;
}
FT_Glyph const &get_last_glyph() const
{
return glyphs.back();
Expand Down
Loading
Loading