Skip to content

Commit 77b8271

Browse files
committed
Use libraqm for text in vector outputs
1 parent fb7cbf5 commit 77b8271

File tree

10 files changed

+58
-113
lines changed

10 files changed

+58
-113
lines changed

lib/matplotlib/_text_helpers.py

Lines changed: 12 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -4,29 +4,23 @@
44

55
from __future__ import annotations
66

7-
import dataclasses
7+
from collections.abc import Iterator
88

99
from . import _api
10-
from .ft2font import FT2Font, GlyphIndexType, Kerning, LoadFlags
10+
from .ft2font import FT2Font, CharacterCodeType, LayoutItem, LoadFlags
1111

1212

13-
@dataclasses.dataclass(frozen=True)
14-
class LayoutItem:
15-
ft_object: FT2Font
16-
char: str
17-
glyph_index: GlyphIndexType
18-
x: float
19-
prev_kern: float
20-
21-
22-
def warn_on_missing_glyph(codepoint, fontnames):
13+
def warn_on_missing_glyph(codepoint: CharacterCodeType, fontnames: str):
2314
_api.warn_external(
2415
f"Glyph {codepoint} "
2516
f"({chr(codepoint).encode('ascii', 'namereplace').decode('ascii')}) "
2617
f"missing from font(s) {fontnames}.")
2718

2819

29-
def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None):
20+
def layout(string: str, font: FT2Font, *,
21+
features: tuple[str] | None = None,
22+
language: str | tuple[tuple[str, int, int], ...] | None = None
23+
) -> Iterator[LayoutItem]:
3024
"""
3125
Render *string* with *font*.
3226
@@ -41,8 +35,6 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N
4135
The font.
4236
features : tuple of str, optional
4337
The font features to apply to the text.
44-
kern_mode : Kerning
45-
A FreeType kerning mode.
4638
language : str, optional
4739
The language of the text in a format accepted by libraqm, namely `a BCP47
4840
language code <https://www.w3.org/International/articles/language-tags/>`_.
@@ -51,20 +43,8 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N
5143
------
5244
LayoutItem
5345
"""
54-
x = 0
55-
prev_glyph_index = None
56-
char_to_font = font._get_fontmap(string) # TODO: Pass in features and language.
57-
base_font = font
58-
for char in string:
59-
# This has done the fallback logic
60-
font = char_to_font.get(char, base_font)
61-
glyph_index = font.get_char_index(ord(char))
62-
kern = (
63-
base_font.get_kerning(prev_glyph_index, glyph_index, kern_mode) / 64
64-
if prev_glyph_index is not None else 0.
65-
)
66-
x += kern
67-
glyph = font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING)
68-
yield LayoutItem(font, char, glyph_index, x, kern)
69-
x += glyph.linearHoriAdvance / 65536
70-
prev_glyph_index = glyph_index
46+
for raqm_item in font._layout(string, LoadFlags.NO_HINTING,
47+
features=features, language=language):
48+
raqm_item.ft_object.load_glyph(raqm_item.glyph_index,
49+
flags=LoadFlags.NO_HINTING)
50+
yield raqm_item

