diff --git a/lib/matplotlib/_text_layout.py b/lib/matplotlib/_text_layout.py index e9fed131677d..c7a87e848688 100644 --- a/lib/matplotlib/_text_layout.py +++ b/lib/matplotlib/_text_layout.py @@ -2,9 +2,15 @@ Text layouting utilities. """ +import dataclasses + from .ft2font import KERNING_DEFAULT, LOAD_NO_HINTING +LayoutItem = dataclasses.make_dataclass( + "LayoutItem", ["char", "glyph_idx", "x", "prev_kern"]) + + def layout(string, font, *, kern_mode=KERNING_DEFAULT): """ Render *string* with *font*. For each character in *string*, yield a @@ -26,13 +32,13 @@ def layout(string, font, *, kern_mode=KERNING_DEFAULT): x_position : float """ x = 0 - last_glyph_idx = None + prev_glyph_idx = None for char in string: glyph_idx = font.get_char_index(ord(char)) - kern = (font.get_kerning(last_glyph_idx, glyph_idx, kern_mode) - if last_glyph_idx is not None else 0) / 64 + kern = (font.get_kerning(prev_glyph_idx, glyph_idx, kern_mode) / 64 + if prev_glyph_idx is not None else 0.) x += kern glyph = font.load_glyph(glyph_idx, flags=LOAD_NO_HINTING) - yield glyph_idx, x + yield LayoutItem(char, glyph_idx, x, kern) x += glyph.linearHoriAdvance / 65536 - last_glyph_idx = glyph_idx + prev_glyph_idx = glyph_idx diff --git a/lib/matplotlib/backends/backend_pdf.py b/lib/matplotlib/backends/backend_pdf.py index d52b41829c64..311092c81e4d 100644 --- a/lib/matplotlib/backends/backend_pdf.py +++ b/lib/matplotlib/backends/backend_pdf.py @@ -2281,21 +2281,23 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): # complication is avoided, but of course, those fonts can not be # subsetted.) else: - singlebyte_chunks = [] # List of (start_x, list-of-1-byte-chars). - multibyte_glyphs = [] # List of (start_x, glyph_index). - prev_was_singlebyte = False - for char, (glyph_idx, glyph_x) in zip( - s, - _text_layout.layout(s, font, kern_mode=KERNING_UNFITTED)): - if ord(char) <= 255: - if prev_was_singlebyte: - singlebyte_chunks[-1][1].append(char) - else: - singlebyte_chunks.append((glyph_x, [char])) - prev_was_singlebyte = True + # List of (start_x, [prev_kern, char, char, ...]), w/o zero kerns. + singlebyte_chunks = [] + # List of (start_x, glyph_index). + multibyte_glyphs = [] + prev_was_multibyte = True + for item in _text_layout.layout( + s, font, kern_mode=KERNING_UNFITTED): + if ord(item.char) <= 255: + if prev_was_multibyte: + singlebyte_chunks.append((item.x, [])) + if item.prev_kern: + singlebyte_chunks[-1][1].append(item.prev_kern) + singlebyte_chunks[-1][1].append(item.char) + prev_was_multibyte = False else: - multibyte_glyphs.append((glyph_x, glyph_idx)) - prev_was_singlebyte = False + multibyte_glyphs.append((item.x, item.glyph_idx)) + prev_was_multibyte = True # Do the rotation and global translation as a single matrix # concatenation up front self.file.output(Op.gsave) @@ -2307,10 +2309,15 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): self.file.output(Op.begin_text, self.file.fontName(prop), fontsize, Op.selectfont) prev_start_x = 0 - for start_x, chars in singlebyte_chunks: + for start_x, kerns_or_chars in singlebyte_chunks: self._setup_textpos(start_x, 0, 0, prev_start_x, 0, 0) - self.file.output(self.encode_string(''.join(chars), fonttype), - Op.show) + 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_string("".join(group), fonttype) + for tp, group in itertools.groupby(kerns_or_chars, type)], + Op.showkern) prev_start_x = start_x self.file.output(Op.end_text) # Then emit all the multibyte characters, one at a time. diff --git a/lib/matplotlib/backends/backend_ps.py b/lib/matplotlib/backends/backend_ps.py index 350785164a10..0be9bcc5cfb3 100644 --- a/lib/matplotlib/backends/backend_ps.py +++ b/lib/matplotlib/backends/backend_ps.py @@ -585,8 +585,9 @@ def draw_text(self, gc, x, y, s, prop, angle, ismath=False, mtext=None): self.set_font(ps_name, prop.get_size_in_points()) thetext = '\n'.join( - '%f 0 m /%s glyphshow' % (x, font.get_glyph_name(glyph_idx)) - for glyph_idx, x in _text_layout.layout(s, font)) + '{:f} 0 m /{:s} glyphshow' + .format(item.x, font.get_glyph_name(item.glyph_idx)) + for item in _text_layout.layout(s, font)) self._pswriter.write(f"""\ gsave {x:f} {y:f} translate diff --git a/lib/matplotlib/tests/baseline_images/test_backend_pdf/kerning.pdf b/lib/matplotlib/tests/baseline_images/test_backend_pdf/kerning.pdf new file mode 100644 index 000000000000..90bf2a5c9845 Binary files /dev/null and b/lib/matplotlib/tests/baseline_images/test_backend_pdf/kerning.pdf differ diff --git a/lib/matplotlib/tests/test_backend_pdf.py b/lib/matplotlib/tests/test_backend_pdf.py index e5adfa6fb4f1..ad2e54c86244 100644 --- a/lib/matplotlib/tests/test_backend_pdf.py +++ b/lib/matplotlib/tests/test_backend_pdf.py @@ -271,3 +271,11 @@ def test_empty_rasterized(): fig, ax = plt.subplots() ax.plot([], [], rasterized=True) fig.savefig(io.BytesIO(), format="pdf") + + +@image_comparison(['kerning.pdf']) +def test_kerning(): + fig = plt.figure() + s = "AVAVAVAVAVAVAVAV€AAVV" + fig.text(0, .25, s, size=5) + fig.text(0, .75, s, size=20) diff --git a/lib/matplotlib/textpath.py b/lib/matplotlib/textpath.py index 8f0f2d7b434b..424d4df7da94 100644 --- a/lib/matplotlib/textpath.py +++ b/lib/matplotlib/textpath.py @@ -149,10 +149,10 @@ def get_glyphs_with_font(self, font, s, glyph_map=None, xpositions = [] glyph_ids = [] - for char, (_, x) in zip(s, _text_layout.layout(s, font)): - char_id = self._get_char_id(font, ord(char)) + for item in _text_layout.layout(s, font): + char_id = self._get_char_id(font, ord(item.char)) glyph_ids.append(char_id) - xpositions.append(x) + xpositions.append(item.x) if char_id not in glyph_map: glyph_map_new[char_id] = font.get_path()