Skip to content

Commit b0a13fa

Browse files
anntzerQuLogic
authored andcommitted
Drop the FT2Font intermediate buffer
Directly render FT glyphs to the Agg buffer. In particular, this naturally provides, with no extra work, subpixel positioning of glyphs (which could also have been implemented in the old framework, but would have required careful tracking of subpixel offets). Note that all baseline images should be regenerated. The new APIs added to FT2Font are also up to bikeshedding (but they are all private).
1 parent 707f384 commit b0a13fa

File tree

6 files changed

+196
-43
lines changed

6 files changed

+196
-43
lines changed

lib/matplotlib/backends/backend_agg.py

Lines changed: 61 additions & 42 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@
2222
"""
2323

2424
from contextlib import nullcontext
25-
from math import radians, cos, sin
25+
import math
2626

2727
import numpy as np
2828
from PIL import features
@@ -32,7 +32,7 @@
3232
from matplotlib.backend_bases import (
3333
_Backend, FigureCanvasBase, FigureManagerBase, RendererBase)
3434
from matplotlib.font_manager import fontManager as _fontManager, get_font
35-
from matplotlib.ft2font import LoadFlags
35+
from matplotlib.ft2font import LoadFlags, RenderMode
3636
from matplotlib.mathtext import MathTextParser
3737
from matplotlib.path import Path
3838
from matplotlib.transforms import Bbox, BboxBase
@@ -71,7 +71,7 @@ def __init__(self, width, height, dpi):
7171
self._filter_renderers = []
7272

7373
self._update_methods()
74-
self.mathtext_parser = MathTextParser('agg')
74+
self.mathtext_parser = MathTextParser('path')
7575

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

@@ -173,48 +173,68 @@ def draw_path(self, gc, path, transform, rgbFace=None):
173173

174174
def draw_mathtext(self, gc, x, y, s, prop, angle):
175175
"""Draw mathtext using :mod:`matplotlib.mathtext`."""
176-
ox, oy, width, height, descent, font_image = \
177-
self.mathtext_parser.parse(s, self.dpi, prop,
178-
antialiased=gc.get_antialiased())
179-
180-
xd = descent * sin(radians(angle))
181-
yd = descent * cos(radians(angle))
182-
x = round(x + ox + xd)
183-
y = round(y - oy + yd)
184-
self._renderer.draw_text_image(font_image, x, y + 1, angle, gc)
176+
# y is downwards.
177+
parse = self.mathtext_parser.parse(
178+
s, self.dpi, prop, antialiased=gc.get_antialiased())
179+
cos = math.cos(math.radians(angle))
180+
sin = math.sin(math.radians(angle))
181+
for font, size, _char, glyph_index, dx, dy in parse.glyphs: # dy is upwards.
182+
font.set_size(size, self.dpi)
183+
hf = font._hinting_factor
184+
font._set_transform(
185+
[[round(0x10000 * cos / hf), round(0x10000 * -sin)],
186+
[round(0x10000 * sin / hf), round(0x10000 * cos)]],
187+
[round(0x40 * (x + dx * cos - dy * sin)),
188+
# FreeType's y is upwards.
189+
round(0x40 * (self.height - y + dx * sin + dy * cos))]
190+
)
191+
bitmap = font._render_glyph(
192+
glyph_index, get_hinting_flag(),
193+
RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO)
194+
buffer = np.asarray(bitmap.buffer)
195+
if not gc.get_antialiased():
196+
buffer *= 0xff
197+
# draw_text_image's y is downwards & the bitmap bottom side.
198+
self._renderer.draw_text_image(
199+
buffer,
200+
bitmap.left,
201+
int(self.height) - bitmap.top + bitmap.buffer.shape[0],
202+
0, gc)
203+
rgba = gc.get_rgb()
204+
if len(rgba) == 3 or gc.get_forced_alpha():
205+
rgba = rgba[:3] + (gc.get_alpha(),)
206+
gc1 = self.new_gc()
207+
gc1.set_linewidth(0)
208+
gc1.set_snap(gc.get_snap())
209+
for dx, dy, w, h in parse.rects: # dy is upwards & the rect top side.
210+
path = Path._create_closed(
211+
[(dx, dy), (dx + w, dy), (dx + w, dy + h), (dx, dy + h)])
212+
self._renderer.draw_path(
213+
gc1, path,
214+
mpl.transforms.Affine2D()
215+
.rotate_deg(angle).translate(x, self.height - y),
216+
rgba)
217+
gc1.restore()
185218

186219
def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
187220
# docstring inherited
188221
if ismath:
189222
return self.draw_mathtext(gc, x, y, s, prop, angle)
190223
font = self._prepare_font(prop)
191-
# We pass '0' for angle here, since it will be rotated (in raster
192-
# space) in the following call to draw_text_image).
193-
font.set_text(s, 0, flags=get_hinting_flag(),
224+
font.set_text(s, angle, flags=get_hinting_flag(),
194225
features=mtext.get_fontfeatures() if mtext is not None else None,
195226
language=mtext.get_language() if mtext is not None else None)
196-
font.draw_glyphs_to_bitmap(
197-
antialiased=gc.get_antialiased())
198-
d = font.get_descent() / 64.0
199-
# The descent needs to be adjusted for the angle.
200-
xo, yo = font.get_bitmap_offset()
201-
xo /= 64.0
202-
yo /= 64.0
203-
204-
rad = radians(angle)
205-
xd = d * sin(rad)
206-
yd = d * cos(rad)
207-
# Rotating the offset vector ensures text rotates around the anchor point.
208-
# Without this, rotated text offsets incorrectly, causing a horizontal shift.
209-
# Applying the 2D rotation matrix.
210-
rotated_xo = xo * cos(rad) - yo * sin(rad)
211-
rotated_yo = xo * sin(rad) + yo * cos(rad)
212-
# Subtract rotated_yo to account for the inverted y-axis in computer graphics,
213-
# compared to the mathematical convention.
214-
x = round(x + rotated_xo + xd)
215-
y = round(y - rotated_yo + yd)
216-
217-
self._renderer.draw_text_image(font, x, y + 1, angle, gc)
227+
for bitmap in font._render_glyphs(
228+
x, self.height - y,
229+
RenderMode.NORMAL if gc.get_antialiased() else RenderMode.MONO,
230+
):
231+
buffer = bitmap.buffer
232+
if not gc.get_antialiased():
233+
buffer *= 0xff
234+
self._renderer.draw_text_image(
235+
buffer,
236+
bitmap.left, int(self.height) - bitmap.top + buffer.shape[0],
237+
0, gc)
218238

219239
def get_text_width_height_descent(self, s, prop, ismath):
220240
# docstring inherited
@@ -224,9 +244,8 @@ def get_text_width_height_descent(self, s, prop, ismath):
224244
return super().get_text_width_height_descent(s, prop, ismath)
225245

226246
if ismath:
227-
ox, oy, width, height, descent, font_image = \
228-
self.mathtext_parser.parse(s, self.dpi, prop)
229-
return width, height, descent
247+
parse = self.mathtext_parser.parse(s, self.dpi, prop)
248+
return parse.width, parse.height, parse.depth
230249

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

250269
w, h, d = self.get_text_width_height_descent(s, prop, ismath="TeX")
251-
xd = d * sin(radians(angle))
252-
yd = d * cos(radians(angle))
270+
xd = d * math.sin(math.radians(angle))
271+
yd = d * math.cos(math.radians(angle))
253272
x = round(x + xd)
254273
y = round(y + yd)
255274
self._renderer.draw_text_image(Z, x, y, angle, gc)

lib/matplotlib/ft2font.pyi

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,14 @@ class LoadFlags(Flag):
7070
TARGET_LCD = cast(int, ...)
7171
TARGET_LCD_V = cast(int, ...)
7272

73+
class RenderMode(Enum):
74+
NORMAL = cast(int, ...)
75+
LIGHT = cast(int, ...)
76+
MONO = cast(int, ...)
77+
LCD = cast(int, ...)
78+
LCD_V = cast(int, ...)
79+
SDF = cast(int, ...)
80+
7381
class StyleFlags(Flag):
7482
NORMAL = cast(int, ...)
7583
ITALIC = cast(int, ...)

lib/matplotlib/text.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -871,6 +871,7 @@ def draw(self, renderer):
871871
gc.set_alpha(self.get_alpha())
872872
gc.set_url(self._url)
873873
gc.set_antialiased(self._antialiased)
874+
gc.set_snap(self.get_snap())
874875
self._set_gc_clip(gc)
875876

876877
angle = self.get_rotation()

src/ft2font.cpp

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -283,6 +283,17 @@ void FT2Font::set_size(double ptsize, double dpi)
283283
}
284284
}
285285

286+
void FT2Font::_set_transform(
287+
std::array<std::array<FT_Fixed, 2>, 2> matrix, std::array<FT_Fixed, 2> delta)
288+
{
289+
FT_Matrix m = {matrix[0][0], matrix[0][1], matrix[1][0], matrix[1][1]};
290+
FT_Vector d = {delta[0], delta[1]};
291+
FT_Set_Transform(face, &m, &d);
292+
for (auto & fallback : fallbacks) {
293+
fallback->_set_transform(matrix, delta);
294+
}
295+
}
296+
286297
void FT2Font::set_charmap(int i)
287298
{
288299
if (i >= face->num_charmaps) {

src/ft2font.h

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919

2020
extern "C" {
2121
#include <ft2build.h>
22+
#include FT_BITMAP_H
2223
#include FT_FREETYPE_H
2324
#include FT_GLYPH_H
2425
#include FT_OUTLINE_H
@@ -111,6 +112,8 @@ class FT2Font
111112
void close();
112113
void clear();
113114
void set_size(double ptsize, double dpi);
115+
void _set_transform(
116+
std::array<std::array<FT_Fixed, 2>, 2> matrix, std::array<FT_Fixed, 2> delta);
114117
void set_charmap(int i);
115118
void select_charmap(unsigned long i);
116119
std::vector<raqm_glyph_t> layout(std::u32string_view text, FT_Int32 flags,
@@ -156,6 +159,10 @@ class FT2Font
156159
{
157160
return image;
158161
}
162+
std::vector<FT_Glyph> &get_glyphs()
163+
{
164+
return glyphs;
165+
}
159166
FT_Glyph const &get_last_glyph() const
160167
{
161168
return glyphs.back();

src/ft2font_wrapper.cpp

Lines changed: 108 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,25 @@ P11X_DECLARE_ENUM(
204204
{"TARGET_LCD_V", LoadFlags::TARGET_LCD_V},
205205
);
206206

207+
const char *RenderMode__doc__ = R"""(
208+
Render modes.
209+
210+
For more information, see `the FreeType documentation
211+
<https://freetype.org/freetype2/docs/reference/ft2-glyph_retrieval.html#ft_render_mode>`_.
212+
213+
.. versionadded:: 3.10
214+
)""";
215+
216+
P11X_DECLARE_ENUM(
217+
"RenderMode", "Enum",
218+
{"NORMAL", FT_RENDER_MODE_NORMAL},
219+
{"LIGHT", FT_RENDER_MODE_LIGHT},
220+
{"MONO", FT_RENDER_MODE_MONO},
221+
{"LCD", FT_RENDER_MODE_LCD},
222+
{"LCD_V", FT_RENDER_MODE_LCD_V},
223+
{"SDF", FT_RENDER_MODE_SDF},
224+
);
225+
207226
const char *StyleFlags__doc__ = R"""(
208227
Flags returned by `FT2Font.style_flags`.
209228
@@ -265,6 +284,45 @@ PyFT2Image_draw_rect_filled(FT2Image *self,
265284
self->draw_rect_filled(x0, y0, x1, y1);
266285
}
267286

287+
/**********************************************************************
288+
* Positioned Bitmap; owns the FT_Bitmap!
289+
* */
290+
291+
struct PyPositionedBitmap {
292+
FT_Int left, top;
293+
bool owning;
294+
FT_Bitmap bitmap;
295+
296+
PyPositionedBitmap(FT_GlyphSlot slot) :
297+
left{slot->bitmap_left}, top{slot->bitmap_top}, owning{true}
298+
{
299+
FT_Bitmap_Init(&bitmap);
300+
FT_CHECK(FT_Bitmap_Convert, _ft2Library, &slot->bitmap, &bitmap, 1);
301+
}
302+
303+
PyPositionedBitmap(FT_BitmapGlyph bg) :
304+
left{bg->left}, top{bg->top}, owning{true}
305+
{
306+
FT_Bitmap_Init(&bitmap);
307+
FT_CHECK(FT_Bitmap_Convert, _ft2Library, &bg->bitmap, &bitmap, 1);
308+
}
309+
310+
PyPositionedBitmap(PyPositionedBitmap& other) = delete; // Non-copyable.
311+
312+
PyPositionedBitmap(PyPositionedBitmap&& other) :
313+
left{other.left}, top{other.top}, owning{true}, bitmap{other.bitmap}
314+
{
315+
other.owning = false; // Prevent double deletion.
316+
}
317+
318+
~PyPositionedBitmap()
319+
{
320+
if (owning) {
321+
FT_Bitmap_Done(_ft2Library, &bitmap);
322+
}
323+
}
324+
};
325+
268326
/**********************************************************************
269327
* Glyph
270328
* */
@@ -538,6 +596,19 @@ const char *PyFT2Font_set_size__doc__ = R"""(
538596
The DPI used for rendering the text.
539597
)""";
540598

599+
const char *PyFT2Font__set_transform__doc__ = R"""(
600+
Set the transform of the text.
601+
602+
This is a low-level function, where *matrix* and *delta* are directly in
603+
16.16 and 26.6 formats respectively. Refer to the FreeType docs of
604+
FT_Set_Transform for further description.
605+
606+
Parameters
607+
----------
608+
matrix : (2, 2) array of int
609+
delta : (2,) array of int
610+
)""";
611+
541612
const char *PyFT2Font_set_charmap__doc__ = R"""(
542613
Make the i-th charmap current.
543614
@@ -1493,6 +1564,7 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
14931564
p11x::bind_enums(m);
14941565
p11x::enums["Kerning"].attr("__doc__") = Kerning__doc__;
14951566
p11x::enums["LoadFlags"].attr("__doc__") = LoadFlags__doc__;
1567+
p11x::enums["RenderMode"].attr("__doc__") = RenderMode__doc__;
14961568
p11x::enums["FaceFlags"].attr("__doc__") = FaceFlags__doc__;
14971569
p11x::enums["StyleFlags"].attr("__doc__") = StyleFlags__doc__;
14981570

@@ -1519,6 +1591,17 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
15191591
return py::buffer_info(self.get_buffer(), shape, strides);
15201592
});
15211593

1594+
py::class_<PyPositionedBitmap>(m, "_PositionedBitmap", py::is_final())
1595+
.def_readonly("left", &PyPositionedBitmap::left)
1596+
.def_readonly("top", &PyPositionedBitmap::top)
1597+
.def_property_readonly(
1598+
"buffer", [](PyPositionedBitmap &self) -> py::array {
1599+
return {{self.bitmap.rows, self.bitmap.width},
1600+
{self.bitmap.pitch, 1},
1601+
self.bitmap.buffer};
1602+
})
1603+
;
1604+
15221605
py::class_<PyGlyph>(m, "Glyph", py::is_final(), PyGlyph__doc__)
15231606
.def(py::init<>([]() -> PyGlyph {
15241607
// Glyph is not useful from Python, so mark it as not constructible.
@@ -1553,6 +1636,8 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
15531636
.def("clear", &PyFT2Font::clear, PyFT2Font_clear__doc__)
15541637
.def("set_size", &PyFT2Font::set_size, "ptsize"_a, "dpi"_a,
15551638
PyFT2Font_set_size__doc__)
1639+
.def("_set_transform", &PyFT2Font::_set_transform, "matrix"_a, "delta"_a,
1640+
PyFT2Font__set_transform__doc__)
15561641
.def("set_charmap", &PyFT2Font::set_charmap, "i"_a,
15571642
PyFT2Font_set_charmap__doc__)
15581643
.def("select_charmap", &PyFT2Font::select_charmap, "i"_a,
@@ -1710,10 +1795,32 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
17101795
.def_property_readonly(
17111796
"fname", &PyFT2Font_fname,
17121797
"The original filename for this object.")
1798+
.def_property_readonly(
1799+
"_hinting_factor", &PyFT2Font::get_hinting_factor,
1800+
"The hinting factor.")
17131801

17141802
.def_buffer([](PyFT2Font &self) -> py::buffer_info {
17151803
return self.get_image().request();
1716-
});
1804+
})
1805+
1806+
.def("_render_glyph",
1807+
[](PyFT2Font *self, FT_UInt idx, LoadFlags flags, FT_Render_Mode render_mode) {
1808+
auto face = self->get_face();
1809+
FT_CHECK(FT_Load_Glyph, face, idx, static_cast<FT_Int32>(flags));
1810+
FT_CHECK(FT_Render_Glyph, face->glyph, render_mode);
1811+
return PyPositionedBitmap{face->glyph};
1812+
})
1813+
.def("_render_glyphs",
1814+
[](PyFT2Font *self, double x, double y, FT_Render_Mode render_mode) {
1815+
auto origin = FT_Vector{std::lround(x * 64), std::lround(y * 64)};
1816+
auto pbs = std::vector<PyPositionedBitmap>{};
1817+
for (auto &g: self->get_glyphs()) {
1818+
FT_CHECK(FT_Glyph_To_Bitmap, &g, render_mode, &origin, 1);
1819+
pbs.emplace_back(reinterpret_cast<FT_BitmapGlyph>(g));
1820+
}
1821+
return pbs;
1822+
})
1823+
;
17171824

17181825
m.attr("__freetype_version__") = version_string;
17191826
m.attr("__freetype_build_type__") = FREETYPE_BUILD_TYPE;

0 commit comments

Comments
 (0)