lib/matplotlib/backends/_backend_pdf_ps.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -220,8 +220,8 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]:
220220
and the character codes will be returned from the string unchanged.
221221
"""
222222
return [
223-
self.track_glyph(f, ord(c), f.get_char_index(ord(c)))
224-
for c, f in font._get_fontmap(s).items()
223+
self.track_glyph(raqm_item.ft_object, raqm_item.char, raqm_item.glyph_index)
224+
for raqm_item in font._layout(s, ft2font.LoadFlags.NO_HINTING)
225225
]
226226

227227
def track_glyph(self, font: FT2Font, chars: str | CharacterCodeType,

lib/matplotlib/backends/backend_pdf.py

Lines changed: 15 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@
3434
from matplotlib.figure import Figure
3535
from matplotlib.font_manager import get_font, fontManager as _fontManager
3636
from matplotlib._afm import AFM
37-
from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags
37+
from matplotlib.ft2font import FT2Font, FaceFlags, LoadFlags, StyleFlags
3838
from matplotlib.transforms import Affine2D, BboxBase
3939
from matplotlib.path import Path
4040
from matplotlib.dates import UTC
@@ -469,6 +469,7 @@ class Op(Enum):
469469
textpos = b'Td'
470470
selectfont = b'Tf'
471471
textmatrix = b'Tm'
472+
textrise = b'Ts'
472473
show = b'Tj'
473474
showkern = b'TJ'
474475
setlinewidth = b'w'
@@ -2285,6 +2286,8 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
22852286
# If fonttype is neither 3 nor 42, emit the whole string at once
22862287
# without manual kerning.
22872288
if fonttype not in [3, 42]:
2289+
if not mpl.rcParams['pdf.use14corefonts']:
2290+
self.file._character_tracker.track(font, s)
22882291
self.file.output(Op.begin_text,
22892292
self.file.fontName(prop), fontsize, Op.selectfont)
22902293
self._setup_textpos(x, y, angle)
@@ -2305,13 +2308,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
23052308
# kerning between chunks.
23062309
else:
23072310
def output_singlebyte_chunk(kerns_or_chars):
2311+
if not kerns_or_chars:
2312+
return
23082313
self.file.output(
23092314
# See pdf spec "Text space details" for the 1000/fontsize
23102315
# (aka. 1000/T_fs) factor.
23112316
[(-1000 * next(group) / fontsize) if tp == float # a kern
23122317
else self._encode_glyphs(group, fonttype)
23132318
for tp, group in itertools.groupby(kerns_or_chars, type)],
23142319
Op.showkern)
2320+
kerns_or_chars.clear()
23152321
# Do the rotation and global translation as a single matrix
23162322
# concatenation up front
23172323
self.file.output(Op.gsave)
@@ -2326,24 +2332,26 @@ def output_singlebyte_chunk(kerns_or_chars):
23262332
# Emit all the characters in a BT/ET group.
23272333
self.file.output(Op.begin_text)
23282334
for item in _text_helpers.layout(s, font, features=features,
2329-
kern_mode=Kerning.UNFITTED,
23302335
language=language):
23312336
subset, charcode = self.file._character_tracker.track_glyph(
23322337
item.ft_object, item.char, item.glyph_index)
23332338
if (item.ft_object, subset) != prev_font:
2334-
if singlebyte_chunk:
2335-
output_singlebyte_chunk(singlebyte_chunk)
2339+
output_singlebyte_chunk(singlebyte_chunk)
23362340
ft_name = self.file.fontName(item.ft_object.fname, subset)
23372341
self.file.output(ft_name, fontsize, Op.selectfont)
23382342
self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0)
2339-
singlebyte_chunk = []
23402343
prev_font = (item.ft_object, subset)
23412344
prev_start_x = item.x
2345+
if item.y:
2346+
output_singlebyte_chunk(singlebyte_chunk)
2347+
self.file.output(item.y, Op.textrise)
23422348
if item.prev_kern:
23432349
singlebyte_chunk.append(item.prev_kern)
23442350
singlebyte_chunk.append(charcode)
2345-
if singlebyte_chunk:
2346-
output_singlebyte_chunk(singlebyte_chunk)
2351+
if item.y:
2352+
output_singlebyte_chunk(singlebyte_chunk)
2353+
self.file.output(0, Op.textrise)
2354+
output_singlebyte_chunk(singlebyte_chunk)
23472355
self.file.output(Op.end_text)
23482356
self.file.output(Op.grestore)
23492357

lib/matplotlib/backends/backend_ps.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -776,7 +776,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
776776
if ismath:
777777
return self.draw_mathtext(gc, x, y, s, prop, angle)
778778

779-
stream = [] # list of (ps_name, x, char_name)
779+
stream = [] # list of (ps_name, x, y, char_name)
780780

781781
if mpl.rcParams['ps.useafm']:
782782
font = self._get_font_afm(prop)
@@ -794,7 +794,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
794794
kern = font.get_kern_dist_from_name(last_name, name)
795795
last_name = name
796796
thisx += kern * scale
797-
stream.append((ps_name, thisx, name))
797+
stream.append((ps_name, thisx, 0, name))
798798
thisx += width * scale
799799

800800
else:
@@ -814,14 +814,13 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
814814
ps_name = (item.ft_object.postscript_name
815815
.encode("ascii", "replace").decode("ascii"))
816816
glyph_name = item.ft_object.get_glyph_name(item.glyph_index)
817-
stream.append((f'{ps_name}-{subset}', item.x, glyph_name))
817+
stream.append((f'{ps_name}-{subset}', item.x, item.y, glyph_name))
818818
self.set_color(*gc.get_rgb())
819819

820-
for ps_name, group in itertools. \
821-
groupby(stream, lambda entry: entry[0]):
820+
for ps_name, group in itertools.groupby(stream, lambda entry: entry[0]):
822821
self.set_font(ps_name, prop.get_size_in_points(), False)
823-
thetext = "\n".join(f"{x:g} 0 m /{name:s} glyphshow"
824-
for _, x, name in group)
822+
thetext = "\n".join(f"{x:g} {y:g} m /{name:s} glyphshow"
823+
for _, x, y, name in group)
825824
self._pswriter.write(f"""\
826825
gsave
827826
{self._get_clip_cmd(gc)}

