Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
Prev Previous commit
Use libraqm for text in vector outputs
  • Loading branch information
QuLogic committed Oct 29, 2025
commit bd17cd43dd71b879928828c899cb2f9087ce745e
44 changes: 12 additions & 32 deletions lib/matplotlib/_text_helpers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,29 +4,23 @@

from __future__ import annotations

import dataclasses
from collections.abc import Iterator

from . import _api
from .ft2font import FT2Font, GlyphIndexType, Kerning, LoadFlags
from .ft2font import FT2Font, CharacterCodeType, LayoutItem, LoadFlags


@dataclasses.dataclass(frozen=True)
class LayoutItem:
ft_object: FT2Font
char: str
glyph_index: GlyphIndexType
x: float
prev_kern: float


def warn_on_missing_glyph(codepoint, fontnames):
def warn_on_missing_glyph(codepoint: CharacterCodeType, fontnames: str):
_api.warn_external(
f"Glyph {codepoint} "
f"({chr(codepoint).encode('ascii', 'namereplace').decode('ascii')}) "
f"missing from font(s) {fontnames}.")


def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=None):
def layout(string: str, font: FT2Font, *,
features: tuple[str] | None = None,
language: str | tuple[tuple[str, int, int], ...] | None = None
) -> Iterator[LayoutItem]:
"""
Render *string* with *font*.

Expand All @@ -41,8 +35,6 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N
The font.
features : tuple of str, optional
The font features to apply to the text.
kern_mode : Kerning
A FreeType kerning mode.
language : str, optional
The language of the text in a format accepted by libraqm, namely `a BCP47
language code <https://www.w3.org/International/articles/language-tags/>`_.
Expand All @@ -51,20 +43,8 @@ def layout(string, font, *, features=None, kern_mode=Kerning.DEFAULT, language=N
------
LayoutItem
"""
x = 0
prev_glyph_index = None
char_to_font = font._get_fontmap(string) # TODO: Pass in features and language.
base_font = font
for char in string:
# This has done the fallback logic
font = char_to_font.get(char, base_font)
glyph_index = font.get_char_index(ord(char))
kern = (
base_font.get_kerning(prev_glyph_index, glyph_index, kern_mode) / 64
if prev_glyph_index is not None else 0.
)
x += kern
glyph = font.load_glyph(glyph_index, flags=LoadFlags.NO_HINTING)
yield LayoutItem(font, char, glyph_index, x, kern)
x += glyph.linearHoriAdvance / 65536
prev_glyph_index = glyph_index
for raqm_item in font._layout(string, LoadFlags.NO_HINTING,
features=features, language=language):
raqm_item.ft_object.load_glyph(raqm_item.glyph_index,
flags=LoadFlags.NO_HINTING)
yield raqm_item
18 changes: 15 additions & 3 deletions lib/matplotlib/backends/_backend_pdf_ps.py
Original file line number Diff line number Diff line change
Expand Up @@ -199,7 +199,10 @@ def __init__(self, subset_size: int = 0):
self.glyph_maps: dict[str, GlyphMap] = {}
self.subset_size = subset_size

def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]:
def track(self, font: FT2Font, s: str,
features: tuple[str, ...] | None = ...,
language: str | tuple[tuple[str, int, int], ...] | None = None
) -> list[tuple[int, CharacterCodeType]]:
"""
Record that string *s* is being typeset using font *font*.

Expand All @@ -209,6 +212,14 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]:
A font that is being used for the provided string.
s : str
The string that should be marked as tracked by the provided font.
features : tuple[str, ...], optional
The font feature tags to use for the font.

Available font feature tags may be found at
https://learn.microsoft.com/en-us/typography/opentype/spec/featurelist
language : str, optional
The language of the text in a format accepted by libraqm, namely `a BCP47
language code <https://www.w3.org/International/articles/language-tags/>`_.

Returns
-------
Expand All @@ -220,8 +231,9 @@ def track(self, font: FT2Font, s: str) -> list[tuple[int, CharacterCodeType]]:
and the character codes will be returned from the string unchanged.
"""
return [
self.track_glyph(f, ord(c), f.get_char_index(ord(c)))
for c, f in font._get_fontmap(s).items()
self.track_glyph(raqm_item.ft_object, raqm_item.char, raqm_item.glyph_index)
for raqm_item in font._layout(s, ft2font.LoadFlags.NO_HINTING,
features=features, language=language)
]

