From 1e1883be9796823fb00c0a5a57abac2e71479ae1 Mon Sep 17 00:00:00 2001 From: Antony Lee Date: Wed, 19 Aug 2020 19:24:47 +0200 Subject: [PATCH] Include kerning when outputting pdf strings. --- lib/matplotlib/_text_layout.py | 16 ++++--- lib/matplotlib/backends/backend_pdf.py | 41 ++++++++++-------- lib/matplotlib/backends/backend_ps.py | 5 ++- .../test_backend_pdf/kerning.pdf | Bin 0 -> 3807 bytes lib/matplotlib/tests/test_backend_pdf.py | 8 ++++ lib/matplotlib/textpath.py | 6 +-- 6 files changed, 49 insertions(+), 27 deletions(-) create mode 100644 lib/matplotlib/tests/baseline_images/test_backend_pdf/kerning.pdf 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 0000000000000000000000000000000000000000..90bf2a5c984571b79bc95a473afaef364e60b0d6 GIT binary patch literal 3807 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