lib/matplotlib/backends/backend_svg.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1048,6 +1048,11 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
10481048
text2path = self._text2path
10491049
color = rgb2hex(gc.get_rgb())
10501050
fontsize = prop.get_size_in_points()
1051+
if mtext is not None:
1052+
features = mtext.get_fontfeatures()
1053+
language = mtext.get_language()
1054+
else:
1055+
features = language = None
10511056

10521057
style = {}
10531058
if color != '#000000':
@@ -1068,7 +1073,8 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
10681073
if not ismath:
10691074
font = text2path._get_font(prop)
10701075
glyph_info, glyph_map_new, rects = text2path.get_glyphs_with_font(
1071-
font, s, glyph_map=glyph_map, return_new_glyphs_only=True)
1076+
font, s, features=features, language=language,
1077+
glyph_map=glyph_map, return_new_glyphs_only=True)
10721078
self._update_glyph_map_defs(glyph_map_new)
10731079

10741080
for glyph_repr, xposition, yposition, scale in glyph_info:

lib/matplotlib/ft2font.pyi

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -219,7 +219,6 @@ class FT2Font(Buffer):
219219
) -> None: ...
220220
if sys.version_info[:2] >= (3, 12):
221221
def __buffer__(self, flags: int) -> memoryview: ...
222-
def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ...
223222
def _layout(
224223
self,
225224
text: str,

lib/matplotlib/tests/test_ft2font.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -972,7 +972,7 @@ def test_fallback_last_resort(recwarn):
972972
"Glyph 128579 (\\N{UPSIDE-DOWN FACE}) missing from font(s)")
973973

974974

975-
def test__get_fontmap():
975+
def test__layout():
976976
fonts, test_str = _gen_multi_font_text()
977977
# Add some glyphs that don't exist in either font to check the Last Resort fallback.
978978
missing_glyphs = '\n几个汉字'
@@ -981,11 +981,11 @@ def test__get_fontmap():
981981
ft = fm.get_font(
982982
fm.fontManager._find_fonts_by_props(fm.FontProperties(family=fonts))
983983
)
984-
fontmap = ft._get_fontmap(test_str)
985-
for char, font in fontmap.items():
986-
if char in missing_glyphs:
987-
assert Path(font.fname).name == 'LastResortHE-Regular.ttf'
988-
elif ord(char) > 127:
989-
assert Path(font.fname).name == 'DejaVuSans.ttf'
990-
else:
991-
assert Path(font.fname).name == 'cmr10.ttf'
984+
for substr in test_str.split('\n'):
985+
for item in ft._layout(substr, ft2font.LoadFlags.DEFAULT):
986+
if item.char in missing_glyphs:
987+
assert Path(item.ft_object.fname).name == 'LastResortHE-Regular.ttf'
988+
elif ord(item.char) > 127:
989+
assert Path(item.ft_object.fname).name == 'DejaVuSans.ttf'
990+
else:
991+
assert Path(item.ft_object.fname).name == 'cmr10.ttf'

lib/matplotlib/tests/test_text.py

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ def find_matplotlib_font(**kw):
113113
ax.set_yticks([])
114114

115115