def track_glyph(self, font: FT2Font, chars: str | CharacterCodeType,
Expand Down
23 changes: 16 additions & 7 deletions lib/matplotlib/backends/backend_pdf.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
from matplotlib.figure import Figure
from matplotlib.font_manager import get_font, fontManager as _fontManager
from matplotlib._afm import AFM
from matplotlib.ft2font import FT2Font, FaceFlags, Kerning, LoadFlags, StyleFlags
from matplotlib.ft2font import FT2Font, FaceFlags, LoadFlags, StyleFlags
from matplotlib.transforms import Affine2D, BboxBase
from matplotlib.path import Path
from matplotlib.dates import UTC
Expand Down Expand Up @@ -469,6 +469,7 @@ class Op(Enum):
textpos = b'Td'
selectfont = b'Tf'
textmatrix = b'Tm'
textrise = b'Ts'
show = b'Tj'
showkern = b'TJ'
setlinewidth = b'w'
Expand Down Expand Up @@ -2285,6 +2286,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# If fonttype is neither 3 nor 42, emit the whole string at once
# without manual kerning.
if fonttype not in [3, 42]:
if not mpl.rcParams['pdf.use14corefonts']:
self.file._character_tracker.track(font, s,
features=features, language=language)
self.file.output(Op.begin_text,
self.file.fontName(prop), fontsize, Op.selectfont)
self._setup_textpos(x, y, angle)
Expand All @@ -2305,13 +2309,16 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
# kerning between chunks.
else:
def output_singlebyte_chunk(kerns_or_chars):
if not kerns_or_chars:
return
self.file.output(
# See pdf spec "Text space details" for the 1000/fontsize
# (aka. 1000/T_fs) factor.
[(-1000 * next(group) / fontsize) if tp == float # a kern
else self._encode_glyphs(group, fonttype)
for tp, group in itertools.groupby(kerns_or_chars, type)],
Op.showkern)
kerns_or_chars.clear()
# Do the rotation and global translation as a single matrix
# concatenation up front
self.file.output(Op.gsave)
Expand All @@ -2326,24 +2333,26 @@ def output_singlebyte_chunk(kerns_or_chars):
# Emit all the characters in a BT/ET group.
self.file.output(Op.begin_text)
for item in _text_helpers.layout(s, font, features=features,
kern_mode=Kerning.UNFITTED,
language=language):
subset, charcode = self.file._character_tracker.track_glyph(
item.ft_object, item.char, item.glyph_index)
if (item.ft_object, subset) != prev_font:
if singlebyte_chunk:
output_singlebyte_chunk(singlebyte_chunk)
output_singlebyte_chunk(singlebyte_chunk)
ft_name = self.file.fontName(item.ft_object.fname, subset)
self.file.output(ft_name, fontsize, Op.selectfont)
self._setup_textpos(item.x, 0, 0, prev_start_x, 0, 0)
singlebyte_chunk = []
prev_font = (item.ft_object, subset)
prev_start_x = item.x
if item.y:
output_singlebyte_chunk(singlebyte_chunk)
self.file.output(item.y, Op.textrise)
if item.prev_kern:
singlebyte_chunk.append(item.prev_kern)
singlebyte_chunk.append(charcode)
if singlebyte_chunk:
output_singlebyte_chunk(singlebyte_chunk)
if item.y:
output_singlebyte_chunk(singlebyte_chunk)
self.file.output(0, Op.textrise)
output_singlebyte_chunk(singlebyte_chunk)
self.file.output(Op.end_text)
self.file.output(Op.grestore)

Expand Down
13 changes: 6 additions & 7 deletions lib/matplotlib/backends/backend_ps.py
Original file line number Diff line number Diff line change
Expand Up @@ -776,7 +776,7 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None):
if ismath:
return self.draw_mathtext(gc, x, y, s, prop, angle)

stream = [] # list of (ps_name, x, char_name)
stream = [] # list of (ps_name, x, y, char_name)

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

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