116-
@image_comparison(['complex.png'])
116+
@image_comparison(['complex'], extensions=['png', 'pdf', 'svg', 'eps'])
117117
def test_complex_shaping():
118118
# Raqm is Arabic for writing; note that because Arabic is RTL, the characters here
119119
# may seem to be in a different order than expected, but libraqm will order them
@@ -1204,7 +1204,8 @@ def test_ytick_rotation_mode():
12041204
plt.subplots_adjust(left=0.4, right=0.6, top=.99, bottom=.01)
12051205

12061206

1207-
@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20')
1207+
@image_comparison(['features'], remove_text=False, style='mpl20',
1208+
extensions=['png', 'pdf', 'svg', 'eps'])
12081209
def test_text_features():
12091210
fig = plt.figure(figsize=(5, 1.5))
12101211
t = fig.text(1, 0.7, 'Default: fi ffi fl st',
@@ -1234,7 +1235,8 @@ def test_text_language_invalid(input, match):
12341235
Text(0, 0, 'foo', language=input)
12351236

12361237

1237-
@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20')
1238+
@image_comparison(['language'], remove_text=False, style='mpl20',
1239+
extensions=['png', 'pdf', 'svg', 'eps'])
12381240
def test_text_language():
12391241
fig = plt.figure(figsize=(5, 3))
12401242

lib/matplotlib/textpath.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,15 +147,16 @@ def get_glyphs_with_font(self, font, s, glyph_map=None,
147147
glyph_map_new = glyph_map
148148

149149
xpositions = []
150+
ypositions = []
150151
glyph_reprs = []
151152
for item in _text_helpers.layout(s, font, features=features, language=language):
152153
glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index)
153154
glyph_reprs.append(glyph_repr)
154155
xpositions.append(item.x)
156+
ypositions.append(item.y)
155157
if glyph_repr not in glyph_map:
156158
glyph_map_new[glyph_repr] = item.ft_object.get_path()
157159

158-
ypositions = [0] * len(xpositions)
159160
sizes = [1.] * len(xpositions)
160161

161162
rects = []

src/ft2font_wrapper.cpp

Lines changed: 0 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -623,54 +623,6 @@ PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right,
623623
return self->get_kerning(left, right, mode);
624624
}
625625

626-
const char *PyFT2Font_get_fontmap__doc__ = R"""(
627-
Get a mapping between characters and the font that includes them.
628-
629-
.. warning::
630-
This API uses the fallback list and is both private and provisional: do not use
631-
it directly.
632-
633-
Parameters
634-
----------
635-
text : str
636-
The characters for which to find fonts.
637-
638-
Returns
639-
-------
640-
dict[str, FT2Font]
641-
A dictionary mapping unicode characters to `.FT2Font` objects.
642-
)""";
643-
644-
static py::dict
645-
PyFT2Font_get_fontmap(PyFT2Font *self, std::u32string text)
646-
{
647-
std::set<FT_ULong> codepoints;
648-
649-
py::dict char_to_font;
650-
for (auto code : text) {
651-
if (!codepoints.insert(code).second) {
652-
continue;
653-
}
654-
655-
py::object target_font;
656-
int index;
657-
if (self->get_char_fallback_index(code, index)) {
658-
if (index >= 0) {
659-
target_font = self->fallbacks[index];
660-
} else {
661-
target_font = py::cast(self);
662-
}
663-
} else {
664-
// TODO Handle recursion!
665-
target_font = py::cast(self);
666-
}
667-
668-
auto key = py::cast(std::u32string(1, code));
669-
char_to_font[key] = target_font;
670-
}
671-
return char_to_font;
672-
}
673-
674626
const char *PyFT2Font_set_text__doc__ = R"""(
675627
Set the text *string* and *angle*.
676628
@@ -1705,8 +1657,6 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
17051657
"string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(),
17061658
"features"_a=nullptr, "language"_a=nullptr,
17071659
PyFT2Font_set_text__doc__)
1708-
.def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a,
1709-
PyFT2Font_get_fontmap__doc__)
17101660
.def("get_num_glyphs", &PyFT2Font::get_num_glyphs,
17111661
PyFT2Font_get_num_glyphs__doc__)
17121662
.def("load_char", &PyFT2Font_load_char,

0 commit comments

Comments
 (0)