for ps_name, group in itertools. \
groupby(stream, lambda entry: entry[0]):
for ps_name, group in itertools.groupby(stream, lambda entry: entry[0]):
self.set_font(ps_name, prop.get_size_in_points(), False)
thetext = "\n".join(f"{x:g} 0 m /{name:s} glyphshow"
for _, x, name in group)
thetext = "\n".join(f"{x:g} {y:g} m /{name:s} glyphshow"
for _, x, y, name in group)
self._pswriter.write(f"""\
gsave
{self._get_clip_cmd(gc)}
Expand Down
8 changes: 7 additions & 1 deletion lib/matplotlib/backends/backend_svg.py
Original file line number Diff line number Diff line change
Expand Up @@ -1048,6 +1048,11 @@ def _draw_text_as_path(self, gc, x, y, s, prop, angle, ismath, mtext=None):
text2path = self._text2path
color = rgb2hex(gc.get_rgb())
fontsize = prop.get_size_in_points()
if mtext is not None:
features = mtext.get_fontfeatures()
language = mtext.get_language()
else:
features = language = None

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

for glyph_repr, xposition, yposition, scale in glyph_info:
Expand Down
1 change: 0 additions & 1 deletion lib/matplotlib/ft2font.pyi
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,6 @@ class FT2Font(Buffer):
) -> None: ...
if sys.version_info[:2] >= (3, 12):
def __buffer__(self, flags: int) -> memoryview: ...
def _get_fontmap(self, string: str) -> dict[str, FT2Font]: ...
def _layout(
self,
text: str,
Expand Down
18 changes: 9 additions & 9 deletions lib/matplotlib/tests/test_ft2font.py
Original file line number Diff line number Diff line change
Expand Up @@ -972,7 +972,7 @@ def test_fallback_last_resort(recwarn):
"Glyph 128579 (\\N{UPSIDE-DOWN FACE}) missing from font(s)")


def test__get_fontmap():
def test__layout():
fonts, test_str = _gen_multi_font_text()
# Add some glyphs that don't exist in either font to check the Last Resort fallback.
missing_glyphs = '\n几个汉字'
Expand All @@ -981,11 +981,11 @@ def test__get_fontmap():
ft = fm.get_font(
fm.fontManager._find_fonts_by_props(fm.FontProperties(family=fonts))
)
fontmap = ft._get_fontmap(test_str)
for char, font in fontmap.items():
if char in missing_glyphs:
assert Path(font.fname).name == 'LastResortHE-Regular.ttf'
elif ord(char) > 127:
assert Path(font.fname).name == 'DejaVuSans.ttf'
else:
assert Path(font.fname).name == 'cmr10.ttf'
for substr in test_str.split('\n'):
for item in ft._layout(substr, ft2font.LoadFlags.DEFAULT):
if item.char in missing_glyphs:
assert Path(item.ft_object.fname).name == 'LastResortHE-Regular.ttf'
elif ord(item.char) > 127:
assert Path(item.ft_object.fname).name == 'DejaVuSans.ttf'
else:
assert Path(item.ft_object.fname).name == 'cmr10.ttf'
8 changes: 5 additions & 3 deletions lib/matplotlib/tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@ def find_matplotlib_font(**kw):
ax.set_yticks([])


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


@image_comparison(baseline_images=['features.png'], remove_text=False, style='mpl20')
@image_comparison(['features'], remove_text=False, style='mpl20',
extensions=['png', 'pdf', 'svg', 'eps'])
def test_text_features():
fig = plt.figure(figsize=(5, 1.5))
t = fig.text(1, 0.7, 'Default: fi ffi fl st',
Expand Down Expand Up @@ -1270,7 +1271,8 @@ def test_text_language_invalid(input, match):
Text(0, 0, 'foo', language=input)


@image_comparison(baseline_images=['language.png'], remove_text=False, style='mpl20')
@image_comparison(['language'], remove_text=False, style='mpl20',
extensions=['png', 'pdf', 'svg', 'eps'])
def test_text_language():
fig = plt.figure(figsize=(5, 3))

Expand Down
3 changes: 2 additions & 1 deletion lib/matplotlib/textpath.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,15 +147,16 @@ def get_glyphs_with_font(self, font, s, glyph_map=None,
glyph_map_new = glyph_map

xpositions = []
ypositions = []
glyph_reprs = []
for item in _text_helpers.layout(s, font, features=features, language=language):
glyph_repr = self._get_glyph_repr(item.ft_object, item.glyph_index)
glyph_reprs.append(glyph_repr)
xpositions.append(item.x)
ypositions.append(item.y)
if glyph_repr not in glyph_map:
glyph_map_new[glyph_repr] = item.ft_object.get_path()

ypositions = [0] * len(xpositions)
sizes = [1.] * len(xpositions)

rects = []
Expand Down
50 changes: 0 additions & 50 deletions src/ft2font_wrapper.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -623,54 +623,6 @@ PyFT2Font_get_kerning(PyFT2Font *self, FT_UInt left, FT_UInt right,
return self->get_kerning(left, right, mode);
}

const char *PyFT2Font_get_fontmap__doc__ = R"""(
Get a mapping between characters and the font that includes them.

.. warning::
This API uses the fallback list and is both private and provisional: do not use
it directly.

Parameters
----------
text : str
The characters for which to find fonts.

Returns
-------
dict[str, FT2Font]
A dictionary mapping unicode characters to `.FT2Font` objects.
)""";

static py::dict
PyFT2Font_get_fontmap(PyFT2Font *self, std::u32string text)
{
std::set<FT_ULong> codepoints;

py::dict char_to_font;
for (auto code : text) {
if (!codepoints.insert(code).second) {
continue;
}

py::object target_font;
int index;
if (self->get_char_fallback_index(code, index)) {
if (index >= 0) {
target_font = self->fallbacks[index];
} else {
target_font = py::cast(self);
}
} else {
// TODO Handle recursion!
target_font = py::cast(self);
}

auto key = py::cast(std::u32string(1, code));
char_to_font[key] = target_font;
}
return char_to_font;
}

const char *PyFT2Font_set_text__doc__ = R"""(
Set the text *string* and *angle*.

Expand Down Expand Up @@ -1705,8 +1657,6 @@ PYBIND11_MODULE(ft2font, m, py::mod_gil_not_used())
"string"_a, "angle"_a=0.0, "flags"_a=LoadFlags::FORCE_AUTOHINT, py::kw_only(),
"features"_a=nullptr, "language"_a=nullptr,
PyFT2Font_set_text__doc__)
.def("_get_fontmap", &PyFT2Font_get_fontmap, "string"_a,
PyFT2Font_get_fontmap__doc__)
.def("get_num_glyphs", &PyFT2Font::get_num_glyphs,
PyFT2Font_get_num_glyphs__doc__)
.def("load_char", &PyFT2Font_load_char,
Expand Down
Loading